[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
This commit is contained in:
charlesgong-openai
2026-06-17 13:16:34 -07:00
committed by GitHub
Unverified
parent 5867b529ae
commit 3959ab0ffc
25 changed files with 1314 additions and 97 deletions
@@ -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 {
+30
View File
@@ -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 {
+50
View File
@@ -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<String>,
}
#[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<String>,
}
#[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,
+24
View File
@@ -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,
+2
View File
@@ -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;
+75
View File
@@ -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<TrackEventRequest>,
) {
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<TrackEventRequest>,
) {
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<TrackEventRequest>,
) {
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,
+8 -1
View File
@@ -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": [
@@ -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": [
@@ -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": [
@@ -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"
}
},
@@ -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": [
@@ -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,
/**
@@ -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<ExternalAgentConfigMigrationItem>, };
export type ExternalAgentConfigImportParams = { migrationItems: Array<ExternalAgentConfigMigrationItem>,
/**
* Source product that produced the migration items. Missing means unspecified.
*/
source?: string | null, };
@@ -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<ExternalAgentConfigMigrationItem>,
/// Source product that produced the migration items. Missing means unspecified.
#[ts(optional = nullable)]
pub source: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
@@ -376,6 +376,7 @@ fn external_agent_config_import_params_accept_legacy_plugin_details() {
..Default::default()
}),
}],
source: None,
}
);
}
+1 -1
View File
@@ -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`.
@@ -237,7 +237,7 @@ impl ExternalAgentConfigService {
pub(crate) async fn import(
&self,
migration_items: Vec<ExternalAgentConfigMigrationItem>,
) -> io::Result<ExternalAgentConfigImportOutcome> {
) -> 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(
@@ -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"),
+2 -1
View File
@@ -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(),
});
@@ -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<ThreadManager>,
config_processor: ConfigRequestProcessor,
state_db: Option<StateDbHandle>,
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<StateDbHandle>,
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(&params.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(&params);
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, JSONRPCErrorError> {
) -> 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(&notification);
track_completed_import_notification(analytics_events_client, &analytics_source, &notification);
if let Some(state_db) = state_db
&& let Err(err) = record_completed_import_notification(state_db, &notification).await
{
@@ -571,6 +587,75 @@ async fn send_completed_import_notification(
.await;
}
fn log_completed_import_failures(notification: &ExternalAgentConfigImportCompletedNotification) {
for type_result in &notification.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 &notification.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,
@@ -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,
@@ -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::<Vec<serde_json::Value>, 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"),
)
}
@@ -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<serde_json::Value>,
) {
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<serde_json::Value>,
) {
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": {{
+154 -8
View File
@@ -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<PluginInstallOutcome, PluginInstallError> {
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<PluginInstallOutcome, PluginInstallError> {
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}")]
+4 -1
View File
@@ -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");