mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
Add remote plugin IDs to analytics events
This commit is contained in:
@@ -2877,6 +2877,7 @@ fn plugin_used_event_serializes_expected_shape() {
|
||||
"event_type": "codex_plugin_used",
|
||||
"event_params": {
|
||||
"plugin_id": "sample@test",
|
||||
"remote_plugin_id": null,
|
||||
"plugin_name": "sample",
|
||||
"marketplace_name": "test",
|
||||
"has_skills": true,
|
||||
@@ -2907,6 +2908,7 @@ fn plugin_management_event_serializes_expected_shape() {
|
||||
"event_type": "codex_plugin_installed",
|
||||
"event_params": {
|
||||
"plugin_id": "sample@test",
|
||||
"remote_plugin_id": null,
|
||||
"plugin_name": "sample",
|
||||
"marketplace_name": "test",
|
||||
"has_skills": true,
|
||||
@@ -2919,7 +2921,7 @@ fn plugin_management_event_serializes_expected_shape() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_management_event_can_use_remote_plugin_id_override() {
|
||||
fn plugin_management_event_serializes_local_and_remote_plugin_ids() {
|
||||
let mut plugin = sample_plugin_metadata();
|
||||
plugin.remote_plugin_id = Some("plugins~Plugin_remote".to_string());
|
||||
let event = TrackEventRequest::PluginInstalled(CodexPluginEventRequest {
|
||||
@@ -2929,8 +2931,9 @@ fn plugin_management_event_can_use_remote_plugin_id_override() {
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize plugin installed event");
|
||||
|
||||
assert_eq!(payload["event_params"]["plugin_id"], "sample@test");
|
||||
assert_eq!(
|
||||
payload["event_params"]["plugin_id"],
|
||||
payload["event_params"]["remote_plugin_id"],
|
||||
"plugins~Plugin_remote"
|
||||
);
|
||||
assert_eq!(payload["event_params"]["plugin_name"], "sample");
|
||||
@@ -3274,6 +3277,7 @@ async fn reducer_ingests_plugin_state_changed_fact() {
|
||||
"event_type": "codex_plugin_disabled",
|
||||
"event_params": {
|
||||
"plugin_id": "sample@test",
|
||||
"remote_plugin_id": null,
|
||||
"plugin_name": "sample",
|
||||
"marketplace_name": "test",
|
||||
"has_skills": true,
|
||||
|
||||
@@ -863,6 +863,7 @@ pub(crate) struct CodexTurnSteerEventRequest {
|
||||
#[derive(Serialize)]
|
||||
pub(crate) struct CodexPluginMetadata {
|
||||
pub(crate) plugin_id: Option<String>,
|
||||
pub(crate) remote_plugin_id: Option<String>,
|
||||
pub(crate) plugin_name: Option<String>,
|
||||
pub(crate) marketplace_name: Option<String>,
|
||||
pub(crate) has_skills: Option<bool>,
|
||||
@@ -923,9 +924,9 @@ pub(crate) fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPlu
|
||||
remote_plugin_id,
|
||||
capability_summary,
|
||||
} = plugin;
|
||||
let event_plugin_id = remote_plugin_id.unwrap_or_else(|| plugin_id.as_key());
|
||||
CodexPluginMetadata {
|
||||
plugin_id: Some(event_plugin_id),
|
||||
plugin_id: Some(plugin_id.as_key()),
|
||||
remote_plugin_id,
|
||||
plugin_name: Some(plugin_id.plugin_name),
|
||||
marketplace_name: Some(plugin_id.marketplace_name),
|
||||
has_skills: capability_summary
|
||||
|
||||
@@ -311,7 +311,6 @@ use codex_core_plugins::PluginReadRequest;
|
||||
use codex_core_plugins::PluginUninstallError as CorePluginUninstallError;
|
||||
use codex_core_plugins::loader::load_plugin_apps;
|
||||
use codex_core_plugins::loader::load_plugin_mcp_servers;
|
||||
use codex_core_plugins::loader::plugin_telemetry_metadata_from_root;
|
||||
use codex_core_plugins::manifest::PluginManifestInterface;
|
||||
use codex_core_plugins::marketplace::MarketplaceError;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginSource;
|
||||
|
||||
@@ -295,15 +295,14 @@ impl ConfigRequestProcessor {
|
||||
&self,
|
||||
pending_changes: std::collections::BTreeMap<String, bool>,
|
||||
) {
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
for (plugin_id, enabled) in pending_changes {
|
||||
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
|
||||
continue;
|
||||
};
|
||||
let metadata = codex_core_plugins::loader::installed_plugin_telemetry_metadata(
|
||||
self.config_manager.codex_home(),
|
||||
&plugin_id,
|
||||
)
|
||||
.await;
|
||||
let metadata = plugins_manager
|
||||
.telemetry_metadata_for_installed_plugin(&plugin_id)
|
||||
.await;
|
||||
if enabled {
|
||||
self.analytics_events_client.track_plugin_enabled(metadata);
|
||||
} else {
|
||||
|
||||
@@ -1523,9 +1523,14 @@ impl PluginRequestProcessor {
|
||||
Some(self.effective_plugins_changed_callback()),
|
||||
);
|
||||
|
||||
let mut plugin_metadata =
|
||||
plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await;
|
||||
plugin_metadata.remote_plugin_id = Some(remote_plugin_id);
|
||||
let plugin_metadata = self
|
||||
.thread_manager
|
||||
.plugins_manager()
|
||||
.telemetry_metadata_for_installed_plugin_with_remote_id(
|
||||
&result.plugin_id,
|
||||
&remote_plugin_id,
|
||||
)
|
||||
.await;
|
||||
self.analytics_events_client
|
||||
.track_plugin_installed(plugin_metadata);
|
||||
|
||||
@@ -1801,11 +1806,27 @@ impl PluginRequestProcessor {
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
let uninstall_target = codex_core_plugins::remote::resolve_remote_plugin_uninstall_target(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
&plugin_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
remote_plugin_catalog_error_to_jsonrpc(err, "resolve remote plugin before uninstall")
|
||||
})?;
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let plugin_telemetry = plugins_manager
|
||||
.telemetry_metadata_for_installed_plugin_with_remote_id(
|
||||
&uninstall_target.plugin_id,
|
||||
&uninstall_target.remote_plugin_id,
|
||||
)
|
||||
.await;
|
||||
let uninstall_result = codex_core_plugins::remote::uninstall_remote_plugin(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
config.codex_home.to_path_buf(),
|
||||
&plugin_id,
|
||||
uninstall_target,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1813,7 +1834,8 @@ impl PluginRequestProcessor {
|
||||
&uninstall_result,
|
||||
Ok(()) | Err(RemotePluginCatalogError::CacheRemove(_))
|
||||
) {
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
self.analytics_events_client
|
||||
.track_plugin_uninstalled(plugin_telemetry);
|
||||
if plugins_manager.clear_remote_installed_plugins_cache() {
|
||||
self.on_effective_plugins_changed();
|
||||
}
|
||||
|
||||
@@ -739,6 +739,7 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> {
|
||||
"event_type": "codex_plugin_installed",
|
||||
"event_params": {
|
||||
"plugin_id": "sample-plugin@debug",
|
||||
"remote_plugin_id": null,
|
||||
"plugin_name": "sample-plugin",
|
||||
"marketplace_name": "debug",
|
||||
"has_skills": false,
|
||||
@@ -791,7 +792,8 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
|
||||
"events": [{
|
||||
"event_type": "codex_plugin_installed",
|
||||
"event_params": {
|
||||
"plugin_id": REMOTE_PLUGIN_ID,
|
||||
"plugin_id": "linear@openai-curated-remote",
|
||||
"remote_plugin_id": REMOTE_PLUGIN_ID,
|
||||
"plugin_name": "linear",
|
||||
"marketplace_name": "openai-curated-remote",
|
||||
"has_skills": true,
|
||||
|
||||
@@ -3,10 +3,14 @@ use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::DEFAULT_CLIENT_NAME;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::ConfigWriteResponse;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_app_server_protocol::PluginInstalledParams;
|
||||
@@ -19,6 +23,7 @@ use codex_app_server_protocol::PluginShareDiscoverability;
|
||||
use codex_app_server_protocol::PluginSource;
|
||||
use codex_app_server_protocol::PluginSummary;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::WriteStatus;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_core::config::set_project_trust_level;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
@@ -280,6 +285,103 @@ enabled = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_toggle_analytics_resolves_remote_plugin_id_from_installed_snapshot() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "linear", "1.2.3")?;
|
||||
let global_installed_body = remote_installed_plugin_body("", "1.2.3", /*enabled*/ true);
|
||||
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
||||
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/codex/analytics-events/events"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":"ok"}"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut app_server = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??;
|
||||
|
||||
let installed_request_id = app_server
|
||||
.send_plugin_installed_request(PluginInstalledParams {
|
||||
cwds: None,
|
||||
install_suggestion_plugin_names: None,
|
||||
})
|
||||
.await?;
|
||||
let installed_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
app_server.read_stream_until_response_message(RequestId::Integer(installed_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: PluginInstalledResponse = to_response(installed_response)?;
|
||||
|
||||
for enabled in [false, true] {
|
||||
let request_id = app_server
|
||||
.send_config_value_write_request(ConfigValueWriteParams {
|
||||
file_path: None,
|
||||
key_path: "plugins.linear@openai-curated-remote.enabled".to_string(),
|
||||
value: serde_json::json!(enabled),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
expected_version: None,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: ConfigWriteResponse = to_response(response)?;
|
||||
assert_eq!(response.status, WriteStatus::Ok);
|
||||
}
|
||||
|
||||
let events = wait_for_plugin_analytics_events(&server, /*expected_count*/ 2).await?;
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
serde_json::json!({
|
||||
"event_type": "codex_plugin_disabled",
|
||||
"event_params": {
|
||||
"plugin_id": "linear@openai-curated-remote",
|
||||
"remote_plugin_id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"plugin_name": "linear",
|
||||
"marketplace_name": "openai-curated-remote",
|
||||
"has_skills": false,
|
||||
"mcp_server_count": 0,
|
||||
"connector_ids": [],
|
||||
"product_client_id": DEFAULT_CLIENT_NAME,
|
||||
}
|
||||
}),
|
||||
serde_json::json!({
|
||||
"event_type": "codex_plugin_enabled",
|
||||
"event_params": {
|
||||
"plugin_id": "linear@openai-curated-remote",
|
||||
"remote_plugin_id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"plugin_name": "linear",
|
||||
"marketplace_name": "openai-curated-remote",
|
||||
"has_skills": false,
|
||||
"mcp_server_count": 0,
|
||||
"connector_ids": [],
|
||||
"product_client_id": DEFAULT_CLIENT_NAME,
|
||||
}
|
||||
}),
|
||||
]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_installed_ignores_local_cache_without_catalog() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -3415,3 +3517,38 @@ fn write_plugin_share_local_path_mapping(
|
||||
format!("{contents}\n"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn wait_for_plugin_analytics_events(
|
||||
server: &MockServer,
|
||||
expected_count: usize,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
let requests = server.received_requests().await.unwrap_or_default();
|
||||
let events = requests
|
||||
.iter()
|
||||
.filter(|request| {
|
||||
request.method == "POST"
|
||||
&& request
|
||||
.url
|
||||
.path()
|
||||
.ends_with("/codex/analytics-events/events")
|
||||
})
|
||||
.filter_map(|request| {
|
||||
serde_json::from_slice::<serde_json::Value>(&request.body).ok()
|
||||
})
|
||||
.flat_map(|payload| payload["events"].as_array().cloned().unwrap_or_default())
|
||||
.filter(|event| {
|
||||
event["event_type"]
|
||||
.as_str()
|
||||
.is_some_and(|event_type| event_type.starts_with("codex_plugin_"))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if events.len() >= expected_count {
|
||||
return Ok::<_, anyhow::Error>(events);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
@@ -139,6 +139,7 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> {
|
||||
"event_type": "codex_plugin_uninstalled",
|
||||
"event_params": {
|
||||
"plugin_id": "sample-plugin@debug",
|
||||
"remote_plugin_id": null,
|
||||
"plugin_name": "sample-plugin",
|
||||
"marketplace_name": "debug",
|
||||
"has_skills": false,
|
||||
@@ -216,6 +217,11 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/codex/analytics-events/events"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":"ok"}"#))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let remote_plugin_cache_root = codex_home
|
||||
.path()
|
||||
@@ -225,6 +231,11 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
|
||||
remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"),
|
||||
r#"{"name":"linear","version":"1.0.0"}"#,
|
||||
)?;
|
||||
std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/skills/plan-work"))?;
|
||||
std::fs::write(
|
||||
remote_plugin_cache_root.join("1.0.0/skills/plan-work/SKILL.md"),
|
||||
"---\nname: plan-work\ndescription: Plan work\n---\n",
|
||||
)?;
|
||||
let legacy_remote_plugin_cache_root = codex_home.path().join(format!(
|
||||
"plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}"
|
||||
));
|
||||
@@ -255,11 +266,31 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
|
||||
.await?;
|
||||
assert!(!remote_plugin_cache_root.exists());
|
||||
assert!(!legacy_remote_plugin_cache_root.exists());
|
||||
let payload = wait_for_plugin_analytics_payload(&server).await?;
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"events": [{
|
||||
"event_type": "codex_plugin_uninstalled",
|
||||
"event_params": {
|
||||
"plugin_id": "linear@openai-curated-remote",
|
||||
"remote_plugin_id": REMOTE_PLUGIN_ID,
|
||||
"plugin_name": "linear",
|
||||
"marketplace_name": "openai-curated-remote",
|
||||
"has_skills": true,
|
||||
"mcp_server_count": 0,
|
||||
"connector_ids": [],
|
||||
"product_client_id": DEFAULT_CLIENT_NAME,
|
||||
}
|
||||
}]
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> {
|
||||
async fn plugin_uninstall_uses_detail_identity_and_scope_for_uninstall_target() -> Result<()> {
|
||||
const REQUEST_REMOTE_PLUGIN_ID: &str = "plugins~Plugin_linear_alias";
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
@@ -274,7 +305,15 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()>
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.0.0", "WORKSPACE").await;
|
||||
mount_remote_plugin_detail_response_with_name(
|
||||
&server,
|
||||
REQUEST_REMOTE_PLUGIN_ID,
|
||||
REMOTE_PLUGIN_ID,
|
||||
"linear",
|
||||
"1.0.0",
|
||||
"WORKSPACE",
|
||||
)
|
||||
.await;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path(format!(
|
||||
@@ -307,7 +346,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()>
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: REMOTE_PLUGIN_ID.to_string(),
|
||||
plugin_id: REQUEST_REMOTE_PLUGIN_ID.to_string(),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
@@ -616,6 +655,25 @@ async fn mount_remote_plugin_detail_with_name(
|
||||
plugin_name: &str,
|
||||
release_version: &str,
|
||||
scope: &str,
|
||||
) {
|
||||
mount_remote_plugin_detail_response_with_name(
|
||||
server,
|
||||
remote_plugin_id,
|
||||
remote_plugin_id,
|
||||
plugin_name,
|
||||
release_version,
|
||||
scope,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_detail_response_with_name(
|
||||
server: &MockServer,
|
||||
request_remote_plugin_id: &str,
|
||||
response_remote_plugin_id: &str,
|
||||
plugin_name: &str,
|
||||
release_version: &str,
|
||||
scope: &str,
|
||||
) {
|
||||
let discoverability = if scope == "WORKSPACE" {
|
||||
r#"
|
||||
@@ -625,7 +683,7 @@ async fn mount_remote_plugin_detail_with_name(
|
||||
};
|
||||
let detail_body = format!(
|
||||
r#"{{
|
||||
"id": "{remote_plugin_id}",
|
||||
"id": "{response_remote_plugin_id}",
|
||||
"name": "{plugin_name}",
|
||||
"scope": "{scope}",{discoverability}
|
||||
"installation_policy": "AVAILABLE",
|
||||
@@ -644,7 +702,9 @@ async fn mount_remote_plugin_detail_with_name(
|
||||
);
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}")))
|
||||
.and(path(format!(
|
||||
"/backend-api/ps/plugins/{request_remote_plugin_id}"
|
||||
)))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(detail_body))
|
||||
@@ -686,3 +746,26 @@ async fn wait_for_remote_plugin_request_count(
|
||||
.await??;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_plugin_analytics_payload(server: &MockServer) -> Result<serde_json::Value> {
|
||||
timeout(DEFAULT_TIMEOUT, async {
|
||||
loop {
|
||||
let Some(requests) = server.received_requests().await else {
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
continue;
|
||||
};
|
||||
if let Some(request) = requests.iter().find(|request| {
|
||||
request.method == "POST"
|
||||
&& request
|
||||
.url
|
||||
.path()
|
||||
.ends_with("/codex/analytics-events/events")
|
||||
}) {
|
||||
return serde_json::from_slice(&request.body)
|
||||
.map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"));
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ use codex_plugin::PluginHookSource;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_plugin::PluginLoadOutcome;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -1019,13 +1018,11 @@ async fn load_apps_from_paths(
|
||||
connector_ids
|
||||
}
|
||||
|
||||
pub async fn plugin_telemetry_metadata_from_root(
|
||||
pub async fn plugin_capability_summary_from_root(
|
||||
plugin_id: &PluginId,
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
) -> PluginTelemetryMetadata {
|
||||
let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else {
|
||||
return PluginTelemetryMetadata::from_plugin_id(plugin_id);
|
||||
};
|
||||
) -> Option<PluginCapabilitySummary> {
|
||||
let manifest = load_plugin_manifest(plugin_root.as_path())?;
|
||||
|
||||
let manifest_paths = &manifest.paths;
|
||||
let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty();
|
||||
@@ -1041,22 +1038,18 @@ pub async fn plugin_telemetry_metadata_from_root(
|
||||
mcp_server_names.sort_unstable();
|
||||
mcp_server_names.dedup();
|
||||
|
||||
PluginTelemetryMetadata {
|
||||
plugin_id: plugin_id.clone(),
|
||||
remote_plugin_id: None,
|
||||
capability_summary: Some(PluginCapabilitySummary {
|
||||
config_name: plugin_id.as_key(),
|
||||
display_name: plugin_id.plugin_name.clone(),
|
||||
description: None,
|
||||
has_skills,
|
||||
mcp_server_names,
|
||||
app_connector_ids: load_apps_from_paths(
|
||||
plugin_root.as_path(),
|
||||
plugin_app_config_paths(plugin_root.as_path(), manifest_paths),
|
||||
)
|
||||
.await,
|
||||
}),
|
||||
}
|
||||
Some(PluginCapabilitySummary {
|
||||
config_name: plugin_id.as_key(),
|
||||
display_name: plugin_id.plugin_name.clone(),
|
||||
description: None,
|
||||
has_skills,
|
||||
mcp_server_names,
|
||||
app_connector_ids: load_apps_from_paths(
|
||||
plugin_root.as_path(),
|
||||
plugin_app_config_paths(plugin_root.as_path(), manifest_paths),
|
||||
)
|
||||
.await,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn load_plugin_mcp_servers(plugin_root: &Path) -> HashMap<String, McpServerConfig> {
|
||||
@@ -1075,24 +1068,6 @@ pub async fn load_plugin_mcp_servers(plugin_root: &Path) -> HashMap<String, McpS
|
||||
mcp_servers
|
||||
}
|
||||
|
||||
pub async fn installed_plugin_telemetry_metadata(
|
||||
codex_home: &Path,
|
||||
plugin_id: &PluginId,
|
||||
) -> PluginTelemetryMetadata {
|
||||
let store = match PluginStore::try_new(codex_home.to_path_buf()) {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
warn!("failed to resolve plugin cache root: {err}");
|
||||
return PluginTelemetryMetadata::from_plugin_id(plugin_id);
|
||||
}
|
||||
};
|
||||
let Some(plugin_root) = store.active_plugin_root(plugin_id) else {
|
||||
return PluginTelemetryMetadata::from_plugin_id(plugin_id);
|
||||
};
|
||||
|
||||
plugin_telemetry_metadata_from_root(plugin_id, &plugin_root).await
|
||||
}
|
||||
|
||||
async fn load_mcp_servers_from_file(
|
||||
plugin_root: &Path,
|
||||
mcp_config_path: &AbsolutePathBuf,
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
|
||||
use crate::loader::PluginHookLoadOutcome;
|
||||
use crate::loader::configured_curated_plugin_ids_from_codex_home;
|
||||
use crate::loader::curated_plugin_cache_version;
|
||||
use crate::loader::installed_plugin_telemetry_metadata;
|
||||
use crate::loader::load_plugin_apps;
|
||||
use crate::loader::load_plugin_hooks;
|
||||
use crate::loader::load_plugin_hooks_from_layer_stack;
|
||||
@@ -13,7 +12,7 @@ use crate::loader::load_plugin_skills;
|
||||
use crate::loader::load_plugins_from_layer_stack;
|
||||
use crate::loader::log_plugin_load_errors;
|
||||
use crate::loader::materialize_marketplace_plugin_source;
|
||||
use crate::loader::plugin_telemetry_metadata_from_root;
|
||||
use crate::loader::plugin_capability_summary_from_root;
|
||||
use crate::loader::refresh_curated_plugin_cache;
|
||||
use crate::loader::refresh_non_curated_plugin_cache;
|
||||
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall;
|
||||
@@ -63,6 +62,7 @@ use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_plugin::prompt_safe_plugin_description;
|
||||
use codex_protocol::protocol::HookEventName;
|
||||
use codex_protocol::protocol::Product;
|
||||
@@ -538,6 +538,61 @@ impl PluginsManager {
|
||||
remote_installed_plugins_to_config(plugins, &self.store)
|
||||
}
|
||||
|
||||
fn remote_plugin_id_for(&self, plugin_id: &PluginId) -> Option<String> {
|
||||
let cache = match self.remote_installed_plugins_cache.read() {
|
||||
Ok(cache) => cache,
|
||||
Err(err) => err.into_inner(),
|
||||
};
|
||||
cache.as_ref()?.iter().find_map(|plugin| {
|
||||
(plugin.name == plugin_id.plugin_name
|
||||
&& plugin.marketplace_name == plugin_id.marketplace_name)
|
||||
.then(|| plugin.id.clone())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn telemetry_metadata_for_installed_plugin(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
) -> PluginTelemetryMetadata {
|
||||
let capability_summary = match self.store.active_plugin_root(plugin_id) {
|
||||
Some(plugin_root) => plugin_capability_summary_from_root(plugin_id, &plugin_root).await,
|
||||
None => None,
|
||||
};
|
||||
PluginTelemetryMetadata {
|
||||
plugin_id: plugin_id.clone(),
|
||||
remote_plugin_id: self.remote_plugin_id_for(plugin_id),
|
||||
capability_summary,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn telemetry_metadata_for_installed_plugin_with_remote_id(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
remote_plugin_id: &str,
|
||||
) -> PluginTelemetryMetadata {
|
||||
let capability_summary = match self.store.active_plugin_root(plugin_id) {
|
||||
Some(plugin_root) => plugin_capability_summary_from_root(plugin_id, &plugin_root).await,
|
||||
None => None,
|
||||
};
|
||||
PluginTelemetryMetadata {
|
||||
plugin_id: plugin_id.clone(),
|
||||
remote_plugin_id: Some(remote_plugin_id.to_string()),
|
||||
capability_summary,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn telemetry_metadata_for_capability_summary(
|
||||
&self,
|
||||
summary: &PluginCapabilitySummary,
|
||||
) -> Option<PluginTelemetryMetadata> {
|
||||
let plugin_id = PluginId::parse(&summary.config_name).ok()?;
|
||||
Some(PluginTelemetryMetadata {
|
||||
remote_plugin_id: self.remote_plugin_id_for(&plugin_id),
|
||||
plugin_id,
|
||||
capability_summary: Some(summary.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_remote_installed_plugin_marketplaces_from_cache(
|
||||
&self,
|
||||
visible_scopes: &[RemotePluginScope],
|
||||
@@ -875,7 +930,7 @@ impl PluginsManager {
|
||||
};
|
||||
if let Some(analytics_events_client) = analytics_events_client {
|
||||
analytics_events_client.track_plugin_installed(
|
||||
plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path)
|
||||
self.telemetry_metadata_for_installed_plugin(&result.plugin_id)
|
||||
.await,
|
||||
);
|
||||
}
|
||||
@@ -916,7 +971,10 @@ impl PluginsManager {
|
||||
|
||||
async fn uninstall_plugin_id(&self, plugin_id: PluginId) -> Result<(), PluginUninstallError> {
|
||||
let plugin_telemetry = if self.store.active_plugin_root(&plugin_id).is_some() {
|
||||
Some(installed_plugin_telemetry_metadata(self.codex_home.as_path(), &plugin_id).await)
|
||||
Some(
|
||||
self.telemetry_metadata_for_installed_plugin(&plugin_id)
|
||||
.await,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -368,6 +368,112 @@ remote_plugin = true
|
||||
assert_eq!(outcome, PluginLoadOutcome::default());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_plugin_telemetry_metadata_uses_local_identity_without_remote_snapshot() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
write_cached_plugin(codex_home.path(), "test", "sample");
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse");
|
||||
|
||||
let metadata = manager
|
||||
.telemetry_metadata_for_installed_plugin(&plugin_id)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
metadata,
|
||||
PluginTelemetryMetadata {
|
||||
plugin_id,
|
||||
remote_plugin_id: None,
|
||||
capability_summary: Some(PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "sample".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_plugin_telemetry_metadata_resolves_remote_snapshot_identity() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]);
|
||||
let plugin_id =
|
||||
PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse");
|
||||
|
||||
let metadata = manager
|
||||
.telemetry_metadata_for_installed_plugin(&plugin_id)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
metadata,
|
||||
PluginTelemetryMetadata {
|
||||
plugin_id,
|
||||
remote_plugin_id: Some("plugins~Plugin_linear".to_string()),
|
||||
capability_summary: Some(PluginCapabilitySummary {
|
||||
config_name: "linear@openai-curated-remote".to_string(),
|
||||
display_name: "linear".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
app_connector_ids: Vec::new(),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_plugin_telemetry_metadata_accepts_authoritative_remote_identity() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
let plugin_id =
|
||||
PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse");
|
||||
|
||||
let metadata = manager
|
||||
.telemetry_metadata_for_installed_plugin_with_remote_id(&plugin_id, "plugins~Plugin_linear")
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
metadata,
|
||||
PluginTelemetryMetadata {
|
||||
plugin_id,
|
||||
remote_plugin_id: Some("plugins~Plugin_linear".to_string()),
|
||||
capability_summary: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capability_summary_telemetry_metadata_resolves_remote_snapshot_identity() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
||||
manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]);
|
||||
let summary = PluginCapabilitySummary {
|
||||
config_name: "linear@openai-curated-remote".to_string(),
|
||||
display_name: "Linear".to_string(),
|
||||
description: Some("Track work".to_string()),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["linear".to_string()],
|
||||
app_connector_ids: vec![AppConnectorId("linear-app".to_string())],
|
||||
};
|
||||
|
||||
let metadata = manager.telemetry_metadata_for_capability_summary(&summary);
|
||||
|
||||
assert_eq!(
|
||||
metadata,
|
||||
Some(PluginTelemetryMetadata {
|
||||
plugin_id: PluginId::parse("linear@openai-curated-remote")
|
||||
.expect("plugin id should parse"),
|
||||
remote_plugin_id: Some("plugins~Plugin_linear".to_string()),
|
||||
capability_summary: Some(summary),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -638,14 +744,14 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() {
|
||||
}"#,
|
||||
);
|
||||
|
||||
let metadata = plugin_telemetry_metadata_from_root(
|
||||
let summary = plugin_capability_summary_from_root(
|
||||
&PluginId::parse("sample@test").expect("plugin id should parse"),
|
||||
&plugin_root.abs(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
metadata.capability_summary,
|
||||
summary,
|
||||
Some(PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "sample".to_string(),
|
||||
|
||||
@@ -99,6 +99,12 @@ pub struct RemotePluginServiceConfig {
|
||||
pub chatgpt_base_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemotePluginUninstallTarget {
|
||||
pub plugin_id: PluginId,
|
||||
pub remote_plugin_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RemoteMarketplace {
|
||||
pub name: String,
|
||||
@@ -1068,42 +1074,64 @@ pub async fn install_remote_plugin(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resolve_remote_plugin_uninstall_target(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
remote_plugin_id: &str,
|
||||
) -> Result<RemotePluginUninstallTarget, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let plugin = fetch_plugin_detail(
|
||||
config,
|
||||
auth,
|
||||
remote_plugin_id,
|
||||
/*include_download_urls*/ false,
|
||||
)
|
||||
.await?;
|
||||
let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string();
|
||||
let remote_plugin_id = plugin.id;
|
||||
let plugin_id = PluginId::new(plugin.name, marketplace_name).map_err(|err| {
|
||||
RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
"invalid local plugin id for remote plugin `{remote_plugin_id}`: {err}"
|
||||
))
|
||||
})?;
|
||||
Ok(RemotePluginUninstallTarget {
|
||||
plugin_id,
|
||||
remote_plugin_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn uninstall_remote_plugin(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
codex_home: PathBuf,
|
||||
plugin_id: &str,
|
||||
target: RemotePluginUninstallTarget,
|
||||
) -> Result<(), RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let plugin = fetch_plugin_detail(
|
||||
config, auth, plugin_id, /*include_download_urls*/ false,
|
||||
)
|
||||
.await?;
|
||||
let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string();
|
||||
let plugin_name = plugin.name;
|
||||
|
||||
let RemotePluginUninstallTarget {
|
||||
plugin_id,
|
||||
remote_plugin_id,
|
||||
} = target;
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/plugins/{plugin_id}/uninstall");
|
||||
let url = format!("{base_url}/plugins/{remote_plugin_id}/uninstall");
|
||||
let client = build_reqwest_client();
|
||||
let request = authenticated_request(client.post(&url), auth)?;
|
||||
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
|
||||
if response.id != plugin_id {
|
||||
if response.id != remote_plugin_id {
|
||||
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
expected: remote_plugin_id,
|
||||
actual: response.id,
|
||||
});
|
||||
}
|
||||
if response.enabled {
|
||||
return Err(RemotePluginCatalogError::UnexpectedEnabledState {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
plugin_id: response.id,
|
||||
expected_enabled: false,
|
||||
actual_enabled: response.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
let legacy_plugin_id = plugin_id.to_string();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
remove_remote_plugin_cache(codex_home, marketplace_name, plugin_name, legacy_plugin_id)
|
||||
remove_remote_plugin_cache(codex_home, plugin_id, response.id)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
@@ -1118,18 +1146,11 @@ pub async fn uninstall_remote_plugin(
|
||||
|
||||
fn remove_remote_plugin_cache(
|
||||
codex_home: PathBuf,
|
||||
marketplace_name: String,
|
||||
plugin_name: String,
|
||||
plugin_id: PluginId,
|
||||
legacy_plugin_id: String,
|
||||
) -> Result<(), String> {
|
||||
let store = PluginStore::try_new(codex_home.clone())
|
||||
.map_err(|err| format!("failed to resolve remote plugin cache root: {err}"))?;
|
||||
let plugin_id =
|
||||
PluginId::new(plugin_name.clone(), marketplace_name.clone()).map_err(|err| {
|
||||
format!(
|
||||
"invalid remote plugin cache id for `{plugin_name}` in `{marketplace_name}`: {err}"
|
||||
)
|
||||
})?;
|
||||
let plugin_cache_root = store.plugin_base_root(&plugin_id);
|
||||
store.uninstall(&plugin_id).map_err(|err| {
|
||||
format!(
|
||||
@@ -1140,7 +1161,7 @@ fn remove_remote_plugin_cache(
|
||||
|
||||
let legacy_remote_plugin_cache_root = codex_home
|
||||
.join(PLUGINS_CACHE_DIR)
|
||||
.join(marketplace_name)
|
||||
.join(plugin_id.marketplace_name)
|
||||
.join(legacy_plugin_id);
|
||||
if legacy_remote_plugin_cache_root != plugin_cache_root.as_path()
|
||||
&& legacy_remote_plugin_cache_root.exists()
|
||||
|
||||
@@ -561,13 +561,16 @@ async fn build_skills_and_plugins(
|
||||
sess.services
|
||||
.analytics_events_client
|
||||
.track_app_mentioned(tracking.clone(), mentioned_app_invocations);
|
||||
for plugin in mentioned_plugins
|
||||
.iter()
|
||||
.filter_map(crate::plugins::PluginCapabilitySummary::telemetry_metadata)
|
||||
{
|
||||
sess.services
|
||||
.analytics_events_client
|
||||
.track_plugin_used(tracking.clone(), plugin);
|
||||
for summary in &mentioned_plugins {
|
||||
if let Some(plugin) = sess
|
||||
.services
|
||||
.plugins_manager
|
||||
.telemetry_metadata_for_capability_summary(summary)
|
||||
{
|
||||
sess.services
|
||||
.analytics_events_client
|
||||
.track_plugin_used(tracking.clone(), plugin);
|
||||
}
|
||||
}
|
||||
|
||||
let mut injection_items: Vec<ResponseItem> = match injected_host_skill_prompts {
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core_plugins::remote::RemotePluginScope;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -23,11 +24,18 @@ use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_mcp_server;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
|
||||
const SAMPLE_PLUGIN_CONFIG_NAME: &str = "sample@test";
|
||||
const SAMPLE_PLUGIN_DISPLAY_NAME: &str = "sample";
|
||||
const SAMPLE_PLUGIN_DESCRIPTION: &str = "inspect sample data";
|
||||
const REMOTE_SAMPLE_PLUGIN_CONFIG_NAME: &str = "sample@openai-curated-remote";
|
||||
const REMOTE_SAMPLE_PLUGIN_ID: &str = "plugins~Plugin_sample";
|
||||
|
||||
fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf {
|
||||
home.path().join("plugins/cache/test/sample/local")
|
||||
@@ -65,6 +73,33 @@ fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf {
|
||||
skill_dir.join("SKILL.md")
|
||||
}
|
||||
|
||||
fn write_remote_plugin_skill_plugin(home: &TempDir) {
|
||||
let plugin_root = home
|
||||
.path()
|
||||
.join("plugins/cache/openai-curated-remote/sample/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))
|
||||
.expect("create remote plugin manifest dir");
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample","description":"inspect sample data"}"#,
|
||||
)
|
||||
.expect("write remote plugin manifest");
|
||||
let skill_dir = plugin_root.join("skills/sample-search");
|
||||
std::fs::create_dir_all(&skill_dir).expect("create remote plugin skill dir");
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\ndescription: inspect sample data\n---\n\n# body\n",
|
||||
)
|
||||
.expect("write remote plugin skill");
|
||||
std::fs::write(
|
||||
home.path().join("config.toml"),
|
||||
format!(
|
||||
"[features]\nplugins = true\nremote_plugin = true\n\n[plugins.\"{REMOTE_SAMPLE_PLUGIN_CONFIG_NAME}\"]\nenabled = true\n"
|
||||
),
|
||||
)
|
||||
.expect("write remote plugin config");
|
||||
}
|
||||
|
||||
fn write_plugin_mcp_plugin(home: &TempDir, command: &str) {
|
||||
let plugin_root = write_sample_plugin_manifest_and_config(home);
|
||||
std::fs::write(
|
||||
@@ -358,32 +393,12 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> {
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let plugin_event = loop {
|
||||
let requests = server.received_requests().await.unwrap_or_default();
|
||||
if let Some(event) = requests
|
||||
.into_iter()
|
||||
.filter(|request| request.url.path() == "/codex/analytics-events/events")
|
||||
.find_map(|request| {
|
||||
let payload: serde_json::Value = serde_json::from_slice(&request.body).ok()?;
|
||||
payload["events"].as_array().and_then(|events| {
|
||||
events
|
||||
.iter()
|
||||
.find(|event| event["event_type"] == "codex_plugin_used")
|
||||
.cloned()
|
||||
})
|
||||
})
|
||||
{
|
||||
break event;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
panic!("timed out waiting for plugin analytics request");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
};
|
||||
|
||||
let event = plugin_event;
|
||||
let event = wait_for_plugin_used_analytics_event(&server).await;
|
||||
assert_eq!(event["event_params"]["plugin_id"], "sample@test");
|
||||
assert_eq!(
|
||||
event["event_params"]["remote_plugin_id"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(event["event_params"]["plugin_name"], "sample");
|
||||
assert_eq!(event["event_params"]["marketplace_name"], "test");
|
||||
assert_eq!(event["event_params"]["has_skills"], true);
|
||||
@@ -406,3 +421,122 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn explicit_remote_plugin_mentions_track_remote_plugin_id() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = start_mock_server().await;
|
||||
let _resp_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]),
|
||||
)
|
||||
.await;
|
||||
let global_installed_body = format!(
|
||||
r#"{{
|
||||
"plugins": [{{
|
||||
"id": "{REMOTE_SAMPLE_PLUGIN_ID}",
|
||||
"name": "sample",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {{
|
||||
"version": "1.0.0",
|
||||
"display_name": "Sample",
|
||||
"description": "Inspect sample data",
|
||||
"app_ids": [],
|
||||
"interface": {{}},
|
||||
"skills": []
|
||||
}},
|
||||
"enabled": true,
|
||||
"disabled_skill_names": []
|
||||
}}],
|
||||
"pagination": {{"limit": 50, "next_page_token": null}}
|
||||
}}"#
|
||||
);
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/installed"))
|
||||
.and(query_param("scope", "GLOBAL"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(global_installed_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/ps/plugins/installed"))
|
||||
.and(query_param("scope", "WORKSPACE"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(
|
||||
r#"{"plugins":[],"pagination":{"limit":50,"next_page_token":null}}"#,
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let codex_home = Arc::new(TempDir::new()?);
|
||||
write_remote_plugin_skill_plugin(codex_home.as_ref());
|
||||
let test_codex = build_analytics_plugin_test_codex(&server, codex_home).await?;
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
test_codex
|
||||
.thread_manager
|
||||
.plugins_manager()
|
||||
.build_and_cache_remote_installed_plugin_marketplaces(
|
||||
&test_codex.config.plugins_config_input(),
|
||||
Some(&auth),
|
||||
&[RemotePluginScope::Global],
|
||||
/*on_effective_plugins_changed*/ None,
|
||||
)
|
||||
.await?;
|
||||
let codex = Arc::clone(&test_codex.codex);
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
environments: None,
|
||||
items: vec![codex_protocol::user_input::UserInput::Mention {
|
||||
name: "sample".into(),
|
||||
path: format!("plugin://{REMOTE_SAMPLE_PLUGIN_CONFIG_NAME}"),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
additional_context: Default::default(),
|
||||
thread_settings: Default::default(),
|
||||
})
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let event = wait_for_plugin_used_analytics_event(&server).await;
|
||||
assert_eq!(
|
||||
event["event_params"]["plugin_id"],
|
||||
REMOTE_SAMPLE_PLUGIN_CONFIG_NAME
|
||||
);
|
||||
assert_eq!(
|
||||
event["event_params"]["remote_plugin_id"],
|
||||
REMOTE_SAMPLE_PLUGIN_ID
|
||||
);
|
||||
assert_eq!(event["event_params"]["has_skills"], true);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_plugin_used_analytics_event(server: &MockServer) -> serde_json::Value {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let requests = server.received_requests().await.unwrap_or_default();
|
||||
if let Some(event) = requests
|
||||
.into_iter()
|
||||
.filter(|request| request.url.path() == "/codex/analytics-events/events")
|
||||
.find_map(|request| {
|
||||
let payload: serde_json::Value = serde_json::from_slice(&request.body).ok()?;
|
||||
payload["events"].as_array().and_then(|events| {
|
||||
events
|
||||
.iter()
|
||||
.find(|event| event["event_type"] == "codex_plugin_used")
|
||||
.cloned()
|
||||
})
|
||||
})
|
||||
{
|
||||
return event;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
panic!("timed out waiting for plugin analytics request");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,31 +41,9 @@ pub struct PluginHookSource {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginTelemetryMetadata {
|
||||
/// Local plugin identifier used by Codex configuration and the plugin cache.
|
||||
pub plugin_id: PluginId,
|
||||
/// Optional backend identifier for remote plugins, used when analytics
|
||||
/// should report the remote id instead of the local plugin cache id.
|
||||
/// Optional backend identifier for remote plugins.
|
||||
pub remote_plugin_id: Option<String>,
|
||||
pub capability_summary: Option<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl PluginTelemetryMetadata {
|
||||
pub fn from_plugin_id(plugin_id: &PluginId) -> Self {
|
||||
Self {
|
||||
plugin_id: plugin_id.clone(),
|
||||
remote_plugin_id: None,
|
||||
capability_summary: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginCapabilitySummary {
|
||||
pub fn telemetry_metadata(&self) -> Option<PluginTelemetryMetadata> {
|
||||
PluginId::parse(&self.config_name)
|
||||
.ok()
|
||||
.map(|plugin_id| PluginTelemetryMetadata {
|
||||
plugin_id,
|
||||
remote_plugin_id: None,
|
||||
capability_summary: Some(self.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user