From 3959ab0ffcd4e48620d5a380ccad66f00176a6e4 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Wed, 17 Jun 2026 13:16:34 -0700 Subject: [PATCH] [codex] Track plugin install and import telemetry failures (#28731) ## Summary - Track plugin install failures through the unified `codex_plugin_install_failed` event for local installs, remote install preflight failures, bundle failures, and remote catalog/backend failures. - Send classified `error_type` values in plugin install failure analytics instead of raw error strings. - Stop sending raw external-agent import errors in analytics while preserving raw failure details in app-facing import notifications/history. - Keep raw plugin/migration diagnostics in `tracing::warn!` logs. - Keep remote failure plugin names as the existing local placeholder (`unknown`) and remove the extra telemetry plugin-name override. - Change `ExternalAgentConfigImportParams.source` from a generated enum to `string | null`, with legacy `claudeCode` / `claudeCowork` inputs normalized to existing analytics values. ## Testing --- .../analytics/src/analytics_client_tests.rs | 221 ++++++++++++++ codex-rs/analytics/src/client.rs | 30 ++ codex-rs/analytics/src/events.rs | 50 ++++ codex-rs/analytics/src/facts.rs | 24 ++ codex-rs/analytics/src/lib.rs | 2 + codex-rs/analytics/src/reducer.rs | 75 +++++ .../schema/json/ClientRequest.json | 9 +- .../codex_app_server_protocol.schemas.json | 9 +- .../codex_app_server_protocol.v2.schemas.json | 9 +- .../v2/ExternalAgentConfigDetectParams.json | 2 +- .../v2/ExternalAgentConfigImportParams.json | 7 + .../v2/ExternalAgentConfigDetectParams.ts | 2 +- .../v2/ExternalAgentConfigImportParams.ts | 6 +- .../src/protocol/v2/config.rs | 5 +- .../src/protocol/v2/tests.rs | 1 + codex-rs/app-server/README.md | 2 +- .../src/config/external_agent_config.rs | 27 +- .../src/config/external_agent_config_tests.rs | 48 ++-- codex-rs/app-server/src/message_processor.rs | 3 +- .../external_agent_config_processor.rs | 91 +++++- .../src/request_processors/plugins.rs | 162 ++++++++++- .../tests/suite/v2/external_agent_config.rs | 272 ++++++++++++++++-- .../tests/suite/v2/plugin_install.rs | 187 +++++++++++- codex-rs/core-plugins/src/manager.rs | 162 ++++++++++- codex-rs/tui/src/app_server_session.rs | 5 +- 25 files changed, 1314 insertions(+), 97 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index d7be7f1df..d6792587f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -9,7 +9,11 @@ use crate::events::CodexCommandExecutionEventParams; use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; use crate::events::CodexHookRunEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureMetadata; use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginInstallFailedEventRequest; +use crate::events::CodexPluginInstallFailedMetadata; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexReviewEventParams; use crate::events::CodexReviewEventRequest; @@ -51,10 +55,13 @@ use crate::facts::CompactionStatus; use crate::facts::CompactionStrategy; use crate::facts::CompactionTrigger; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunFact; use crate::facts::HookRunInput; use crate::facts::InputError; use crate::facts::InvocationType; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -3054,6 +3061,36 @@ fn plugin_management_event_serializes_expected_shape() { ); } +#[test] +fn plugin_install_failed_event_serializes_expected_shape() { + let event = TrackEventRequest::PluginInstallFailed(CodexPluginInstallFailedEventRequest { + event_type: "codex_plugin_install_failed", + event_params: CodexPluginInstallFailedMetadata { + plugin: codex_plugin_metadata(sample_plugin_metadata()), + error_type: "store_io".to_string(), + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize plugin install failed event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value, + "error_type": "store_io" + } + }) + ); +} + #[test] fn plugin_management_event_can_use_remote_plugin_id_override() { let mut plugin = sample_plugin_metadata(); @@ -3421,6 +3458,190 @@ async fn reducer_ingests_plugin_state_changed_fact() { ); } +#[tokio::test] +async fn reducer_ingests_plugin_install_failed_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed( + PluginInstallFailedInput { + plugin: sample_plugin_metadata(), + error_type: "invalid_plugin".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "sample@test", + "plugin_name": "sample", + "marketplace_name": "test", + "has_skills": true, + "mcp_server_count": 2, + "connector_ids": ["calendar", "drive"], + "product_client_id": originator().value, + "error_type": "invalid_plugin" + } + }]) + ); +} + +#[tokio::test] +async fn reducer_ingests_plugin_install_failed_fact_without_detail() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + let mut plugin = PluginTelemetryMetadata::from_plugin_id( + &PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), + ); + plugin.remote_plugin_id = Some("plugins~Plugin_00000000000000000000000000000000".to_string()); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::PluginInstallFailed( + PluginInstallFailedInput { + plugin, + error_type: "remote_catalog_unexpected_status".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_plugin_install_failed", + "event_params": { + "plugin_id": "plugins~Plugin_00000000000000000000000000000000", + "plugin_name": "unknown", + "marketplace_name": "openai-curated-remote", + "has_skills": null, + "mcp_server_count": null, + "connector_ids": null, + "product_client_id": originator().value, + "error_type": "remote_catalog_unexpected_status" + } + }]) + ); +} + +#[tokio::test] +async fn reducer_ingests_external_agent_config_import_completed_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::ExternalAgentConfigImportCompleted( + ExternalAgentConfigImportCompletedInput { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "PLUGINS".to_string(), + success_count: 2, + failed_count: 1, + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_onboarding_external_agent_import_complete", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "PLUGINS", + "success_count": 2, + "failed_count": 1, + "product_client_id": originator().value, + } + }]) + ); +} + +#[test] +fn external_agent_config_import_failure_event_serializes_expected_shape() { + let event = TrackEventRequest::ExternalAgentConfigImportFailure( + CodexOnboardingExternalAgentImportFailureEventRequest { + event_type: "codex_onboarding_external_agent_import_failure", + event_params: CodexOnboardingExternalAgentImportFailureMetadata { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "SESSIONS".to_string(), + failure_stage: "session_missing".to_string(), + error_type: "session_missing".to_string(), + product_client_id: Some(originator().value), + }, + }, + ); + + let payload = serde_json::to_value(&event).expect("serialize import failure event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_onboarding_external_agent_import_failure", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "SESSIONS", + "failure_stage": "session_missing", + "error_type": "session_missing", + "product_client_id": originator().value, + } + }) + ); +} + +#[tokio::test] +async fn reducer_ingests_external_agent_config_import_failure_fact() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::ExternalAgentConfigImportFailure( + ExternalAgentConfigImportFailureInput { + import_id: "import-1".to_string(), + source: "app_server".to_string(), + item_type: "SESSIONS".to_string(), + failure_stage: "session_missing".to_string(), + error_type: "session_missing".to_string(), + }, + )), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload, + json!([{ + "event_type": "codex_onboarding_external_agent_import_failure", + "event_params": { + "import_id": "import-1", + "source": "app_server", + "type": "SESSIONS", + "failure_stage": "session_missing", + "error_type": "session_missing", + "product_client_id": originator().value, + } + }]) + ); +} + #[test] fn turn_event_serializes_expected_shape() { let event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index e88c0114a..24ff523c9 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -11,8 +11,11 @@ use crate::facts::AppMentionedInput; use crate::facts::AppUsedInput; use crate::facts::CodexGoalEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunFact; use crate::facts::HookRunInput; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::SkillInvocation; @@ -343,6 +346,33 @@ impl AnalyticsEventsClient { )); } + pub fn track_plugin_install_failed(&self, plugin: PluginTelemetryMetadata, error_type: String) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::PluginInstallFailed(PluginInstallFailedInput { + plugin, + error_type, + }), + )); + } + + pub fn track_external_agent_config_import_completed( + &self, + input: ExternalAgentConfigImportCompletedInput, + ) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::ExternalAgentConfigImportCompleted(input), + )); + } + + pub fn track_external_agent_config_import_failure( + &self, + input: ExternalAgentConfigImportFailureInput, + ) { + self.record_fact(AnalyticsFact::Custom( + CustomAnalyticsFact::ExternalAgentConfigImportFailure(input), + )); + } + pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) { self.record_fact(AnalyticsFact::Custom( CustomAnalyticsFact::PluginStateChanged(PluginStateChangedInput { diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index eed972852..29d896156 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -83,6 +83,9 @@ pub(crate) enum TrackEventRequest { PluginUninstalled(CodexPluginEventRequest), PluginEnabled(CodexPluginEventRequest), PluginDisabled(CodexPluginEventRequest), + PluginInstallFailed(CodexPluginInstallFailedEventRequest), + ExternalAgentConfigImportCompleted(CodexOnboardingExternalAgentImportCompleteEventRequest), + ExternalAgentConfigImportFailure(CodexOnboardingExternalAgentImportFailureEventRequest), } impl TrackEventRequest { @@ -954,6 +957,53 @@ pub(crate) struct CodexPluginEventRequest { pub(crate) event_params: CodexPluginMetadata, } +#[derive(Serialize)] +pub(crate) struct CodexPluginInstallFailedMetadata { + #[serde(flatten)] + pub(crate) plugin: CodexPluginMetadata, + pub(crate) error_type: String, +} + +#[derive(Serialize)] +pub(crate) struct CodexPluginInstallFailedEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexPluginInstallFailedMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportCompleteMetadata { + pub(crate) import_id: String, + pub(crate) source: String, + #[serde(rename = "type")] + pub(crate) item_type: String, + pub(crate) success_count: usize, + pub(crate) failed_count: usize, + pub(crate) product_client_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportCompleteEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexOnboardingExternalAgentImportCompleteMetadata, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportFailureMetadata { + pub(crate) import_id: String, + pub(crate) source: String, + #[serde(rename = "type")] + pub(crate) item_type: String, + pub(crate) failure_stage: String, + pub(crate) error_type: String, + pub(crate) product_client_id: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexOnboardingExternalAgentImportFailureEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexOnboardingExternalAgentImportFailureMetadata, +} + #[derive(Serialize)] pub(crate) struct CodexPluginUsedEventRequest { pub(crate) event_type: &'static str, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index ab278e223..a8d1d6d6b 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -504,6 +504,9 @@ pub(crate) enum CustomAnalyticsFact { HookRun(HookRunInput), PluginUsed(PluginUsedInput), PluginStateChanged(PluginStateChangedInput), + PluginInstallFailed(PluginInstallFailedInput), + ExternalAgentConfigImportCompleted(ExternalAgentConfigImportCompletedInput), + ExternalAgentConfigImportFailure(ExternalAgentConfigImportFailureInput), } pub(crate) struct SkillInvokedInput { @@ -542,6 +545,27 @@ pub(crate) struct PluginStateChangedInput { pub state: PluginState, } +pub(crate) struct PluginInstallFailedInput { + pub plugin: PluginTelemetryMetadata, + pub error_type: String, +} + +pub struct ExternalAgentConfigImportCompletedInput { + pub import_id: String, + pub source: String, + pub item_type: String, + pub success_count: usize, + pub failed_count: usize, +} + +pub struct ExternalAgentConfigImportFailureInput { + pub import_id: String, + pub source: String, + pub item_type: String, + pub failure_stage: String, + pub error_type: String, +} + #[derive(Clone, Copy)] pub(crate) enum PluginState { Installed, diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index 687a3a763..58228f5c5 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -36,6 +36,8 @@ pub use facts::CompactionReason; pub use facts::CompactionStatus; pub use facts::CompactionStrategy; pub use facts::CompactionTrigger; +pub use facts::ExternalAgentConfigImportCompletedInput; +pub use facts::ExternalAgentConfigImportFailureInput; pub use facts::GoalEventKind; pub use facts::HookRunFact; pub use facts::InputError; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index 89d6e57dc..b647984df 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -21,7 +21,13 @@ use crate::events::CodexImageGenerationEventParams; use crate::events::CodexImageGenerationEventRequest; use crate::events::CodexMcpToolCallEventParams; use crate::events::CodexMcpToolCallEventRequest; +use crate::events::CodexOnboardingExternalAgentImportCompleteEventRequest; +use crate::events::CodexOnboardingExternalAgentImportCompleteMetadata; +use crate::events::CodexOnboardingExternalAgentImportFailureEventRequest; +use crate::events::CodexOnboardingExternalAgentImportFailureMetadata; use crate::events::CodexPluginEventRequest; +use crate::events::CodexPluginInstallFailedEventRequest; +use crate::events::CodexPluginInstallFailedMetadata; use crate::events::CodexPluginUsedEventRequest; use crate::events::CodexReviewEventParams; use crate::events::CodexReviewEventRequest; @@ -66,7 +72,10 @@ use crate::facts::AppUsedInput; use crate::facts::CodexCompactionEvent; use crate::facts::CodexGoalEvent; use crate::facts::CustomAnalyticsFact; +use crate::facts::ExternalAgentConfigImportCompletedInput; +use crate::facts::ExternalAgentConfigImportFailureInput; use crate::facts::HookRunInput; +use crate::facts::PluginInstallFailedInput; use crate::facts::PluginState; use crate::facts::PluginStateChangedInput; use crate::facts::PluginUsedInput; @@ -511,6 +520,15 @@ impl AnalyticsReducer { CustomAnalyticsFact::PluginStateChanged(input) => { self.ingest_plugin_state_changed(input, out); } + CustomAnalyticsFact::PluginInstallFailed(input) => { + self.ingest_plugin_install_failed(input, out); + } + CustomAnalyticsFact::ExternalAgentConfigImportCompleted(input) => { + self.ingest_external_agent_config_import_completed(input, out); + } + CustomAnalyticsFact::ExternalAgentConfigImportFailure(input) => { + self.ingest_external_agent_config_import_failure(input, out); + } }, } } @@ -775,6 +793,63 @@ impl AnalyticsReducer { }); } + fn ingest_plugin_install_failed( + &mut self, + input: PluginInstallFailedInput, + out: &mut Vec, + ) { + let PluginInstallFailedInput { plugin, error_type } = input; + out.push(TrackEventRequest::PluginInstallFailed( + CodexPluginInstallFailedEventRequest { + event_type: "codex_plugin_install_failed", + event_params: CodexPluginInstallFailedMetadata { + plugin: codex_plugin_metadata(plugin), + error_type, + }, + }, + )); + } + + fn ingest_external_agent_config_import_completed( + &mut self, + input: ExternalAgentConfigImportCompletedInput, + out: &mut Vec, + ) { + out.push(TrackEventRequest::ExternalAgentConfigImportCompleted( + CodexOnboardingExternalAgentImportCompleteEventRequest { + event_type: "codex_onboarding_external_agent_import_complete", + event_params: CodexOnboardingExternalAgentImportCompleteMetadata { + import_id: input.import_id, + source: input.source, + item_type: input.item_type, + success_count: input.success_count, + failed_count: input.failed_count, + product_client_id: Some(originator().value), + }, + }, + )); + } + + fn ingest_external_agent_config_import_failure( + &mut self, + input: ExternalAgentConfigImportFailureInput, + out: &mut Vec, + ) { + out.push(TrackEventRequest::ExternalAgentConfigImportFailure( + CodexOnboardingExternalAgentImportFailureEventRequest { + event_type: "codex_onboarding_external_agent_import_failure", + event_params: CodexOnboardingExternalAgentImportFailureMetadata { + import_id: input.import_id, + source: input.source, + item_type: input.item_type, + failure_stage: input.failure_stage, + error_type: input.error_type, + product_client_id: Some(originator().value), + }, + }, + )); + } + async fn ingest_response( &mut self, connection_id: u64, diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4f04698e3..509670f61 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -800,7 +800,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -813,6 +813,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5cbf96f24..99a8a24e3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -9119,7 +9119,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -9282,6 +9282,13 @@ "$ref": "#/definitions/v2/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e5a922ea7..b0399a223 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5404,7 +5404,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, @@ -5567,6 +5567,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json index 20ddd6e48..01fda3b07 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigDetectParams.json @@ -12,7 +12,7 @@ ] }, "includeHome": { - "description": "If true, include detection under the user's home (~/.claude, ~/.codex, etc.).", + "description": "If true, include detection under the user's home directory.", "type": "boolean" } }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json index b26e9d187..bc432b873 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ExternalAgentConfigImportParams.json @@ -184,6 +184,13 @@ "$ref": "#/definitions/ExternalAgentConfigMigrationItem" }, "type": "array" + }, + "source": { + "description": "Source product that produced the migration items. Missing means unspecified.", + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts index 163d96192..48b55e9b9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigDetectParams.ts @@ -4,7 +4,7 @@ export type ExternalAgentConfigDetectParams = { /** - * If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + * If true, include detection under the user's home directory. */ includeHome?: boolean, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts index 7bc5d9d91..be7f7ffe3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExternalAgentConfigImportParams.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ExternalAgentConfigMigrationItem } from "./ExternalAgentConfigMigrationItem"; -export type ExternalAgentConfigImportParams = { migrationItems: Array, }; +export type ExternalAgentConfigImportParams = { migrationItems: Array, +/** + * Source product that produced the migration items. Missing means unspecified. + */ +source?: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 4f299d022..b37f10dce 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -651,7 +651,7 @@ pub struct ExternalAgentConfigDetectResponse { #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct ExternalAgentConfigDetectParams { - /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + /// If true, include detection under the user's home directory. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub include_home: bool, /// Zero or more working directories to include for repo-scoped detection. @@ -664,6 +664,9 @@ pub struct ExternalAgentConfigDetectParams { #[ts(export_to = "v2/")] pub struct ExternalAgentConfigImportParams { pub migration_items: Vec, + /// Source product that produced the migration items. Missing means unspecified. + #[ts(optional = nullable)] + pub source: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 39bc52717..b973b8b4a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -376,6 +376,7 @@ fn external_agent_config_import_params_accept_legacy_plugin_details() { ..Default::default() }), }], + source: None, } ); } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index ab001cc68..d34231391 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -232,7 +232,7 @@ Example with notification opt-out: - `feedback/upload` — submit a feedback report (classification + optional reason/logs, conversation_id, and optional `extraLogFiles` attachments array); returns the tracking thread id. - `config/read` — fetch the effective config on disk after resolving config layering, including opaque `desktop` values stored in `config.toml`. - `externalAgentConfig/detect` — detect migratable external-agent artifacts with `includeHome` and optional `cwds`; each detected item includes `cwd` (`null` for home), and plugin/session migration items may additionally include structured `details` grouping plugin ids or session metadata. -- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. Returns an `importId` used to correlate the completion notification. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes with type-level `itemResults` containing each migrated type's success count, error count, successes, and raw errors (immediately after the response when everything completed synchronously, or after background imports finish). +- `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. Callers may pass `source` to identify the product that initiated the import; omitted or `null` means unspecified. The response acknowledges the synchronous import phase with an `importId`. Expected migration failures are reported as per-item failures rather than JSON-RPC errors, so the server still returns that `importId` and emits `externalAgentConfig/import/completed` with the same ID once all synchronous and background work finishes. The completion notification contains type-level `itemTypeResults` with successes and failures, including raw failure messages for the client to report separately. - `config/value/write` — write a single config key/value to the user's config.toml on disk; dotted paths such as `desktop.someKey` use the same generic write surface. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads, including multiple `desktop.*` edits. - `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), the layered permission-profile allow map (`allowedPermissionProfiles`), the managed permission-profile default (`defaultPermissions`), lifecycle hook lockdown (`allowManagedHooksOnly`), remote-control policy (`allowRemoteControl`; `false` force-disables remote control while `true` or `null` preserves existing behavior), computer use policy (`computerUse`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. diff --git a/codex-rs/app-server/src/config/external_agent_config.rs b/codex-rs/app-server/src/config/external_agent_config.rs index 79171e171..ce10d6e81 100644 --- a/codex-rs/app-server/src/config/external_agent_config.rs +++ b/codex-rs/app-server/src/config/external_agent_config.rs @@ -237,7 +237,7 @@ impl ExternalAgentConfigService { pub(crate) async fn import( &self, migration_items: Vec, - ) -> io::Result { + ) -> ExternalAgentConfigImportOutcome { let mut outcome = ExternalAgentConfigImportOutcome::default(); for migration_item in migration_items { let item_type = migration_item.item_type; @@ -413,17 +413,28 @@ impl ExternalAgentConfigService { })(), ExternalAgentConfigMigrationItemType::Sessions => Ok(()), }; - if let Err(err) = import_result { - if item_type == ExternalAgentConfigMigrationItemType::Plugins { - outcome.item_results.push(item_result); - continue; - } - return Err(err); + if let Err(err) = import_result + && item_type != ExternalAgentConfigMigrationItemType::Plugins + { + let message = err.to_string(); + let error_type = if message.contains("invalid existing config.toml") { + "invalid_existing_config" + } else { + "external_agent_config_import_error" + }; + item_result.record_error(ExternalAgentConfigImportRawError { + item_type, + error_type: Some(error_type.to_string()), + failure_stage: "import_request_failed".to_string(), + message, + cwd: item_result.cwd.clone(), + source: None, + }); } outcome.item_results.push(item_result); } - Ok(outcome) + outcome } async fn detect_migrations( diff --git a/codex-rs/app-server/src/config/external_agent_config_tests.rs b/codex-rs/app-server/src/config/external_agent_config_tests.rs index d6fa5a927..447741d14 100644 --- a/codex-rs/app-server/src/config/external_agent_config_tests.rs +++ b/codex-rs/app-server/src/config/external_agent_config_tests.rs @@ -561,8 +561,7 @@ async fn import_repo_migrates_mcp_hooks_commands_and_subagents() { details: None, }, ]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -724,8 +723,7 @@ url = "https://example.com/mixed-transport" cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -838,8 +836,7 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() { details: None, }, ]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(codex_home.join("AGENTS.md")).expect("read agents"), @@ -894,8 +891,7 @@ async fn import_home_config_uses_local_settings_over_project_settings() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str(&fs::read_to_string(codex_home.join("config.toml")).expect("read config")) @@ -939,8 +935,7 @@ async fn import_home_config_ignores_invalid_local_settings() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(codex_home.join("config.toml")).expect("read config"), @@ -965,8 +960,7 @@ async fn import_home_skips_empty_config_migration() { cwd: None, details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1043,8 +1037,7 @@ async fn import_local_plugins_returns_completed_status() { ..Default::default() }), }]) - .await - .expect("import"); + .await; assert_eq!( outcome.pending_plugin_imports, @@ -1104,8 +1097,7 @@ async fn import_git_plugins_returns_pending_async_status() { ..Default::default() }), }]) - .await - .expect("import"); + .await; assert_eq!( outcome.pending_plugin_imports, @@ -1235,8 +1227,7 @@ async fn import_repo_agents_md_rewrites_terms_and_skips_non_empty_targets() { details: None, }, ]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1302,8 +1293,7 @@ async fn import_repo_agents_md_overwrites_empty_targets() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1403,8 +1393,7 @@ async fn import_repo_hooks_preserves_disabled_codex_hooks_feature() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1481,8 +1470,7 @@ async fn import_repo_mcp_uses_home_settings_toggles_when_repo_settings_missing() cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( outcome.item_results, @@ -1559,8 +1547,7 @@ async fn import_repo_mcp_uses_local_settings_toggles_over_project_settings() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -1607,8 +1594,7 @@ async fn import_repo_mcp_ignores_invalid_home_settings_when_repo_settings_missin cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; let config: TomlValue = toml::from_str( &fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"), @@ -1649,8 +1635,7 @@ async fn import_repo_uses_non_empty_external_agent_agents_source() { cwd: Some(repo_root.clone()), details: None, }]) - .await - .expect("import"); + .await; assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), @@ -1683,8 +1668,7 @@ async fn import_continues_after_failed_migration_item() { details: None, }, ]) - .await - .expect("import continues"); + .await; assert_eq!( fs::read_to_string(repo_root.join("AGENTS.md")).expect("read target"), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 985160985..3544592b4 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -515,7 +515,7 @@ impl MessageProcessor { outgoing.clone(), config_manager.clone(), thread_manager.clone(), - analytics_events_client, + analytics_events_client.clone(), ); let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new(ExternalAgentConfigRequestProcessorArgs { @@ -525,6 +525,7 @@ impl MessageProcessor { config_manager: config_manager.clone(), config_processor: config_processor.clone(), state_db, + analytics_events_client, arg0_paths, codex_home: config.codex_home.to_path_buf(), }); diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index b3cdb0e21..69bb4218c 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -15,6 +15,9 @@ use crate::config_manager::ConfigManager; use crate::error_code::internal_error; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::ExternalAgentConfigImportCompletedInput; +use codex_analytics::ExternalAgentConfigImportFailureInput; use codex_app_server_protocol::CommandMigration; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; @@ -57,6 +60,7 @@ pub(crate) struct ExternalAgentConfigRequestProcessor { thread_manager: Arc, config_processor: ConfigRequestProcessor, state_db: Option, + analytics_events_client: AnalyticsEventsClient, } pub(crate) struct ExternalAgentConfigRequestProcessorArgs { @@ -66,6 +70,7 @@ pub(crate) struct ExternalAgentConfigRequestProcessorArgs { pub(crate) config_manager: ConfigManager, pub(crate) config_processor: ConfigRequestProcessor, pub(crate) state_db: Option, + pub(crate) analytics_events_client: AnalyticsEventsClient, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) codex_home: PathBuf, } @@ -79,6 +84,7 @@ impl ExternalAgentConfigRequestProcessor { config_manager, config_processor, state_db, + analytics_events_client, arg0_paths, codex_home, } = args; @@ -96,6 +102,7 @@ impl ExternalAgentConfigRequestProcessor { thread_manager, config_processor, state_db, + analytics_events_client, } } @@ -199,6 +206,7 @@ impl ExternalAgentConfigRequestProcessor { params: ExternalAgentConfigImportParams, ) -> Result<(), JSONRPCErrorError> { let import_id = Uuid::new_v4().to_string(); + let analytics_source = params.source.clone().unwrap_or_default(); let needs_runtime_refresh = migration_items_need_runtime_refresh(¶ms.migration_items); let has_migration_items = !params.migration_items.is_empty(); let has_plugin_imports = params.migration_items.iter().any(|item| { @@ -209,7 +217,7 @@ impl ExternalAgentConfigRequestProcessor { }); let (pending_session_imports, session_validation_result) = self.validate_pending_session_imports(¶ms); - let import_outcome = self.import_external_agent_config(params).await?; + let import_outcome = self.import_external_agent_config(params).await; if needs_runtime_refresh { self.config_processor.handle_config_mutation().await; } @@ -242,7 +250,9 @@ impl ExternalAgentConfigRequestProcessor { send_completed_import_notification( &self.outgoing, self.state_db.as_ref(), + &self.analytics_events_client, import_id, + analytics_source, &completed_item_results, ) .await; @@ -253,6 +263,7 @@ impl ExternalAgentConfigRequestProcessor { let plugin_processor = self.clone(); let outgoing = Arc::clone(&self.outgoing); let state_db = self.state_db.clone(); + let analytics_events_client = self.analytics_events_client.clone(); let thread_manager = Arc::clone(&self.thread_manager); let session_import_result = (!pending_session_imports.is_empty()).then(|| { CoreImportItemResult::new( @@ -324,7 +335,9 @@ impl ExternalAgentConfigRequestProcessor { send_completed_import_notification( &outgoing, state_db.as_ref(), + &analytics_events_client, import_id, + analytics_source, &completed_item_results, ) .await; @@ -421,7 +434,7 @@ impl ExternalAgentConfigRequestProcessor { async fn import_external_agent_config( &self, params: ExternalAgentConfigImportParams, - ) -> Result { + ) -> CoreImportOutcome { self.migration_service .import( params @@ -516,7 +529,6 @@ impl ExternalAgentConfigRequestProcessor { .collect(), ) .await - .map_err(|err| internal_error(err.to_string())) } async fn complete_pending_plugin_import( @@ -551,10 +563,14 @@ async fn send_import_progress( async fn send_completed_import_notification( outgoing: &OutgoingMessageSender, state_db: Option<&StateDbHandle>, + analytics_events_client: &AnalyticsEventsClient, import_id: String, + analytics_source: String, item_results: &[CoreImportItemResult], ) { let notification = completed_notification(import_id, item_results); + log_completed_import_failures(¬ification); + track_completed_import_notification(analytics_events_client, &analytics_source, ¬ification); if let Some(state_db) = state_db && let Err(err) = record_completed_import_notification(state_db, ¬ification).await { @@ -571,6 +587,75 @@ async fn send_completed_import_notification( .await; } +fn log_completed_import_failures(notification: &ExternalAgentConfigImportCompletedNotification) { + for type_result in ¬ification.item_type_results { + for failure in &type_result.failures { + let error_type = import_failure_error_type(failure); + tracing::warn!( + import_id = %notification.import_id, + item_type = ?failure.item_type, + error_type = %error_type, + failure_stage = %failure.failure_stage, + cwd = ?failure.cwd, + source = ?failure.source, + error = %failure.message, + "external agent config migration item failed" + ); + } + } +} + +fn track_completed_import_notification( + analytics_events_client: &AnalyticsEventsClient, + analytics_source: &str, + notification: &ExternalAgentConfigImportCompletedNotification, +) { + for type_result in ¬ification.item_type_results { + let item_type = analytics_migration_item_type(type_result.item_type).to_string(); + analytics_events_client.track_external_agent_config_import_completed( + ExternalAgentConfigImportCompletedInput { + import_id: notification.import_id.clone(), + source: analytics_source.to_string(), + item_type: item_type.clone(), + success_count: type_result.successes.len(), + failed_count: type_result.failures.len(), + }, + ); + for failure in &type_result.failures { + analytics_events_client.track_external_agent_config_import_failure( + ExternalAgentConfigImportFailureInput { + import_id: notification.import_id.clone(), + source: analytics_source.to_string(), + item_type: item_type.clone(), + failure_stage: failure.failure_stage.clone(), + error_type: import_failure_error_type(failure), + }, + ); + } + } +} + +fn import_failure_error_type(failure: &ProtocolImportFailure) -> String { + failure + .error_type + .clone() + .unwrap_or_else(|| failure.failure_stage.clone()) +} + +fn analytics_migration_item_type(item_type: ExternalAgentConfigMigrationItemType) -> &'static str { + match item_type { + ExternalAgentConfigMigrationItemType::AgentsMd => "AGENTS_MD", + ExternalAgentConfigMigrationItemType::Config => "CONFIG", + ExternalAgentConfigMigrationItemType::Skills => "SKILLS", + ExternalAgentConfigMigrationItemType::Plugins => "PLUGINS", + ExternalAgentConfigMigrationItemType::McpServerConfig => "MCP_SERVER_CONFIG", + ExternalAgentConfigMigrationItemType::Subagents => "SUBAGENTS", + ExternalAgentConfigMigrationItemType::Hooks => "HOOKS", + ExternalAgentConfigMigrationItemType::Commands => "COMMANDS", + ExternalAgentConfigMigrationItemType::Sessions => "SESSIONS", + } +} + async fn record_completed_import_notification( state_db: &StateDbHandle, notification: &ExternalAgentConfigImportCompletedNotification, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index d2cd08b63..e9bf04476 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -18,9 +18,12 @@ use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETP use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason; use codex_core_plugins::remote::is_valid_remote_plugin_id; use codex_core_plugins::remote::validate_remote_plugin_id; +use codex_core_plugins::remote_bundle::RemotePluginBundleInstallError; use codex_mcp::McpOAuthLoginSupport; use codex_mcp::oauth_login_support; use codex_mcp::should_retry_without_scopes; +use codex_plugin::PluginId; +use codex_plugin::PluginTelemetryMetadata; use codex_rmcp_client::perform_oauth_login_silent; #[derive(Clone)] @@ -1437,15 +1440,24 @@ impl PluginRequestProcessor { } let plugins_manager = self.thread_manager.plugins_manager(); + let marketplace_display = marketplace_path.display().to_string(); + let plugin_name_for_log = plugin_name.clone(); let request = PluginInstallRequest { plugin_name, marketplace_path, }; - let result = plugins_manager - .install_plugin(request) - .await - .map_err(Self::plugin_install_error)?; + let result = match plugins_manager.install_plugin(request).await { + Ok(result) => result, + Err(err) => { + warn!( + marketplace = %marketplace_display, + plugin_name = %plugin_name_for_log, + "failed to install plugin: {err}" + ); + return Err(Self::plugin_install_error(err)); + } + }; let config = match self.load_latest_config(config_cwd).await { Ok(config) => config, Err(err) => { @@ -1512,11 +1524,20 @@ impl PluginRequestProcessor { ) .await .map_err(|err| { + let error_type = remote_plugin_catalog_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &remote_marketplace_name, + error_type, + err.to_string(), + ); remote_plugin_catalog_error_to_jsonrpc( err, "read remote plugin details before install", ) })?; + let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); + let remote_plugin_name = remote_detail.summary.name.clone(); if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin { return Err(invalid_request(format!( "remote plugin {remote_plugin_id} is disabled by admin" @@ -1527,31 +1548,48 @@ impl PluginRequestProcessor { "remote plugin {remote_plugin_id} is not available for install" ))); } - let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); // Direct install writes the same cache tree that installed-plugin sync // prunes before the backend installed snapshot can include this plugin. let _remote_plugin_cache_mutation = codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight( config.codex_home.as_path(), &actual_remote_marketplace_name, - &remote_detail.summary.name, + &remote_plugin_name, ); let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle( &remote_plugin_id, &actual_remote_marketplace_name, - &remote_detail.summary.name, + &remote_plugin_name, remote_detail.release_version.as_deref(), remote_detail.bundle_download_url.as_deref(), remote_detail.app_manifest.clone(), ) - .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + .map_err(|err| { + let error_type = remote_plugin_bundle_install_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_bundle_install_error_to_jsonrpc(err) + })?; let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle( config.codex_home.to_path_buf(), validated_bundle, ) .await - .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + .map_err(|err| { + let error_type = remote_plugin_bundle_install_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_bundle_install_error_to_jsonrpc(err) + })?; // Cache first so a backend install cannot succeed when local materialization fails. // If this backend call fails, the cache entry is harmless because remote installed state @@ -1563,7 +1601,16 @@ impl PluginRequestProcessor { &remote_plugin_id, ) .await - .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?; + .map_err(|err| { + let error_type = remote_plugin_catalog_error_type(&err); + self.track_plugin_install_failed_for_remote_plugin( + &remote_plugin_id, + &actual_remote_marketplace_name, + error_type, + err.to_string(), + ); + remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin") + })?; self.thread_manager .plugins_manager() @@ -1646,6 +1693,32 @@ impl PluginRequestProcessor { }) } + fn track_plugin_install_failed_for_remote_plugin( + &self, + remote_plugin_id: &str, + marketplace_name: &str, + error_type: &'static str, + error_message: String, + ) { + tracing::warn!( + remote_plugin_id = %remote_plugin_id, + marketplace_name = %marketplace_name, + error_type = %error_type, + error = %error_message, + "remote plugin install failed" + ); + // The remote id is reported separately; this local name only satisfies + // PluginId validation before remote details are available. + let Ok(plugin_id) = PluginId::new("unknown".to_string(), marketplace_name.to_string()) + else { + return; + }; + let mut plugin = PluginTelemetryMetadata::from_plugin_id(&plugin_id); + plugin.remote_plugin_id = Some(remote_plugin_id.to_string()); + self.analytics_events_client + .track_plugin_install_failed(plugin, error_type.to_string()); + } + async fn plugin_apps_needing_auth_for_install( &self, config: &Config, @@ -2145,6 +2218,75 @@ fn remote_plugin_detail_to_info( } } +fn remote_plugin_catalog_error_type(err: &RemotePluginCatalogError) -> &'static str { + match err { + RemotePluginCatalogError::AuthRequired => "remote_catalog_auth_required", + RemotePluginCatalogError::UnsupportedAuthMode => "remote_catalog_unsupported_auth_mode", + RemotePluginCatalogError::AuthToken(_) => "remote_catalog_auth_token", + RemotePluginCatalogError::Request { .. } => "remote_catalog_request", + RemotePluginCatalogError::UnexpectedStatus { .. } => "remote_catalog_unexpected_status", + RemotePluginCatalogError::Decode { .. } => "remote_catalog_decode", + RemotePluginCatalogError::InvalidBaseUrl(_) => "remote_catalog_invalid_base_url", + RemotePluginCatalogError::InvalidBaseUrlPath => "remote_catalog_invalid_base_url_path", + RemotePluginCatalogError::UnknownMarketplace { .. } => "remote_catalog_unknown_marketplace", + RemotePluginCatalogError::UnexpectedPluginId { .. } => { + "remote_catalog_unexpected_plugin_id" + } + RemotePluginCatalogError::UnexpectedSkillName { .. } => { + "remote_catalog_unexpected_skill_name" + } + RemotePluginCatalogError::UnexpectedEnabledState { .. } => { + "remote_catalog_unexpected_enabled_state" + } + RemotePluginCatalogError::InvalidPluginPath { .. } => "remote_catalog_invalid_plugin_path", + RemotePluginCatalogError::PluginShareCheckoutNotAvailable { .. } => { + "remote_catalog_plugin_share_checkout_not_available" + } + RemotePluginCatalogError::Archive { .. } => "remote_catalog_archive", + RemotePluginCatalogError::ArchiveJoin(_) => "remote_catalog_archive_join", + RemotePluginCatalogError::ArchiveTooLarge { .. } => "remote_catalog_archive_too_large", + RemotePluginCatalogError::MissingUploadEtag => "remote_catalog_missing_upload_etag", + RemotePluginCatalogError::UnexpectedResponse(_) => "remote_catalog_unexpected_response", + RemotePluginCatalogError::CacheRemove(_) => "remote_catalog_cache_remove", + } +} + +fn remote_plugin_bundle_install_error_type(err: &RemotePluginBundleInstallError) -> &'static str { + match err { + RemotePluginBundleInstallError::MissingReleaseVersion { .. } => { + "remote_bundle_missing_release_version" + } + RemotePluginBundleInstallError::InvalidReleaseVersion { .. } => { + "remote_bundle_invalid_release_version" + } + RemotePluginBundleInstallError::MissingBundleDownloadUrl { .. } => { + "remote_bundle_missing_download_url" + } + RemotePluginBundleInstallError::InvalidBundleDownloadUrl { .. } => { + "remote_bundle_invalid_download_url" + } + RemotePluginBundleInstallError::UnsupportedBundleDownloadUrlScheme { .. } => { + "remote_bundle_unsupported_download_url_scheme" + } + RemotePluginBundleInstallError::InvalidPluginId { .. } => "remote_bundle_invalid_plugin_id", + RemotePluginBundleInstallError::DownloadRequest { .. } => "remote_bundle_download_request", + RemotePluginBundleInstallError::DownloadStatus { .. } => "remote_bundle_download_status", + RemotePluginBundleInstallError::DownloadBody { .. } => "remote_bundle_download_body", + RemotePluginBundleInstallError::DownloadTooLarge { .. } => { + "remote_bundle_download_too_large" + } + RemotePluginBundleInstallError::UnsupportedBundleDownloadFinalUrl { .. } => { + "remote_bundle_unsupported_download_final_url" + } + RemotePluginBundleInstallError::ExtractedBundleTooLarge { .. } => { + "remote_bundle_extracted_too_large" + } + RemotePluginBundleInstallError::Io { .. } => "remote_bundle_io", + RemotePluginBundleInstallError::InvalidBundle(_) => "remote_bundle_invalid_bundle", + RemotePluginBundleInstallError::Store(_) => "remote_bundle_store", + } +} + fn remote_plugin_catalog_error_to_jsonrpc( err: RemotePluginCatalogError, context: &str, diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index b48aeca2b..a5d918cba 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -1,9 +1,12 @@ use std::time::Duration; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::TestAppServer; use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::start_analytics_events_server; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; use app_test_support::write_mock_responses_config_toml; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; @@ -11,7 +14,6 @@ use codex_app_server_protocol::ExternalAgentConfigImportHistoriesReadResponse; use codex_app_server_protocol::ExternalAgentConfigImportProgressNotification; use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; -use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; @@ -25,16 +27,25 @@ use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::UserInput; +use codex_config::types::AuthCredentialsStoreMode; use core_test_support::responses; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +use std::path::Path; +use std::path::PathBuf; use tempfile::TempDir; #[cfg(unix)] use tokio::io::AsyncWriteExt; use tokio::time::timeout; +use super::analytics::wait_for_analytics_event; + const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +fn external_agent_home(codex_home: &Path) -> PathBuf { + codex_home.join(concat!(".", "cl", "aude")) +} + fn assert_import_response(response: ExternalAgentConfigImportResponse) -> String { assert!(!response.import_id.is_empty()); response.import_id @@ -158,15 +169,172 @@ async fn external_agent_config_import_sends_completion_notification_for_sync_onl } #[tokio::test] -async fn external_agent_config_import_returns_error_for_failed_sync_import() -> Result<()> { +async fn external_agent_config_import_reports_failed_sync_import_in_completion() -> Result<()> { let codex_home = TempDir::new()?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + 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 source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), r#"{"env":{"FOO":"bar"}}"#, )?; std::fs::write(codex_home.path().join("config.toml"), "invalid = [")?; let home_dir = codex_home.path().display().to_string(); + let analytics_capture_file = codex_home.path().join("analytics-events.jsonl"); + let analytics_capture_file = analytics_capture_file.display().to_string(); + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home_dir.as_str())), + ( + "CODEX_ANALYTICS_EVENTS_CAPTURE_FILE", + Some(analytics_capture_file.as_str()), + ), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_raw_request( + "externalAgentConfig/import", + Some(serde_json::json!({ + "source": "test_import", + "migrationItems": [ + { + "itemType": "CONFIG", + "description": "Import config", + "cwd": null + }, + { + "itemType": "COMMANDS", + "description": "Import commands", + "cwd": null + } + ] + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ExternalAgentConfigImportResponse = to_response(response)?; + let import_id = assert_import_response(response); + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + let completed: ExternalAgentConfigImportCompletedNotification = + serde_json::from_value(notification.params.expect("completed params"))?; + assert_eq!(completed.import_id, import_id); + let config_result = completed + .item_type_results + .iter() + .find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Config) + .expect("config result"); + assert!(config_result.successes.is_empty()); + assert_eq!(config_result.failures.len(), 1); + let config_failure = &config_result.failures[0]; + assert_eq!( + config_failure.error_type.as_deref(), + Some("invalid_existing_config") + ); + assert_eq!(config_failure.failure_stage, "import_request_failed"); + assert!( + config_failure + .message + .contains("invalid existing config.toml"), + "unexpected failure: {config_failure:?}" + ); + let commands_result = completed + .item_type_results + .iter() + .find(|result| result.item_type == ExternalAgentConfigMigrationItemType::Commands) + .expect("commands result"); + assert!(commands_result.successes.is_empty()); + assert!(commands_result.failures.is_empty()); + + let events = timeout(DEFAULT_TIMEOUT, async { + loop { + let contents = match std::fs::read_to_string(&analytics_capture_file) { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + tokio::time::sleep(Duration::from_millis(25)).await; + continue; + } + Err(err) => return Err(err.into()), + }; + let mut captured_events = Vec::new(); + for line in contents.lines() { + let payload: serde_json::Value = serde_json::from_str(line)?; + let Some(events) = payload["events"].as_array() else { + continue; + }; + captured_events.extend(events.iter().cloned()); + } + if captured_events.iter().any(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_complete" + && event["event_params"]["type"] == "COMMANDS" + }) { + return Ok::, anyhow::Error>(captured_events); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + }) + .await??; + let event = events + .iter() + .find(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_failure" + && event["event_params"]["type"] == "CONFIG" + }) + .expect("config failure analytics event"); + let event_params = &event["event_params"]; + assert_eq!(event_params["import_id"], import_id); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "CONFIG"); + assert_eq!(event_params["failure_stage"], "import_request_failed"); + assert_eq!(event_params["error_type"], "invalid_existing_config"); + assert!(event_params.get("raw_errors").is_none()); + assert!(event_params.get("message").is_none()); + assert!(!events.iter().any(|event| { + event["event_type"] == "codex_onboarding_external_agent_import_failure" + && event["event_params"]["type"] == "COMMANDS" + })); + + Ok(()) +} + +#[tokio::test] +async fn external_agent_config_import_completed_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + 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 missing_session_path = + external_agent_home(codex_home.path()).join("projects/repo/missing.jsonl"); + let project_root = codex_home.path().join("repo"); + let home_dir = codex_home.path().display().to_string(); let mut mcp = TestAppServer::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]) .await?; @@ -176,25 +344,70 @@ async fn external_agent_config_import_returns_error_for_failed_sync_import() -> .send_raw_request( "externalAgentConfig/import", Some(serde_json::json!({ + "source": "test_import", "migrationItems": [{ - "itemType": "CONFIG", - "description": "Import config", - "cwd": null + "itemType": "SESSIONS", + "description": "Migrate recent sessions", + "cwd": null, + "details": { + "sessions": [{ + "path": missing_session_path, + "cwd": project_root, + "title": "missing session" + }] + } }] })), ) .await?; - - let error: JSONRPCError = timeout( + let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; - assert_eq!(error.error.code, -32603); - assert!( - error.error.message.contains("invalid existing config.toml"), - "unexpected error: {error:?}" - ); + let response: ExternalAgentConfigImportResponse = to_response(response)?; + let import_id = assert_import_response(response); + + let notification = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"), + ) + .await??; + let completed: ExternalAgentConfigImportCompletedNotification = + serde_json::from_value(notification.params.expect("completed params"))?; + assert_eq!(completed.import_id, import_id); + assert_eq!(completed.item_type_results.len(), 1); + assert_eq!(completed.item_type_results[0].successes.len(), 0); + assert_eq!(completed.item_type_results[0].failures.len(), 1); + + let event = wait_for_analytics_event( + &analytics_server, + DEFAULT_TIMEOUT, + "codex_onboarding_external_agent_import_complete", + ) + .await?; + let event_params = &event["event_params"]; + assert_eq!(event_params["import_id"], serde_json::json!(import_id)); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "SESSIONS"); + assert_eq!(event_params["success_count"], 0); + assert_eq!(event_params["failed_count"], 1); + assert!(event_params.get("raw_errors").is_none()); + + 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["import_id"], serde_json::json!(import_id)); + assert_eq!(event_params["source"], "test_import"); + assert_eq!(event_params["type"], "SESSIONS"); + assert_eq!(event_params["failure_stage"], "session_missing"); + assert_eq!(event_params["error_type"], "session_missing"); + assert!(event_params.get("raw_errors").is_none()); + assert!(event_params.get("message").is_none()); Ok(()) } @@ -226,7 +439,8 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl plugin_root.join(".codex-plugin/plugin.json"), r#"{"name":"sample","version":"0.1.0"}"#, )?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + let source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; let settings = serde_json::json!({ "enabledPlugins": { "sample@debug": true @@ -239,7 +453,7 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl } }); std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), serde_json::to_string_pretty(&settings)?, )?; @@ -318,11 +532,12 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl async fn external_agent_config_import_sends_completion_notification_after_pending_plugins_finish() -> Result<()> { let codex_home = TempDir::new()?; - std::fs::create_dir_all(codex_home.path().join(".claude"))?; + let source_home = external_agent_home(codex_home.path()); + std::fs::create_dir_all(&source_home)?; // This test only needs a pending non-local plugin import. Use an invalid // source so the background completion path cannot make a real network clone. std::fs::write( - codex_home.path().join(".claude").join("settings.json"), + source_home.join("settings.json"), r#"{ "enabledPlugins": { "formatter@acme-tools": true @@ -387,7 +602,7 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -613,7 +828,7 @@ required = true std::fs::write(codex_home.path().join("config.toml"), config)?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -698,7 +913,7 @@ async fn external_agent_config_import_accepts_detected_session_payload_after_res create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -788,7 +1003,7 @@ async fn external_agent_config_import_skips_already_imported_session_versions() create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -882,7 +1097,7 @@ async fn external_agent_config_import_returns_before_background_session_import_f create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -1043,7 +1258,7 @@ async fn external_agent_config_import_compacts_huge_session_before_first_follow_ let project_root = codex_home.path().join("repo"); let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); - let session_dir = codex_home.path().join(".claude/projects/repo"); + let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; std::fs::create_dir_all(&session_dir)?; @@ -1209,3 +1424,10 @@ stream_max_retries = 0 ), ) } + +fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!("chatgpt_base_url = \"{base_url}\"\n"), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 6965e759e..cc008e73a 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -511,6 +511,46 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_tracks_analytics_when_remote_detail_fetch_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_empty_remote_installed_plugins(&server).await; + mount_backend_analytics_events(&server).await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("failed with status 404")); + + let payload = wait_for_plugin_analytics_payload(&server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(event_params["plugin_name"], "unknown"); + assert_eq!( + event_params["marketplace_name"], + "caller-marketplace-is-ignored" + ); + assert_eq!( + event_params["error_type"], + "remote_catalog_unexpected_status" + ); + Ok(()) +} + #[tokio::test] async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download() -> Result<()> { let codex_home = TempDir::new()?; @@ -571,6 +611,42 @@ async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download( Ok(()) } +#[tokio::test] +async fn plugin_install_rejects_remote_plugin_not_available() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail_with_install_policy( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + /*install_policy*/ "NOT_AVAILABLE", + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("not available for install")); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + #[tokio::test] async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -820,6 +896,66 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_failure_tracks_analytics_event() -> Result<()> { + let analytics_server = start_analytics_events_server().await?; + let codex_home = TempDir::new()?; + 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 repo_root = TempDir::new()?; + write_plugin_marketplace( + repo_root.path(), + "debug", + "sample-plugin", + "./missing-plugin", + /*install_policy*/ None, + /*auth_policy*/ None, + )?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_install_request(PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "sample-plugin".to_string(), + }) + .await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!(err.error.code, -32600); + + let payload = wait_for_plugin_analytics_payload(&analytics_server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], "sample-plugin@debug"); + assert_eq!(event_params["plugin_name"], "sample-plugin"); + assert_eq!(event_params["marketplace_name"], "debug"); + assert_eq!(event_params["has_skills"], json!(null)); + assert_eq!(event_params["mcp_server_count"], json!(null)); + assert_eq!(event_params["connector_ids"], json!(null)); + assert_eq!(event_params["product_client_id"], DEFAULT_CLIENT_NAME); + assert_eq!(event_params["error_type"], "store_invalid"); + Ok(()) +} + #[tokio::test] async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { let codex_home = TempDir::new()?; @@ -887,6 +1023,7 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; mount_empty_remote_installed_plugins(&server).await; mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; + mount_backend_analytics_events(&server).await; let mut mcp = TestAppServer::new_with_env( codex_home.path(), @@ -918,6 +1055,15 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> /*expected_count*/ 0, ) .await?; + let payload = wait_for_plugin_analytics_payload(&server).await?; + let event_params = &payload["events"][0]["event_params"]; + assert_eq!( + payload["events"][0]["event_type"], + "codex_plugin_install_failed" + ); + assert_eq!(event_params["plugin_id"], REMOTE_PLUGIN_ID); + assert_eq!(event_params["marketplace_name"], "openai-curated-remote"); + assert_eq!(event_params["error_type"], "remote_bundle_download_status"); assert!( !codex_home .path() @@ -1867,6 +2013,45 @@ async fn mount_remote_plugin_detail_with_status_and_app_manifest( bundle_download_url: Option<&str>, status: PluginAvailability, app_manifest: Option, +) { + mount_remote_plugin_detail_with_options( + server, + remote_plugin_id, + release_version, + bundle_download_url, + status, + "AVAILABLE", + app_manifest, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_install_policy( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + install_policy: &str, +) { + mount_remote_plugin_detail_with_options( + server, + remote_plugin_id, + release_version, + /*bundle_download_url*/ None, + PluginAvailability::Available, + install_policy, + /*app_manifest*/ None, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_options( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + status: PluginAvailability, + install_policy: &str, + app_manifest: Option, ) { let status = match status { PluginAvailability::Available => "ENABLED", @@ -1883,7 +2068,7 @@ async fn mount_remote_plugin_detail_with_status_and_app_manifest( "id": "{remote_plugin_id}", "name": "linear", "scope": "GLOBAL", - "installation_policy": "AVAILABLE", + "installation_policy": "{install_policy}", "authentication_policy": "ON_USE", "status": "{status}", "release": {{ diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 7fbbcc002..88b6cd3bb 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -69,6 +69,7 @@ use codex_plugin::AppConnectorId; use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginIdError; +use codex_plugin::PluginTelemetryMetadata; use codex_plugin::app_connector_ids_from_declarations; use codex_plugin::prompt_safe_plugin_description; use codex_protocol::protocol::HookEventName; @@ -1079,12 +1080,29 @@ impl PluginsManager { &self, request: PluginInstallRequest, ) -> Result { - let resolved = find_installable_marketplace_plugin( + let resolved = match find_installable_marketplace_plugin( &request.marketplace_path, &request.plugin_name, self.restriction_product, - )?; - self.install_resolved_plugin(resolved).await + ) { + Ok(resolved) => resolved, + Err(err) => { + self.track_plugin_install_resolution_failed(&err); + return Err(err.into()); + } + }; + let plugin_id = resolved.plugin_id.clone(); + match self.install_resolved_plugin(resolved).await { + Ok(outcome) => Ok(outcome), + Err(err) => { + self.track_plugin_install_failed( + &plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + Err(err) + } + } } pub async fn install_plugin_with_remote_sync( @@ -1093,21 +1111,101 @@ impl PluginsManager { auth: Option<&CodexAuth>, request: PluginInstallRequest, ) -> Result { - let resolved = find_installable_marketplace_plugin( + let resolved = match find_installable_marketplace_plugin( &request.marketplace_path, &request.plugin_name, self.restriction_product, - )?; + ) { + Ok(resolved) => resolved, + Err(err) => { + self.track_plugin_install_resolution_failed(&err); + return Err(err.into()); + } + }; let plugin_id = resolved.plugin_id.as_key(); // This only forwards the backend mutation before the local install flow. - crate::remote_legacy::enable_remote_plugin( + if let Err(err) = crate::remote_legacy::enable_remote_plugin( &remote_plugin_service_config(config), auth, &plugin_id, ) .await - .map_err(PluginInstallError::from)?; - self.install_resolved_plugin(resolved).await + { + let err = PluginInstallError::from(err); + self.track_plugin_install_failed( + &resolved.plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + return Err(err); + } + let plugin_id = resolved.plugin_id.clone(); + match self.install_resolved_plugin(resolved).await { + Ok(outcome) => Ok(outcome), + Err(err) => { + self.track_plugin_install_failed( + &plugin_id, + plugin_install_error_type(&err), + err.to_string(), + ); + Err(err) + } + } + } + + fn track_plugin_install_resolution_failed(&self, err: &MarketplaceError) { + let plugin_id = match err { + MarketplaceError::PluginNotFound { + plugin_name, + marketplace_name, + } + | MarketplaceError::PluginNotAvailable { + plugin_name, + marketplace_name, + } => PluginId::new(plugin_name.clone(), marketplace_name.clone()).ok(), + MarketplaceError::Io { .. } + | MarketplaceError::MarketplaceNotFound { .. } + | MarketplaceError::InvalidMarketplaceFile { .. } + | MarketplaceError::PluginsDisabled + | MarketplaceError::InvalidPlugin(_) => None, + }; + if let Some(plugin_id) = plugin_id { + self.track_plugin_install_failed( + &plugin_id, + marketplace_error_type(err), + err.to_string(), + ); + } else { + tracing::warn!( + error_type = %marketplace_error_type(err), + error = %err, + "plugin install failed while resolving marketplace plugin" + ); + } + } + + fn track_plugin_install_failed( + &self, + plugin_id: &PluginId, + error_type: &'static str, + error_message: String, + ) { + tracing::warn!( + plugin_id = %plugin_id.as_key(), + error_type = %error_type, + error = %error_message, + "plugin install failed" + ); + let analytics_events_client = match self.analytics_events_client.read() { + Ok(client) => client.clone(), + Err(err) => err.into_inner().clone(), + }; + if let Some(analytics_events_client) = analytics_events_client { + analytics_events_client.track_plugin_install_failed( + PluginTelemetryMetadata::from_plugin_id(plugin_id), + error_type.to_string(), + ); + } } async fn install_resolved_plugin( @@ -2173,6 +2271,54 @@ impl PluginInstallError { } } +fn plugin_install_error_type(err: &PluginInstallError) -> &'static str { + match err { + PluginInstallError::Marketplace(err) => marketplace_error_type(err), + PluginInstallError::Remote(err) => remote_plugin_mutation_error_type(err), + PluginInstallError::Store(err) => plugin_store_error_type(err), + PluginInstallError::Config(_) => "config", + PluginInstallError::Join(_) => "join", + } +} + +fn marketplace_error_type(err: &MarketplaceError) -> &'static str { + match err { + MarketplaceError::Io { .. } => "marketplace_io", + MarketplaceError::MarketplaceNotFound { .. } => "marketplace_not_found", + MarketplaceError::InvalidMarketplaceFile { .. } => "invalid_marketplace_file", + MarketplaceError::PluginNotFound { .. } => "plugin_not_found", + MarketplaceError::PluginNotAvailable { .. } => "plugin_not_available", + MarketplaceError::PluginsDisabled => "plugins_disabled", + MarketplaceError::InvalidPlugin(_) => "invalid_plugin", + } +} + +fn remote_plugin_mutation_error_type(err: &RemotePluginMutationError) -> &'static str { + match err { + RemotePluginMutationError::AuthRequired => "remote_mutation_auth_required", + RemotePluginMutationError::UnsupportedAuthMode => "remote_mutation_unsupported_auth_mode", + RemotePluginMutationError::AuthToken(_) => "remote_mutation_auth_token", + RemotePluginMutationError::InvalidBaseUrl(_) => "remote_mutation_invalid_base_url", + RemotePluginMutationError::InvalidBaseUrlPath => "remote_mutation_invalid_base_url_path", + RemotePluginMutationError::Request { .. } => "remote_mutation_request", + RemotePluginMutationError::UnexpectedStatus { .. } => "remote_mutation_unexpected_status", + RemotePluginMutationError::Decode { .. } => "remote_mutation_decode", + RemotePluginMutationError::UnexpectedPluginId { .. } => { + "remote_mutation_unexpected_plugin_id" + } + RemotePluginMutationError::UnexpectedEnabledState { .. } => { + "remote_mutation_unexpected_enabled_state" + } + } +} + +fn plugin_store_error_type(err: &PluginStoreError) -> &'static str { + match err { + PluginStoreError::Io { .. } => "store_io", + PluginStoreError::Invalid(_) => "store_invalid", + } +} + #[derive(Debug, thiserror::Error)] pub enum PluginUninstallError { #[error("{0}")] diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 4049ca14a..e57b26840 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -391,7 +391,10 @@ impl AppServerSession { .client .request_typed(ClientRequest::ExternalAgentConfigImport { request_id, - params: ExternalAgentConfigImportParams { migration_items }, + params: ExternalAgentConfigImportParams { + migration_items, + source: None, + }, }) .await .wrap_err("externalAgentConfig/import failed during Claude Code import");