mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
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.
This commit is contained in:
1 parent
a6b99ee5c4
commit
8dc77faf51
13 files changed
+531
-83
No files matched your search
@@ -66,6 +66,7 @@ use crate::facts::PluginInstallRequestSource;
|
|||||||
use crate::facts::PluginInstallRequested;
|
use crate::facts::PluginInstallRequested;
|
||||||
use crate::facts::PluginInstallRequestedInput;
|
use crate::facts::PluginInstallRequestedInput;
|
||||||
use crate::facts::PluginInstallRequestedPlugin;
|
use crate::facts::PluginInstallRequestedPlugin;
|
||||||
|
use crate::facts::PluginInstallSource;
|
||||||
use crate::facts::PluginState;
|
use crate::facts::PluginState;
|
||||||
use crate::facts::PluginStateChangedInput;
|
use crate::facts::PluginStateChangedInput;
|
||||||
use crate::facts::PluginUsedInput;
|
use crate::facts::PluginUsedInput;
|
||||||
@@ -3242,6 +3243,7 @@ fn plugin_install_failed_event_serializes_expected_shape() {
|
|||||||
event_type: "codex_plugin_install_failed",
|
event_type: "codex_plugin_install_failed",
|
||||||
event_params: CodexPluginInstallFailedMetadata {
|
event_params: CodexPluginInstallFailedMetadata {
|
||||||
plugin: codex_plugin_metadata(sample_plugin_metadata()),
|
plugin: codex_plugin_metadata(sample_plugin_metadata()),
|
||||||
|
source: PluginInstallSource::Manual,
|
||||||
error_type: "store_io".to_string(),
|
error_type: "store_io".to_string(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -3261,6 +3263,7 @@ fn plugin_install_failed_event_serializes_expected_shape() {
|
|||||||
"mcp_server_count": 2,
|
"mcp_server_count": 2,
|
||||||
"connector_ids": ["calendar", "drive"],
|
"connector_ids": ["calendar", "drive"],
|
||||||
"product_client_id": originator().value,
|
"product_client_id": originator().value,
|
||||||
|
"source": "manual",
|
||||||
"error_type": "store_io"
|
"error_type": "store_io"
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -3692,6 +3695,7 @@ async fn reducer_ingests_plugin_install_failed_fact() {
|
|||||||
AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed(
|
AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed(
|
||||||
PluginInstallFailedInput {
|
PluginInstallFailedInput {
|
||||||
plugin: sample_plugin_metadata(),
|
plugin: sample_plugin_metadata(),
|
||||||
|
source: PluginInstallSource::ExternalAgentMigration,
|
||||||
error_type: "invalid_plugin".to_string(),
|
error_type: "invalid_plugin".to_string(),
|
||||||
},
|
},
|
||||||
)),
|
)),
|
||||||
@@ -3713,6 +3717,7 @@ async fn reducer_ingests_plugin_install_failed_fact() {
|
|||||||
"mcp_server_count": 2,
|
"mcp_server_count": 2,
|
||||||
"connector_ids": ["calendar", "drive"],
|
"connector_ids": ["calendar", "drive"],
|
||||||
"product_client_id": originator().value,
|
"product_client_id": originator().value,
|
||||||
|
"source": "external_agent_migration",
|
||||||
"error_type": "invalid_plugin"
|
"error_type": "invalid_plugin"
|
||||||
}
|
}
|
||||||
}])
|
}])
|
||||||
@@ -3734,6 +3739,7 @@ async fn reducer_ingests_plugin_install_failed_fact_without_detail() {
|
|||||||
AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed(
|
AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed(
|
||||||
PluginInstallFailedInput {
|
PluginInstallFailedInput {
|
||||||
plugin,
|
plugin,
|
||||||
|
source: PluginInstallSource::Manual,
|
||||||
error_type: "remote_catalog_unexpected_status".to_string(),
|
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,
|
"mcp_server_count": null,
|
||||||
"connector_ids": null,
|
"connector_ids": null,
|
||||||
"product_client_id": originator().value,
|
"product_client_id": originator().value,
|
||||||
|
"source": "manual",
|
||||||
"error_type": "remote_catalog_unexpected_status"
|
"error_type": "remote_catalog_unexpected_status"
|
||||||
}
|
}
|
||||||
}])
|
}])
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::facts::HookRunInput;
|
|||||||
use crate::facts::PluginInstallFailedInput;
|
use crate::facts::PluginInstallFailedInput;
|
||||||
use crate::facts::PluginInstallRequested;
|
use crate::facts::PluginInstallRequested;
|
||||||
use crate::facts::PluginInstallRequestedInput;
|
use crate::facts::PluginInstallRequestedInput;
|
||||||
|
use crate::facts::PluginInstallSource;
|
||||||
use crate::facts::PluginState;
|
use crate::facts::PluginState;
|
||||||
use crate::facts::PluginStateChangedInput;
|
use crate::facts::PluginStateChangedInput;
|
||||||
use crate::facts::SkillInvocation;
|
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(
|
self.record_fact(AnalyticsFact::Custom(
|
||||||
CustomAnalyticsFact::PluginInstallFailed(PluginInstallFailedInput {
|
CustomAnalyticsFact::PluginInstallFailed(PluginInstallFailedInput {
|
||||||
plugin,
|
plugin,
|
||||||
|
source,
|
||||||
error_type,
|
error_type,
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -991,6 +991,7 @@ pub(crate) struct CodexPluginEventRequest {
|
|||||||
pub(crate) struct CodexPluginInstallFailedMetadata {
|
pub(crate) struct CodexPluginInstallFailedMetadata {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub(crate) plugin: CodexPluginMetadata,
|
pub(crate) plugin: CodexPluginMetadata,
|
||||||
|
pub(crate) source: crate::facts::PluginInstallSource,
|
||||||
pub(crate) error_type: String,
|
pub(crate) error_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -579,8 +579,16 @@ pub(crate) struct PluginStateChangedInput {
|
|||||||
pub state: PluginState,
|
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(crate) struct PluginInstallFailedInput {
|
||||||
pub plugin: PluginTelemetryMetadata,
|
pub plugin: PluginTelemetryMetadata,
|
||||||
|
pub source: PluginInstallSource,
|
||||||
pub error_type: String,
|
pub error_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ pub use facts::InvocationType;
|
|||||||
pub use facts::PluginInstallRequestSource;
|
pub use facts::PluginInstallRequestSource;
|
||||||
pub use facts::PluginInstallRequested;
|
pub use facts::PluginInstallRequested;
|
||||||
pub use facts::PluginInstallRequestedPlugin;
|
pub use facts::PluginInstallRequestedPlugin;
|
||||||
|
pub use facts::PluginInstallSource;
|
||||||
pub use facts::SkillInvocation;
|
pub use facts::SkillInvocation;
|
||||||
pub use facts::SubAgentThreadStartedInput;
|
pub use facts::SubAgentThreadStartedInput;
|
||||||
pub use facts::ThreadInitializationMode;
|
pub use facts::ThreadInitializationMode;
|
||||||
|
|||||||
@@ -837,12 +837,17 @@ impl AnalyticsReducer {
|
|||||||
input: PluginInstallFailedInput,
|
input: PluginInstallFailedInput,
|
||||||
out: &mut Vec<TrackEventRequest>,
|
out: &mut Vec<TrackEventRequest>,
|
||||||
) {
|
) {
|
||||||
let PluginInstallFailedInput { plugin, error_type } = input;
|
let PluginInstallFailedInput {
|
||||||
|
plugin,
|
||||||
|
source,
|
||||||
|
error_type,
|
||||||
|
} = input;
|
||||||
out.push(TrackEventRequest::PluginInstallFailed(
|
out.push(TrackEventRequest::PluginInstallFailed(
|
||||||
CodexPluginInstallFailedEventRequest {
|
CodexPluginInstallFailedEventRequest {
|
||||||
event_type: "codex_plugin_install_failed",
|
event_type: "codex_plugin_install_failed",
|
||||||
event_params: CodexPluginInstallFailedMetadata {
|
event_params: CodexPluginInstallFailedMetadata {
|
||||||
plugin: codex_plugin_metadata(plugin),
|
plugin: codex_plugin_metadata(plugin),
|
||||||
|
source,
|
||||||
error_type,
|
error_type,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
use codex_analytics::AnalyticsEventsClient;
|
||||||
|
use codex_analytics::PluginInstallSource;
|
||||||
use codex_config::types::PluginConfig;
|
use codex_config::types::PluginConfig;
|
||||||
use codex_core::config::Config;
|
use codex_core::config::Config;
|
||||||
use codex_core::config::ConfigBuilder;
|
use codex_core::config::ConfigBuilder;
|
||||||
|
use codex_core_plugins::PluginInstallError;
|
||||||
use codex_core_plugins::PluginInstallRequest;
|
use codex_core_plugins::PluginInstallRequest;
|
||||||
use codex_core_plugins::PluginsManager;
|
use codex_core_plugins::PluginsManager;
|
||||||
|
use codex_core_plugins::marketplace::MarketplaceError;
|
||||||
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
|
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
|
||||||
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
|
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
|
||||||
use codex_core_plugins::marketplace_add::MarketplaceAddRequest;
|
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_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import";
|
||||||
const EXTERNAL_AGENT_DIR: &str = ".claude";
|
const EXTERNAL_AGENT_DIR: &str = ".claude";
|
||||||
const EXTERNAL_AGENT_CONFIG_MD: &str = "CLAUDE.md";
|
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_NAME: &str = "claude-plugins-official";
|
||||||
const EXTERNAL_OFFICIAL_MARKETPLACE_SOURCE: &str = "anthropics/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 {
|
pub(crate) struct ExternalAgentConfigService {
|
||||||
codex_home: PathBuf,
|
codex_home: PathBuf,
|
||||||
external_agent_home: PathBuf,
|
external_agent_home: PathBuf,
|
||||||
|
analytics_events_client: Option<AnalyticsEventsClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExternalAgentConfigService {
|
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();
|
let external_agent_home = default_external_agent_home();
|
||||||
Self {
|
Self {
|
||||||
codex_home,
|
codex_home,
|
||||||
external_agent_home,
|
external_agent_home,
|
||||||
|
analytics_events_client: Some(analytics_events_client),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +199,7 @@ impl ExternalAgentConfigService {
|
|||||||
Self {
|
Self {
|
||||||
codex_home,
|
codex_home,
|
||||||
external_agent_home,
|
external_agent_home,
|
||||||
|
analytics_events_client: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,9 +813,10 @@ impl ExternalAgentConfigService {
|
|||||||
configured_plugin_ids: &HashSet<String>,
|
configured_plugin_ids: &HashSet<String>,
|
||||||
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
|
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
|
||||||
) -> Option<ExternalAgentConfigMigrationItem> {
|
) -> Option<ExternalAgentConfigMigrationItem> {
|
||||||
|
let import_sources = self.marketplace_import_sources(settings, source_root);
|
||||||
let plugin_details = extract_plugin_migration_details(
|
let plugin_details = extract_plugin_migration_details(
|
||||||
settings,
|
settings,
|
||||||
source_root,
|
&import_sources,
|
||||||
configured_plugin_ids,
|
configured_plugin_ids,
|
||||||
configured_marketplace_plugins,
|
configured_marketplace_plugins,
|
||||||
)?;
|
)?;
|
||||||
@@ -836,7 +845,7 @@ impl ExternalAgentConfigService {
|
|||||||
);
|
);
|
||||||
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
|
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
|
||||||
let import_sources = effective_external_settings(&source_settings)?
|
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();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let mut local_plugins = Vec::new();
|
let mut local_plugins = Vec::new();
|
||||||
@@ -893,7 +902,19 @@ impl ExternalAgentConfigService {
|
|||||||
.map_err(|err| io::Error::other(format!("failed to load config: {err}")))?;
|
.map_err(|err| io::Error::other(format!("failed to load config: {err}")))?;
|
||||||
let requirements = config.config_layer_stack.requirements().clone();
|
let requirements = config.config_layer_stack.requirements().clone();
|
||||||
let mut outcome = PluginImportOutcome::default();
|
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 {
|
for plugin_group in plugins {
|
||||||
let marketplace_name = plugin_group.marketplace_name.clone();
|
let marketplace_name = plugin_group.marketplace_name.clone();
|
||||||
let plugin_names = plugin_group.plugin_names;
|
let plugin_names = plugin_group.plugin_names;
|
||||||
@@ -901,16 +922,7 @@ impl ExternalAgentConfigService {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
|
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let source_settings = cwd.map_or_else(
|
let import_source = import_sources.get(&marketplace_name).cloned();
|
||||||
|| 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 Some(import_source) = import_source else {
|
let Some(import_source) = import_source else {
|
||||||
let message = format!(
|
let message = format!(
|
||||||
"external agent plugin marketplace source was not found: {marketplace_name}"
|
"external agent plugin marketplace source was not found: {marketplace_name}"
|
||||||
@@ -1009,12 +1021,21 @@ impl ExternalAgentConfigService {
|
|||||||
Err(err) => {
|
Err(err) => {
|
||||||
let plugin_id = format!("{plugin_name}@{marketplace_name}");
|
let plugin_id = format!("{plugin_name}@{marketplace_name}");
|
||||||
outcome.failed_plugin_ids.push(plugin_id.clone());
|
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,
|
cwd,
|
||||||
"plugin_import",
|
"plugin_import",
|
||||||
err.to_string(),
|
err.to_string(),
|
||||||
Some(plugin_id),
|
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)
|
Ok(outcome)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn marketplace_import_sources(
|
||||||
|
&self,
|
||||||
|
settings: &JsonValue,
|
||||||
|
source_root: &Path,
|
||||||
|
) -> BTreeMap<String, MarketplaceImportSource> {
|
||||||
|
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<Option<(String, String)>> {
|
fn import_config(&self, cwd: Option<&Path>) -> io::Result<Option<(String, String)>> {
|
||||||
let repo_root = find_repo_root(cwd)?;
|
let repo_root = find_repo_root(cwd)?;
|
||||||
let (source_settings, target_config) = if let Some(repo_root) = repo_root.as_ref() {
|
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(
|
fn extract_plugin_migration_details(
|
||||||
settings: &JsonValue,
|
settings: &JsonValue,
|
||||||
source_root: &Path,
|
import_sources: &BTreeMap<String, MarketplaceImportSource>,
|
||||||
configured_plugin_ids: &HashSet<String>,
|
configured_plugin_ids: &HashSet<String>,
|
||||||
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
|
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
|
||||||
) -> Option<MigrationDetails> {
|
) -> Option<MigrationDetails> {
|
||||||
let loadable_marketplaces = collect_marketplace_import_sources(settings, source_root)
|
let loadable_marketplaces = import_sources
|
||||||
.into_iter()
|
.iter()
|
||||||
.filter_map(|(marketplace_name, source)| {
|
.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()
|
.ok()
|
||||||
.map(|_| marketplace_name)
|
.map(|_| marketplace_name.clone())
|
||||||
})
|
})
|
||||||
.collect::<HashSet<_>>();
|
.collect::<HashSet<_>>();
|
||||||
let mut plugins = BTreeMap::new();
|
let mut plugins = BTreeMap::new();
|
||||||
@@ -1345,9 +1455,19 @@ fn extract_plugin_migration_details(
|
|||||||
configured_marketplace_plugins.get(&plugin_id.marketplace_name)
|
configured_marketplace_plugins.get(&plugin_id.marketplace_name)
|
||||||
{
|
{
|
||||||
if !installable_plugins.contains(&plugin_id.plugin_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;
|
continue;
|
||||||
}
|
}
|
||||||
} else if !loadable_marketplaces.contains(&plugin_id.marketplace_name) {
|
} 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;
|
continue;
|
||||||
}
|
}
|
||||||
let plugin_group = plugins
|
let plugin_group = plugins
|
||||||
@@ -1444,13 +1564,11 @@ fn configured_marketplace_plugins(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn collect_marketplace_import_sources(
|
fn collect_marketplace_import_sources(
|
||||||
settings: &JsonValue,
|
marketplaces: &JsonValue,
|
||||||
source_root: &Path,
|
source_root: &Path,
|
||||||
) -> BTreeMap<String, MarketplaceImportSource> {
|
) -> BTreeMap<String, MarketplaceImportSource> {
|
||||||
let mut import_sources: BTreeMap<String, MarketplaceImportSource> = settings
|
marketplaces
|
||||||
.as_object()
|
.as_object()
|
||||||
.and_then(|settings| settings.get("extraKnownMarketplaces"))
|
|
||||||
.and_then(JsonValue::as_object)
|
|
||||||
.map(|extra_known_marketplaces| {
|
.map(|extra_known_marketplaces| {
|
||||||
extra_known_marketplaces
|
extra_known_marketplaces
|
||||||
.iter()
|
.iter()
|
||||||
@@ -1462,46 +1580,69 @@ fn collect_marketplace_import_sources(
|
|||||||
} else {
|
} else {
|
||||||
value.as_object()?
|
value.as_object()?
|
||||||
};
|
};
|
||||||
let source = source_fields
|
let source_kind = source_fields
|
||||||
.get("repo")
|
.get("source")
|
||||||
.or_else(|| source_fields.get("url"))
|
.and_then(JsonValue::as_str)
|
||||||
.or_else(|| source_fields.get("path"))
|
.map(str::trim);
|
||||||
.or_else(|| value.get("source"))?
|
let declared_source = match source_kind {
|
||||||
.as_str()?
|
Some("github") => source_fields.get("repo"),
|
||||||
.trim()
|
Some("git") => source_fields.get("url"),
|
||||||
.to_string();
|
Some("directory" | "local") => source_fields.get("path"),
|
||||||
if source.is_empty() {
|
Some("file" | "url" | "npm" | "settings") => None,
|
||||||
return 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);
|
.and_then(JsonValue::as_str)
|
||||||
|
.map(str::trim)
|
||||||
let ref_name = source_fields
|
.filter(|value| !value.is_empty());
|
||||||
.get("ref")
|
let materialized_source = value
|
||||||
.or_else(|| value.get("ref"))
|
.get("installLocation")
|
||||||
.and_then(JsonValue::as_str)
|
.and_then(JsonValue::as_str)
|
||||||
.map(str::trim)
|
.map(str::trim)
|
||||||
.filter(|value| !value.is_empty())
|
.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 }))
|
Some((name.clone(), MarketplaceImportSource { source, ref_name }))
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ fn assert_single_plugin_raw_error(
|
|||||||
raw_errors: &[ExternalAgentConfigImportRawError],
|
raw_errors: &[ExternalAgentConfigImportRawError],
|
||||||
failure_stage: &str,
|
failure_stage: &str,
|
||||||
source: &str,
|
source: &str,
|
||||||
|
error_type: Option<&str>,
|
||||||
) {
|
) {
|
||||||
assert_eq!(raw_errors.len(), 1);
|
assert_eq!(raw_errors.len(), 1);
|
||||||
let raw_error = &raw_errors[0];
|
let raw_error = &raw_errors[0];
|
||||||
@@ -47,7 +48,7 @@ fn assert_single_plugin_raw_error(
|
|||||||
ExternalAgentConfigMigrationItemType::Plugins
|
ExternalAgentConfigMigrationItemType::Plugins
|
||||||
);
|
);
|
||||||
assert_eq!(raw_error.failure_stage, failure_stage);
|
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.cwd, None);
|
||||||
assert_eq!(raw_error.source.as_deref(), Some(source));
|
assert_eq!(raw_error.source.as_deref(), Some(source));
|
||||||
assert!(!raw_error.message.is_empty());
|
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]
|
#[tokio::test]
|
||||||
async fn detect_home_plugins_uses_local_settings_over_project_settings() {
|
async fn detect_home_plugins_uses_local_settings_over_project_settings() {
|
||||||
let (_root, external_agent_home, codex_home) = fixture_paths();
|
let (_root, external_agent_home, codex_home) = fixture_paths();
|
||||||
@@ -2255,6 +2419,7 @@ async fn import_plugins_requires_source_marketplace_details() {
|
|||||||
&outcome.raw_errors,
|
&outcome.raw_errors,
|
||||||
"plugin_import",
|
"plugin_import",
|
||||||
"formatter@other-tools",
|
"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,
|
outcome.failed_plugin_ids,
|
||||||
vec!["formatter@acme-tools".to_string()]
|
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]
|
#[tokio::test]
|
||||||
@@ -2610,6 +2780,7 @@ async fn import_plugins_infers_external_official_marketplace_when_missing_from_s
|
|||||||
&outcome.raw_errors,
|
&outcome.raw_errors,
|
||||||
"plugin_import",
|
"plugin_import",
|
||||||
&format!("sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}"),
|
&format!("sample@{EXTERNAL_OFFICIAL_MARKETPLACE_NAME}"),
|
||||||
|
Some("plugin_not_found"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,9 +96,11 @@ impl ExternalAgentConfigRequestProcessor {
|
|||||||
config_manager,
|
config_manager,
|
||||||
arg0_paths,
|
arg0_paths,
|
||||||
);
|
);
|
||||||
|
let migration_service =
|
||||||
|
ExternalAgentConfigService::new(codex_home, analytics_events_client.clone());
|
||||||
Self {
|
Self {
|
||||||
outgoing,
|
outgoing,
|
||||||
migration_service: ExternalAgentConfigService::new(codex_home),
|
migration_service,
|
||||||
session_importer,
|
session_importer,
|
||||||
thread_manager,
|
thread_manager,
|
||||||
config_processor,
|
config_processor,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::error_code::internal_error;
|
use crate::error_code::internal_error;
|
||||||
use crate::error_code::invalid_request;
|
use crate::error_code::invalid_request;
|
||||||
|
use codex_analytics::PluginInstallSource;
|
||||||
use codex_app_server_protocol::PluginAvailability;
|
use codex_app_server_protocol::PluginAvailability;
|
||||||
use codex_app_server_protocol::PluginInstallPolicy;
|
use codex_app_server_protocol::PluginInstallPolicy;
|
||||||
use codex_app_server_protocol::PluginSharePrincipalRole;
|
use codex_app_server_protocol::PluginSharePrincipalRole;
|
||||||
@@ -1760,8 +1761,11 @@ impl PluginRequestProcessor {
|
|||||||
capability_summary: None,
|
capability_summary: None,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.analytics_events_client
|
self.analytics_events_client.track_plugin_install_failed(
|
||||||
.track_plugin_install_failed(plugin, error_type.to_string());
|
plugin,
|
||||||
|
PluginInstallSource::Manual,
|
||||||
|
error_type.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn plugin_apps_needing_auth_for_install(
|
async fn plugin_apps_needing_auth_for_install(
|
||||||
|
|||||||
@@ -418,9 +418,18 @@ async fn external_agent_config_import_completed_tracks_analytics_event() -> Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[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 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 marketplace_root = codex_home.path().join("marketplace");
|
||||||
let plugin_root = marketplace_root.join("plugins").join("sample");
|
let plugin_root = marketplace_root.join("plugins").join("sample");
|
||||||
std::fs::create_dir_all(marketplace_root.join(".agents/plugins"))?;
|
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"}"#,
|
r#"{"name":"sample","version":"0.1.0"}"#,
|
||||||
)?;
|
)?;
|
||||||
let source_home = external_agent_home(codex_home.path());
|
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!({
|
let settings = serde_json::json!({
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"sample@debug": true
|
"missing@debug": true,
|
||||||
|
"sample@debug": true,
|
||||||
},
|
},
|
||||||
"extraKnownMarketplaces": {
|
"extraKnownMarketplaces": {
|
||||||
"debug": {
|
"debug": {
|
||||||
"source": "local",
|
"source": {
|
||||||
"path": marketplace_root,
|
"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"),
|
source_home.join("settings.json"),
|
||||||
serde_json::to_string_pretty(&settings)?,
|
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 home_dir = codex_home.path().display().to_string();
|
||||||
let mut mcp = TestAppServer::builder()
|
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
|
let request_id = mcp
|
||||||
.send_raw_request(
|
.send_raw_request(
|
||||||
"externalAgentConfig/import",
|
"externalAgentConfig/detect",
|
||||||
Some(serde_json::json!({
|
Some(serde_json::json!({ "includeHome": true })),
|
||||||
"migrationItems": [{
|
|
||||||
"itemType": "PLUGINS",
|
|
||||||
"description": "Import plugins",
|
|
||||||
"cwd": null,
|
|
||||||
"details": {
|
|
||||||
"plugins": [{
|
|
||||||
"marketplaceName": "debug",
|
|
||||||
"pluginNames": ["sample"]
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}]
|
|
||||||
})),
|
|
||||||
)
|
)
|
||||||
.await?;
|
.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(
|
let response: JSONRPCResponse = timeout(
|
||||||
DEFAULT_TIMEOUT,
|
DEFAULT_TIMEOUT,
|
||||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
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 =
|
let completed: ExternalAgentConfigImportCompletedNotification =
|
||||||
serde_json::from_value(notification.params.expect("completed params"))?;
|
serde_json::from_value(notification.params.expect("completed params"))?;
|
||||||
assert_eq!(completed.import_id, import_id);
|
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
|
let request_id = mcp
|
||||||
.send_plugin_list_request(PluginListParams {
|
.send_plugin_list_request(PluginListParams {
|
||||||
|
|||||||
@@ -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["remote_plugin_id"], REMOTE_PLUGIN_ID);
|
||||||
assert_eq!(event_params["plugin_name"], json!(null));
|
assert_eq!(event_params["plugin_name"], json!(null));
|
||||||
assert_eq!(event_params["marketplace_name"], json!(null));
|
assert_eq!(event_params["marketplace_name"], json!(null));
|
||||||
|
assert_eq!(event_params["source"], "manual");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
event_params["error_type"],
|
event_params["error_type"],
|
||||||
"remote_catalog_unexpected_status"
|
"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["mcp_server_count"], json!(null));
|
||||||
assert_eq!(event_params["connector_ids"], json!(null));
|
assert_eq!(event_params["connector_ids"], json!(null));
|
||||||
assert_eq!(event_params["product_client_id"], DEFAULT_CLIENT_NAME);
|
assert_eq!(event_params["product_client_id"], DEFAULT_CLIENT_NAME);
|
||||||
|
assert_eq!(event_params["source"], "manual");
|
||||||
assert_eq!(event_params["error_type"], "store_invalid");
|
assert_eq!(event_params["error_type"], "store_invalid");
|
||||||
Ok(())
|
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["plugin_id"], "linear@openai-curated-remote");
|
||||||
assert_eq!(event_params["remote_plugin_id"], REMOTE_PLUGIN_ID);
|
assert_eq!(event_params["remote_plugin_id"], REMOTE_PLUGIN_ID);
|
||||||
assert_eq!(event_params["marketplace_name"], "openai-curated-remote");
|
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_eq!(event_params["error_type"], "remote_bundle_download_status");
|
||||||
assert!(
|
assert!(
|
||||||
!codex_home
|
!codex_home
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ use crate::store::PluginStore;
|
|||||||
use crate::store::PluginStoreError;
|
use crate::store::PluginStoreError;
|
||||||
use crate::tool_suggest_metadata::ToolSuggestMetadataCache;
|
use crate::tool_suggest_metadata::ToolSuggestMetadataCache;
|
||||||
use codex_analytics::AnalyticsEventsClient;
|
use codex_analytics::AnalyticsEventsClient;
|
||||||
|
use codex_analytics::PluginInstallSource;
|
||||||
use codex_config::ConfigLayerStack;
|
use codex_config::ConfigLayerStack;
|
||||||
use codex_config::clear_user_plugin;
|
use codex_config::clear_user_plugin;
|
||||||
use codex_config::set_user_plugin_enabled;
|
use codex_config::set_user_plugin_enabled;
|
||||||
@@ -370,6 +371,7 @@ pub struct PluginsManager {
|
|||||||
restriction_product: Option<Product>,
|
restriction_product: Option<Product>,
|
||||||
auth_mode: RwLock<Option<AuthMode>>,
|
auth_mode: RwLock<Option<AuthMode>>,
|
||||||
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
|
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
|
||||||
|
plugin_install_source: PluginInstallSource,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -449,9 +451,15 @@ impl PluginsManager {
|
|||||||
restriction_product,
|
restriction_product,
|
||||||
auth_mode: RwLock::new(auth_mode),
|
auth_mode: RwLock::new(auth_mode),
|
||||||
analytics_events_client: RwLock::new(None),
|
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<AuthMode>) -> bool {
|
pub fn set_auth_mode(&self, auth_mode: Option<AuthMode>) -> bool {
|
||||||
let mut stored_auth_mode = match self.auth_mode.write() {
|
let mut stored_auth_mode = match self.auth_mode.write() {
|
||||||
Ok(auth_mode_guard) => auth_mode_guard,
|
Ok(auth_mode_guard) => auth_mode_guard,
|
||||||
@@ -1419,6 +1427,7 @@ impl PluginsManager {
|
|||||||
if let Some(analytics_events_client) = analytics_events_client {
|
if let Some(analytics_events_client) = analytics_events_client {
|
||||||
analytics_events_client.track_plugin_install_failed(
|
analytics_events_client.track_plugin_install_failed(
|
||||||
self.telemetry_metadata_for_plugin_id(plugin_id),
|
self.telemetry_metadata_for_plugin_id(plugin_id),
|
||||||
|
self.plugin_install_source,
|
||||||
error_type.to_string(),
|
error_type.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user