From 8dc77faf51719ff4188accfaa3ae916cf0e2539c Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Thu, 9 Jul 2026 12:16:15 -0400 Subject: [PATCH] Import enabled plugins from known marketplaces (#31672) ## Summary - discover enabled-plugin marketplace sources from the user-level known marketplace registry - preserve scoped settings and supported Git/directory declarations, falling back to the matching registry `installLocation` for file, URL, npm-hosted, and inline marketplace sources - resolve relative registry materialization paths against the external-agent home - reuse the existing marketplace add and plugin install flow during import - emit `plugin_not_found` tracing and analytics with plugin and marketplace identity ## Why Enabled plugins can remain in settings after their marketplace source has moved into the user-level registry. Import detection previously skipped those plugins because it only consulted settings-defined marketplace sources. ## Impact `/import` can now restore those marketplaces and install their enabled plugins through the normal plugin installer, including npm, Git, and local plugin sources. Missing plugins remain visible in import results and the plugin-install failure analytics stream. --- .../analytics/src/analytics_client_tests.rs | 7 + codex-rs/analytics/src/client.rs | 9 +- codex-rs/analytics/src/events.rs | 1 + codex-rs/analytics/src/facts.rs | 8 + codex-rs/analytics/src/lib.rs | 1 + codex-rs/analytics/src/reducer.rs | 7 +- .../src/config/external_agent_config.rs | 253 ++++++++++++++---- .../src/config/external_agent_config_tests.rs | 175 +++++++++++- .../external_agent_config_processor.rs | 4 +- .../src/request_processors/plugins.rs | 8 +- .../tests/suite/v2/external_agent_config.rs | 129 +++++++-- .../tests/suite/v2/plugin_install.rs | 3 + codex-rs/core-plugins/src/manager.rs | 9 + 13 files changed, 531 insertions(+), 83 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 36916534c6..7b878e952c 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -66,6 +66,7 @@ use crate::facts::PluginInstallRequestSource; use crate::facts::PluginInstallRequested; use crate::facts::PluginInstallRequestedInput; use crate::facts::PluginInstallRequestedPlugin; +use crate::facts::PluginInstallSource; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -3242,6 +3243,7 @@ fn plugin_install_failed_event_serializes_expected_shape() { event_type: "codex_plugin_install_failed", event_params: CodexPluginInstallFailedMetadata { plugin: codex_plugin_metadata(sample_plugin_metadata()), + source: PluginInstallSource::Manual, error_type: "store_io".to_string(), }, }); @@ -3261,6 +3263,7 @@ fn plugin_install_failed_event_serializes_expected_shape() { "mcp_server_count": 2, "connector_ids": ["calendar", "drive"], "product_client_id": originator().value, + "source": "manual", "error_type": "store_io" } }) @@ -3692,6 +3695,7 @@ async fn reducer_ingests_plugin_install_failed_fact() { AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed( PluginInstallFailedInput { plugin: sample_plugin_metadata(), + source: PluginInstallSource::ExternalAgentMigration, error_type: "invalid_plugin".to_string(), }, )), @@ -3713,6 +3717,7 @@ async fn reducer_ingests_plugin_install_failed_fact() { "mcp_server_count": 2, "connector_ids": ["calendar", "drive"], "product_client_id": originator().value, + "source": "external_agent_migration", "error_type": "invalid_plugin" } }]) @@ -3734,6 +3739,7 @@ async fn reducer_ingests_plugin_install_failed_fact_without_detail() { AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed( PluginInstallFailedInput { plugin, + source: PluginInstallSource::Manual, error_type: "remote_catalog_unexpected_status".to_string(), }, )), @@ -3755,6 +3761,7 @@ async fn reducer_ingests_plugin_install_failed_fact_without_detail() { "mcp_server_count": null, "connector_ids": null, "product_client_id": originator().value, + "source": "manual", "error_type": "remote_catalog_unexpected_status" } }]) diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index f957a665a0..1fd77b0560 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -18,6 +18,7 @@ use crate::facts::HookRunInput; use crate::facts::PluginInstallFailedInput; use crate::facts::PluginInstallRequested; use crate::facts::PluginInstallRequestedInput; +use crate::facts::PluginInstallSource; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; @@ -370,10 +371,16 @@ impl AnalyticsEventsClient { )); } - pub fn track_plugin_install_failed(&self, plugin: PluginTelemetryMetadata, error_type: String) { + pub fn track_plugin_install_failed( + &self, + plugin: PluginTelemetryMetadata, + source: PluginInstallSource, + error_type: String, + ) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginInstallFailed(PluginInstallFailedInput { plugin, + source, error_type, }), )); diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index ad8adf14cb..999ae3e1f0 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -991,6 +991,7 @@ pub(crate) struct CodexPluginEventRequest { pub(crate) struct CodexPluginInstallFailedMetadata { #[serde(flatten)] pub(crate) plugin: CodexPluginMetadata, + pub(crate) source: crate::facts::PluginInstallSource, pub(crate) error_type: String, } diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 3346a6e19a..1f3333b8bc 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -579,8 +579,16 @@ pub(crate) struct PluginStateChangedInput { pub state: PluginState, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginInstallSource { + Manual, + ExternalAgentMigration, +} + pub(crate) struct PluginInstallFailedInput { pub plugin: PluginTelemetryMetadata, + pub source: PluginInstallSource, pub error_type: String, } diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 86bc9d6829..d178d8d5bf 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -45,6 +45,7 @@ pub use facts::InvocationType; pub use facts::PluginInstallRequestSource; pub use facts::PluginInstallRequested; pub use facts::PluginInstallRequestedPlugin; +pub use facts::PluginInstallSource; pub use facts::SkillInvocation; pub use facts::SubAgentThreadStartedInput; pub use facts::ThreadInitializationMode; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 79649a553b..13696d670f 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -837,12 +837,17 @@ impl AnalyticsReducer { input: PluginInstallFailedInput, out: &mut Vec, ) { - let PluginInstallFailedInput { plugin, error_type } = input; + let PluginInstallFailedInput { + plugin, + source, + error_type, + } = input; out.push(TrackEventRequest::PluginInstallFailed( CodexPluginInstallFailedEventRequest { event_type: "codex_plugin_install_failed", event_params: CodexPluginInstallFailedMetadata { plugin: codex_plugin_metadata(plugin), + source, error_type, }, }, diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index ca1023e529..fa092cfb3c 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -1,8 +1,12 @@ +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::PluginInstallSource; use codex_config::types::PluginConfig; use codex_core::config::Config; use codex_core::config::ConfigBuilder; +use codex_core_plugins::PluginInstallError; use codex_core_plugins::PluginInstallRequest; use codex_core_plugins::PluginsManager; +use codex_core_plugins::marketplace::MarketplaceError; use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy; use codex_core_plugins::marketplace::find_marketplace_manifest_path; use codex_core_plugins::marketplace_add::MarketplaceAddRequest; @@ -36,6 +40,7 @@ const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.d const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import"; const EXTERNAL_AGENT_DIR: &str = ".claude"; const EXTERNAL_AGENT_CONFIG_MD: &str = "CLAUDE.md"; +const EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH: &str = "plugins/known_marketplaces.json"; const EXTERNAL_OFFICIAL_MARKETPLACE_NAME: &str = "claude-plugins-official"; const EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/claude-plugins-official"; @@ -176,14 +181,16 @@ pub(crate) struct ExternalAgentConfigMigrationItem { pub(crate) struct ExternalAgentConfigService { codex_home: PathBuf, external_agent_home: PathBuf, + analytics_events_client: Option, } impl ExternalAgentConfigService { - pub(crate) fn new(codex_home: PathBuf) -> Self { + pub(crate) fn new(codex_home: PathBuf, analytics_events_client: AnalyticsEventsClient) -> Self { let external_agent_home = default_external_agent_home(); Self { codex_home, external_agent_home, + analytics_events_client: Some(analytics_events_client), } } @@ -192,6 +199,7 @@ impl ExternalAgentConfigService { Self { codex_home, external_agent_home, + analytics_events_client: None, } } @@ -805,9 +813,10 @@ impl ExternalAgentConfigService { configured_plugin_ids: &HashSet, configured_marketplace_plugins: &BTreeMap>, ) -> Option { + let import_sources = self.marketplace_import_sources(settings, source_root); let plugin_details = extract_plugin_migration_details( settings, - source_root, + &import_sources, configured_plugin_ids, configured_marketplace_plugins, )?; @@ -836,7 +845,7 @@ impl ExternalAgentConfigService { ); let source_root = cwd.unwrap_or(self.external_agent_home.as_path()); let import_sources = effective_external_settings(&source_settings)? - .map(|settings| collect_marketplace_import_sources(&settings, source_root)) + .map(|settings| self.marketplace_import_sources(&settings, source_root)) .unwrap_or_default(); let mut local_plugins = Vec::new(); @@ -893,7 +902,19 @@ impl ExternalAgentConfigService { .map_err(|err| io::Error::other(format!("failed to load config: {err}")))?; let requirements = config.config_layer_stack.requirements().clone(); let mut outcome = PluginImportOutcome::default(); - let plugins_manager = PluginsManager::new(self.codex_home.clone()); + let plugins_manager = PluginsManager::new(self.codex_home.clone()) + .with_plugin_install_source(PluginInstallSource::ExternalAgentMigration); + if let Some(analytics_events_client) = self.analytics_events_client.clone() { + plugins_manager.set_analytics_events_client(analytics_events_client); + } + let source_settings = cwd.map_or_else( + || self.external_agent_home.join("settings.json"), + |cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"), + ); + let source_root = cwd.unwrap_or(self.external_agent_home.as_path()); + let import_sources = effective_external_settings(&source_settings)? + .map(|settings| self.marketplace_import_sources(&settings, source_root)) + .unwrap_or_default(); for plugin_group in plugins { let marketplace_name = plugin_group.marketplace_name.clone(); let plugin_names = plugin_group.plugin_names; @@ -901,16 +922,7 @@ impl ExternalAgentConfigService { .iter() .map(|plugin_name| format!("{plugin_name}@{marketplace_name}")) .collect::>(); - let source_settings = cwd.map_or_else( - || self.external_agent_home.join("settings.json"), - |cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"), - ); - let source_root = cwd.unwrap_or(self.external_agent_home.as_path()); - let import_source = - effective_external_settings(&source_settings)?.and_then(|settings| { - collect_marketplace_import_sources(&settings, source_root) - .remove(&marketplace_name) - }); + let import_source = import_sources.get(&marketplace_name).cloned(); let Some(import_source) = import_source else { let message = format!( "external agent plugin marketplace source was not found: {marketplace_name}" @@ -1009,12 +1021,21 @@ impl ExternalAgentConfigService { Err(err) => { let plugin_id = format!("{plugin_name}@{marketplace_name}"); outcome.failed_plugin_ids.push(plugin_id.clone()); - outcome.raw_errors.push(plugin_import_raw_error( + let mut raw_error = plugin_import_raw_error( cwd, "plugin_import", err.to_string(), Some(plugin_id), - )); + ); + if matches!( + err, + PluginInstallError::Marketplace( + MarketplaceError::PluginNotFound { .. } + ) + ) { + raw_error.error_type = Some("plugin_not_found".to_string()); + } + outcome.raw_errors.push(raw_error); } } } @@ -1023,6 +1044,95 @@ impl ExternalAgentConfigService { Ok(outcome) } + fn marketplace_import_sources( + &self, + settings: &JsonValue, + source_root: &Path, + ) -> BTreeMap { + let known_marketplaces_path = self + .external_agent_home + .join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH); + let known_marketplaces = match read_external_settings(&known_marketplaces_path) { + Ok(known_marketplaces) => known_marketplaces, + Err(err) => { + tracing::warn!( + path = %known_marketplaces_path.display(), + error = %err, + "ignoring invalid external agent marketplace registry" + ); + None + } + }; + let mut import_sources = known_marketplaces + .as_ref() + .map(|known_marketplaces| { + collect_marketplace_import_sources( + known_marketplaces, + self.external_agent_home.as_path(), + ) + }) + .unwrap_or_default(); + + if let Some(extra_known_marketplaces) = settings + .as_object() + .and_then(|settings| settings.get("extraKnownMarketplaces")) + { + let mut scoped_marketplaces = extra_known_marketplaces.clone(); + if let Some(scoped_marketplaces) = scoped_marketplaces.as_object_mut() { + for (name, scoped_marketplace) in scoped_marketplaces { + import_sources.remove(name); + let Some(known_marketplace) = known_marketplaces + .as_ref() + .and_then(JsonValue::as_object) + .and_then(|known_marketplaces| known_marketplaces.get(name)) + else { + continue; + }; + if scoped_marketplace.get("source") != known_marketplace.get("source") { + continue; + } + let Some(install_location) = known_marketplace + .get("installLocation") + .and_then(JsonValue::as_str) + else { + continue; + }; + let install_location = Path::new(install_location); + let install_location = if install_location.is_absolute() { + install_location.to_path_buf() + } else { + self.external_agent_home.join(install_location) + }; + let Some(scoped_marketplace) = scoped_marketplace.as_object_mut() else { + continue; + }; + scoped_marketplace.insert( + "installLocation".to_string(), + JsonValue::String(install_location.display().to_string()), + ); + } + } + import_sources.extend(collect_marketplace_import_sources( + &scoped_marketplaces, + source_root, + )); + } + + if has_enabled_plugin_for_marketplace(settings, EXTERNAL_OFFICIAL_MARKETPLACE_NAME) + && !import_sources.contains_key(EXTERNAL_OFFICIAL_MARKETPLACE_NAME) + { + import_sources.insert( + EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(), + MarketplaceImportSource { + source: EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE.to_string(), + ref_name: None, + }, + ); + } + + import_sources + } + fn import_config(&self, cwd: Option<&Path>) -> io::Result> { let repo_root = find_repo_root(cwd)?; let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() { @@ -1321,16 +1431,16 @@ fn merge_json_settings(existing: &mut JsonValue, incoming: &JsonValue) { } fn extract_plugin_migration_details( settings: &JsonValue, - source_root: &Path, + import_sources: &BTreeMap, configured_plugin_ids: &HashSet, configured_marketplace_plugins: &BTreeMap>, ) -> Option { - let loadable_marketplaces = collect_marketplace_import_sources(settings, source_root) - .into_iter() + let loadable_marketplaces = import_sources + .iter() .filter_map(|(marketplace_name, source)| { - is_local_marketplace_source(&source.source, source.ref_name) + is_local_marketplace_source(&source.source, source.ref_name.clone()) .ok() - .map(|_| marketplace_name) + .map(|_| marketplace_name.clone()) }) .collect::>(); let mut plugins = BTreeMap::new(); @@ -1345,9 +1455,19 @@ fn extract_plugin_migration_details( configured_marketplace_plugins.get(&plugin_id.marketplace_name) { if !installable_plugins.contains(&plugin_id.plugin_name) { + tracing::warn!( + plugin_id = %plugin_id.as_key(), + marketplace_name = %plugin_id.marketplace_name, + "enabled external agent plugin was not found in configured marketplace" + ); continue; } } else if !loadable_marketplaces.contains(&plugin_id.marketplace_name) { + tracing::warn!( + plugin_id = %plugin_id.as_key(), + marketplace_name = %plugin_id.marketplace_name, + "marketplace source was not found for enabled external agent plugin" + ); continue; } let plugin_group = plugins @@ -1444,13 +1564,11 @@ fn configured_marketplace_plugins( } fn collect_marketplace_import_sources( - settings: &JsonValue, + marketplaces: &JsonValue, source_root: &Path, ) -> BTreeMap { - let mut import_sources: BTreeMap = settings + marketplaces .as_object() - .and_then(|settings| settings.get("extraKnownMarketplaces")) - .and_then(JsonValue::as_object) .map(|extra_known_marketplaces| { extra_known_marketplaces .iter() @@ -1462,46 +1580,69 @@ fn collect_marketplace_import_sources( } else { value.as_object()? }; - let source = source_fields - .get("repo") - .or_else(|| source_fields.get("url")) - .or_else(|| source_fields.get("path")) - .or_else(|| value.get("source"))? - .as_str()? - .trim() - .to_string(); - if source.is_empty() { - return None; + let source_kind = source_fields + .get("source") + .and_then(JsonValue::as_str) + .map(str::trim); + let declared_source = match source_kind { + Some("github") => source_fields.get("repo"), + Some("git") => source_fields.get("url"), + Some("directory" | "local") => source_fields.get("path"), + Some("file" | "url" | "npm" | "settings") => None, + Some(_) => source_fields.get("source"), + None => source_fields + .get("repo") + .or_else(|| source_fields.get("url")) + .or_else(|| source_fields.get("path")) + .or_else(|| value.get("source")), } - let source = resolve_external_marketplace_source(&source, source_root); - - let ref_name = source_fields - .get("ref") - .or_else(|| value.get("ref")) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + let materialized_source = value + .get("installLocation") .and_then(JsonValue::as_str) .map(str::trim) .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned); + .and_then(|value| { + let path = Path::new(value); + let path = if path.is_absolute() { + path.to_path_buf() + } else { + source_root.join(path) + }; + path.is_dir().then(|| path.display().to_string()) + }); + let (source, ref_name) = if let Some(source) = declared_source { + let source = if matches!(source_kind, Some("directory" | "local")) { + let path = Path::new(source); + if path.is_absolute() { + path.to_path_buf() + } else { + source_root.join(path) + } + .display() + .to_string() + } else { + resolve_external_marketplace_source(source, source_root) + }; + let ref_name = source_fields + .get("ref") + .or_else(|| value.get("ref")) + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned); + (source, ref_name) + } else { + (materialized_source?, None) + }; Some((name.clone(), MarketplaceImportSource { source, ref_name })) }) .collect() }) - .unwrap_or_default(); - - if has_enabled_plugin_for_marketplace(settings, EXTERNAL_OFFICIAL_MARKETPLACE_NAME) - && !import_sources.contains_key(EXTERNAL_OFFICIAL_MARKETPLACE_NAME) - { - import_sources.insert( - EXTERNAL_OFFICIAL_MARKETPLACE_NAME.to_string(), - MarketplaceImportSource { - source: EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE.to_string(), - ref_name: None, - }, - ); - } - - import_sources + .unwrap_or_default() } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index 9ab67a1379..9ecf0986ca 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -39,6 +39,7 @@ fn assert_single_plugin_raw_error( raw_errors: &[ExternalAgentConfigImportRawError], failure_stage: &str, source: &str, + error_type: Option<&str>, ) { assert_eq!(raw_errors.len(), 1); let raw_error = &raw_errors[0]; @@ -47,7 +48,7 @@ fn assert_single_plugin_raw_error( ExternalAgentConfigMigrationItemType::Plugins ); assert_eq!(raw_error.failure_stage, failure_stage); - assert_eq!(raw_error.error_type, None); + assert_eq!(raw_error.error_type.as_deref(), error_type); assert_eq!(raw_error.cwd, None); assert_eq!(raw_error.source.as_deref(), Some(source)); assert!(!raw_error.message.is_empty()); @@ -1742,6 +1743,169 @@ async fn detect_home_lists_enabled_plugins_from_settings() { ); } +#[tokio::test] +async fn detect_home_uses_materialized_known_marketplace_for_inline_npm_source() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + let marketplace_root = external_agent_home + .join("plugins") + .join("marketplaces") + .join("acme-tools"); + fs::create_dir_all(external_agent_home.join("plugins")) + .expect("create external agent plugins dir"); + fs::create_dir_all(&marketplace_root).expect("create installed marketplace dir"); + fs::write( + external_agent_home.join("settings.json"), + r#"{ + "enabledPlugins": { + "formatter@acme-tools": true + }, + "extraKnownMarketplaces": { + "acme-tools": { + "source": { + "source": "settings", + "name": "acme-tools", + "plugins": [{ + "name": "formatter", + "source": { + "source": "npm", + "package": "@acme/formatter" + } + }] + } + } + } + }"#, + ) + .expect("write settings"); + fs::write( + external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH), + serde_json::to_string_pretty(&serde_json::json!({ + "acme-tools": { + "source": { + "source": "settings", + "name": "acme-tools", + "plugins": [{ + "name": "formatter", + "source": { + "source": "npm", + "package": "@acme/formatter", + }, + }], + }, + "installLocation": "plugins/marketplaces/acme-tools", + "lastUpdated": "2026-07-09T00:16:23.611Z", + } + })) + .expect("serialize known marketplaces"), + ) + .expect("write known marketplaces"); + + let items = service_for_paths(external_agent_home.clone(), codex_home) + .detect(ExternalAgentConfigDetectOptions { + include_home: true, + cwds: None, + }) + .await + .expect("detect"); + + assert_eq!( + items, + vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: format!( + "Migrate enabled plugins from {}", + external_agent_home.join("settings.json").display() + ), + cwd: None, + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "acme-tools".to_string(), + plugin_names: vec!["formatter".to_string()], + }], + ..Default::default() + }), + }] + ); +} + +#[test] +fn marketplace_import_sources_prefers_scoped_source_over_registry_name_collision() { + let (root, external_agent_home, codex_home) = fixture_paths(); + let source_root = root.path().join("repo"); + let scoped_marketplace = source_root.join("repo-marketplace"); + let cached_marketplace = external_agent_home.join("plugins/marketplaces/debug"); + fs::create_dir_all(&scoped_marketplace).expect("create scoped marketplace"); + fs::create_dir_all(&cached_marketplace).expect("create cached marketplace"); + fs::write( + external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH), + serde_json::to_string_pretty(&serde_json::json!({ + "debug": { + "source": { + "source": "github", + "repo": "acme/global-marketplace", + }, + "installLocation": cached_marketplace, + } + })) + .expect("serialize known marketplaces"), + ) + .expect("write known marketplaces"); + let settings = serde_json::json!({ + "extraKnownMarketplaces": { + "debug": { + "source": { + "source": "directory", + "path": "./repo-marketplace", + } + } + } + }); + + let import_sources = service_for_paths(external_agent_home, codex_home) + .marketplace_import_sources(&settings, &source_root); + + assert_eq!( + import_sources.get("debug"), + Some(&MarketplaceImportSource { + source: source_root.join("./repo-marketplace").display().to_string(), + ref_name: None, + }) + ); +} + +#[test] +fn marketplace_import_sources_prefers_supported_declaration_over_materialization() { + let (_root, external_agent_home, codex_home) = fixture_paths(); + let cached_marketplace = external_agent_home.join("plugins/marketplaces/acme-tools"); + fs::create_dir_all(&cached_marketplace).expect("create cached marketplace"); + fs::write( + external_agent_home.join(EXTERNAL_AGENT_KNOWN_MARKETPLACES_PATH), + serde_json::to_string_pretty(&serde_json::json!({ + "acme-tools": { + "source": { + "source": "git", + "url": "https://git.example.com/acme/tools.git", + "ref": "release", + }, + "installLocation": cached_marketplace, + } + })) + .expect("serialize known marketplaces"), + ) + .expect("write known marketplaces"); + + let import_sources = service_for_paths(external_agent_home.clone(), codex_home) + .marketplace_import_sources(&serde_json::json!({}), &external_agent_home); + + assert_eq!( + import_sources.get("acme-tools"), + Some(&MarketplaceImportSource { + source: "https://git.example.com/acme/tools.git".to_string(), + ref_name: Some("release".to_string()), + }) + ); +} + #[tokio::test] async fn detect_home_plugins_uses_local_settings_over_project_settings() { let (_root, external_agent_home, codex_home) = fixture_paths(); @@ -2255,6 +2419,7 @@ async fn import_plugins_requires_source_marketplace_details() { &outcome.raw_errors, "plugin_import", "formatter@other-tools", + /*error_type*/ None, ); } @@ -2290,7 +2455,12 @@ async fn import_plugins_defers_marketplace_source_validation_to_add_marketplace( outcome.failed_plugin_ids, vec!["formatter@acme-tools".to_string()] ); - assert_single_plugin_raw_error(&outcome.raw_errors, "plugin_import", "formatter@acme-tools"); + assert_single_plugin_raw_error( + &outcome.raw_errors, + "plugin_import", + "formatter@acme-tools", + /*error_type*/ None, + ); } #[tokio::test] @@ -2610,6 +2780,7 @@ async fn import_plugins_infers_external_official_marketplace_when_missing_from_s &outcome.raw_errors, "plugin_import", &format!("sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}"), + Some("plugin_not_found"), ); } diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 5bae994ca5..c71abcb884 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -96,9 +96,11 @@ impl ExternalAgentConfigRequestProcessor { config_manager, arg0_paths, ); + let migration_service = + ExternalAgentConfigService::new(codex_home, analytics_events_client.clone()); Self { outgoing, - migration_service: ExternalAgentConfigService::new(codex_home), + migration_service, session_importer, thread_manager, config_processor, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index ed8495d602..688cfae917 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -1,6 +1,7 @@ use super::*; use crate::error_code::internal_error; use crate::error_code::invalid_request; +use codex_analytics::PluginInstallSource; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginSharePrincipalRole; @@ -1760,8 +1761,11 @@ impl PluginRequestProcessor { capability_summary: None, } }; - self.analytics_events_client - .track_plugin_install_failed(plugin, error_type.to_string()); + self.analytics_events_client.track_plugin_install_failed( + plugin, + PluginInstallSource::Manual, + error_type.to_string(), + ); } async fn plugin_apps_needing_auth_for_install( diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index e83b636618..53c50ea43f 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -418,9 +418,18 @@ async fn external_agent_config_import_completed_tracks_analytics_event() -> Resu } #[tokio::test] -async fn external_agent_config_import_sends_completion_notification_for_local_plugins() -> Result<()> -{ +async fn external_agent_config_import_reinstalls_plugins_from_known_marketplaces() -> Result<()> { let codex_home = TempDir::new()?; + let analytics_server = start_analytics_events_server().await?; + write_analytics_config(codex_home.path(), &analytics_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 marketplace_root = codex_home.path().join("marketplace"); let plugin_root = marketplace_root.join("plugins").join("sample"); std::fs::create_dir_all(marketplace_root.join(".agents/plugins"))?; @@ -445,15 +454,18 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl r#"{"name":"sample","version":"0.1.0"}"#, )?; let source_home = external_agent_home(codex_home.path()); - std::fs::create_dir_all(&source_home)?; + std::fs::create_dir_all(source_home.join("plugins"))?; let settings = serde_json::json!({ "enabledPlugins": { - "sample@debug": true + "missing@debug": true, + "sample@debug": true, }, "extraKnownMarketplaces": { "debug": { - "source": "local", - "path": marketplace_root, + "source": { + "source": "file", + "path": marketplace_root.join(".agents/plugins/marketplace.json"), + } } } }); @@ -461,6 +473,19 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl source_home.join("settings.json"), serde_json::to_string_pretty(&settings)?, )?; + std::fs::write( + source_home.join("plugins/known_marketplaces.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "debug": { + "source": { + "source": "file", + "path": marketplace_root.join(".agents/plugins/marketplace.json"), + }, + "installLocation": marketplace_root, + "lastUpdated": "2026-07-09T00:16:23.611Z", + } + }))?, + )?; let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::builder() @@ -473,23 +498,38 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl let request_id = mcp .send_raw_request( - "externalAgentConfig/import", - Some(serde_json::json!({ - "migrationItems": [{ - "itemType": "PLUGINS", - "description": "Import plugins", - "cwd": null, - "details": { - "plugins": [{ - "marketplaceName": "debug", - "pluginNames": ["sample"] - }] - } - }] - })), + "externalAgentConfig/detect", + Some(serde_json::json!({ "includeHome": true })), ) .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let detected: ExternalAgentConfigDetectResponse = to_response(response)?; + assert_eq!(detected.items.len(), 1); + assert_eq!( + detected.items[0].item_type, + ExternalAgentConfigMigrationItemType::Plugins + ); + assert_eq!( + detected.items[0] + .details + .as_ref() + .map(|details| details.plugins.clone()), + Some(vec![codex_app_server_protocol::PluginsMigration { + marketplace_name: "debug".to_string(), + plugin_names: vec!["missing".to_string(), "sample".to_string()], + }]) + ); + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ "migrationItems": detected.items })), + ) + .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), @@ -507,6 +547,55 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl let completed: ExternalAgentConfigImportCompletedNotification = serde_json::from_value(notification.params.expect("completed params"))?; assert_eq!(completed.import_id, import_id); + assert_eq!(completed.item_type_results.len(), 1); + let plugin_result = &completed.item_type_results[0]; + assert_eq!( + plugin_result.item_type, + ExternalAgentConfigMigrationItemType::Plugins + ); + assert_eq!(plugin_result.successes.len(), 1); + assert_eq!( + plugin_result.successes[0].source.as_deref(), + Some("sample@debug") + ); + assert_eq!(plugin_result.failures.len(), 1); + assert_eq!( + plugin_result.failures[0].source.as_deref(), + Some("missing@debug") + ); + assert_eq!( + plugin_result.failures[0].error_type.as_deref(), + Some("plugin_not_found") + ); + assert_eq!(plugin_result.failures[0].failure_stage, "plugin_import"); + assert_eq!( + plugin_result.failures[0].message, + "plugin `missing` was not found in marketplace `debug`" + ); + + let event = wait_for_analytics_event( + &analytics_server, + DEFAULT_TIMEOUT, + "codex_plugin_install_failed", + ) + .await?; + let event_params = &event["event_params"]; + assert_eq!(event_params["plugin_id"], "missing@debug"); + assert_eq!(event_params["plugin_name"], "missing"); + assert_eq!(event_params["marketplace_name"], "debug"); + assert_eq!(event_params["source"], "external_agent_migration"); + assert_eq!(event_params["error_type"], "plugin_not_found"); + + let event = wait_for_analytics_event( + &analytics_server, + DEFAULT_TIMEOUT, + "codex_onboarding_external_agent_import_failure", + ) + .await?; + let event_params = &event["event_params"]; + assert_eq!(event_params["type"], "PLUGINS"); + assert_eq!(event_params["failure_stage"], "plugin_import"); + assert_eq!(event_params["error_type"], "plugin_not_found"); let request_id = mcp .send_plugin_list_request(PluginListParams { 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 9d0a7bcff3..1d781cdc96 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -580,6 +580,7 @@ async fn plugin_install_tracks_analytics_when_remote_detail_fetch_fails() -> Res assert_eq!(event_params["remote_plugin_id"], REMOTE_PLUGIN_ID); assert_eq!(event_params["plugin_name"], json!(null)); assert_eq!(event_params["marketplace_name"], json!(null)); + assert_eq!(event_params["source"], "manual"); assert_eq!( event_params["error_type"], "remote_catalog_unexpected_status" @@ -1019,6 +1020,7 @@ async fn plugin_install_failure_tracks_analytics_event() -> Result<()> { assert_eq!(event_params["mcp_server_count"], json!(null)); assert_eq!(event_params["connector_ids"], json!(null)); assert_eq!(event_params["product_client_id"], DEFAULT_CLIENT_NAME); + assert_eq!(event_params["source"], "manual"); assert_eq!(event_params["error_type"], "store_invalid"); Ok(()) } @@ -1145,6 +1147,7 @@ async fn plugin_install_preserves_status_when_remote_bundle_error_body_is_too_la assert_eq!(event_params["plugin_id"], "linear@openai-curated-remote"); assert_eq!(event_params["remote_plugin_id"], REMOTE_PLUGIN_ID); assert_eq!(event_params["marketplace_name"], "openai-curated-remote"); + assert_eq!(event_params["source"], "manual"); assert_eq!(event_params["error_type"], "remote_bundle_download_status"); assert!( !codex_home diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 6fac0fafe9..16fb4c8663 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -57,6 +57,7 @@ use crate::store::PluginStore; use crate::store::PluginStoreError; use crate::tool_suggest_metadata::ToolSuggestMetadataCache; use codex_analytics::AnalyticsEventsClient; +use codex_analytics::PluginInstallSource; use codex_config::ConfigLayerStack; use codex_config::clear_user_plugin; use codex_config::set_user_plugin_enabled; @@ -370,6 +371,7 @@ pub struct PluginsManager { restriction_product: Option, auth_mode: RwLock>, analytics_events_client: RwLock>, + plugin_install_source: PluginInstallSource, } #[derive(Clone)] @@ -449,9 +451,15 @@ impl PluginsManager { restriction_product, auth_mode: RwLock::new(auth_mode), analytics_events_client: RwLock::new(None), + plugin_install_source: PluginInstallSource::Manual, } } + pub fn with_plugin_install_source(mut self, source: PluginInstallSource) -> Self { + self.plugin_install_source = source; + self + } + pub fn set_auth_mode(&self, auth_mode: Option) -> bool { let mut stored_auth_mode = match self.auth_mode.write() { Ok(auth_mode_guard) => auth_mode_guard, @@ -1419,6 +1427,7 @@ impl PluginsManager { if let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_install_failed( self.telemetry_metadata_for_plugin_id(plugin_id), + self.plugin_install_source, error_type.to_string(), ); }