diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 36916534c645..7b878e952ce5 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 f957a665a09c..1fd77b05602e 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 ad8adf14cbf6..999ae3e1f092 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 3346a6e19a72..1f3333b8bce3 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 86bc9d682971..d178d8d5bf18 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 79649a553b8e..13696d670f83 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 ca1023e52918..fa092cfb3ccc 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 9ab67a137909..9ecf0986ca81 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 5bae994ca5e2..c71abcb8840f 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 ed8495d60210..688cfae91704 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 e83b63661886..53c50ea43f54 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 9d0a7bcff335..1d781cdc969d 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 6fac0fafe989..16fb4c86632c 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(), ); }