mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
5867b529ae
commit
3959ab0ffc
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
+8
-1
@@ -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": [
|
||||
|
||||
+8
-1
@@ -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": [
|
||||
|
||||
+1
-1
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
+7
@@ -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": [
|
||||
|
||||
+1
-1
@@ -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,
|
||||
/**
|
||||
|
||||
+5
-1
@@ -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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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(¶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, 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(¬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,
|
||||
|
||||
@@ -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": {{
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user