use super::CodexClient; use super::plugin_analytics_capture::PluginEventIdentity; use super::plugin_analytics_capture::read_events_for_remote_plugin; use super::plugin_analytics_capture::validate_mutation_events; use super::plugin_analytics_smoke::ANALYTICS_CAPTURE_ENV_VAR; use super::plugin_analytics_smoke::prepare_capture_file; use super::plugin_analytics_smoke::wait_until_capture_is_ready; use super::shell_quote; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use anyhow::bail; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; use serde_json::Value; use std::ffi::OsString; use std::path::Path; use std::path::PathBuf; use std::process; use std::thread; use std::time::Duration; use std::time::Instant; const REMOTE_MARKETPLACE_HINT: &str = "openai-curated-remote"; const STATE_TIMEOUT: Duration = Duration::from_secs(15); const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); const POLL_INTERVAL: Duration = Duration::from_millis(100); pub(super) fn run( codex_bin: &Path, config_overrides: &[String], remote_plugin_id: &str, confirmation: AccountMutationConfirmation, capture_file: Option, ) -> Result<()> { require_confirmation(confirmation)?; let capture_path = capture_file.unwrap_or_else(|| { std::env::temp_dir().join(format!( "codex-plugin-analytics-mutation-{}.jsonl", process::id() )) }); prepare_capture_file(&capture_path)?; let mut client = spawn_client(codex_bin, config_overrides, &capture_path)?; wait_until_capture_is_ready(&capture_path)?; client.initialize()?; let initial = read_remote_plugin(&mut client, remote_plugin_id)?; validate_initial_plugin(&initial, remote_plugin_id)?; println!( "remote plugin mutation smoke: local_id={} remote_id={} marketplace={}", initial.plugin_id, initial.remote_plugin_id, initial.marketplace_name ); let MutationSequenceResult { result: sequence_result, uninstall_rpc_failed, } = run_mutation_sequence(&mut client, &capture_path, &initial); let restoration = restore_uninstalled_state(&mut client, remote_plugin_id); println!("capture file: {}", capture_path.display()); match (sequence_result, restoration) { (Ok(events), RestorationStatus::Clean) => { println!( "\n[plugin analytics mutation smoke validated]\n{}", serde_json::to_string_pretty(&events)? ); println!("PASS: analytics validated; original uninstalled state restored"); Ok(()) } (Err(err), RestorationStatus::Clean) if uninstall_rpc_failed => { eprintln!( "FAIL-LOCAL-CACHE: backend state is uninstalled, but the uninstall RPC failed after the backend mutation: {err:#}" ); Err(err) } (Err(err), RestorationStatus::Clean) => { eprintln!("FAIL-CLEAN: {err:#}"); eprintln!("The original uninstalled account state was restored."); Err(err) } (sequence_result, RestorationStatus::LocalCleanupFailure(cleanup_err)) => { let sequence_err = sequence_result.err(); eprintln!( "FAIL-LOCAL-CACHE: backend state is uninstalled, but local cleanup reported an error: {cleanup_err:#}" ); Err(sequence_err.unwrap_or(cleanup_err)) } (sequence_result, RestorationStatus::Dirty(cleanup_err)) => { if let Err(err) = sequence_result { eprintln!("mutation smoke failed before cleanup: {err:#}"); } print_dirty_recovery(codex_bin, config_overrides, remote_plugin_id, &cleanup_err); Err(cleanup_err) } (sequence_result, RestorationStatus::Unknown(cleanup_err)) => { if let Err(err) = sequence_result { eprintln!("mutation smoke failed before final state verification: {err:#}"); } eprintln!( "FAIL-UNKNOWN: could not verify whether `{remote_plugin_id}` is installed: {cleanup_err:#}" ); print_recovery_command(codex_bin, config_overrides, remote_plugin_id); Err(cleanup_err) } } } pub(super) fn run_cleanup( codex_bin: &Path, config_overrides: &[String], remote_plugin_id: &str, confirmation: AccountMutationConfirmation, ) -> Result<()> { require_confirmation(confirmation)?; let mut overrides = config_overrides.to_vec(); overrides.extend([ "analytics.enabled=false".to_string(), "features.plugins=true".to_string(), "features.remote_plugin=true".to_string(), ]); let mut client = CodexClient::spawn_stdio(codex_bin, &overrides)?; client.initialize()?; match restore_uninstalled_state(&mut client, remote_plugin_id) { RestorationStatus::Clean => { println!("PASS: `{remote_plugin_id}` is uninstalled"); Ok(()) } RestorationStatus::LocalCleanupFailure(err) => { eprintln!( "FAIL-LOCAL-CACHE: backend state is uninstalled, but local cleanup reported an error: {err:#}" ); Err(err) } RestorationStatus::Dirty(err) => { print_dirty_recovery(codex_bin, config_overrides, remote_plugin_id, &err); Err(err) } RestorationStatus::Unknown(err) => { eprintln!( "FAIL-UNKNOWN: could not verify whether `{remote_plugin_id}` is installed: {err:#}" ); Err(err) } } } #[derive(Clone, Copy)] pub(super) enum AccountMutationConfirmation { Confirmed, Missing, } impl AccountMutationConfirmation { pub(super) fn from_flag(confirm_account_mutation: bool) -> Self { if confirm_account_mutation { Self::Confirmed } else { Self::Missing } } } fn require_confirmation(confirmation: AccountMutationConfirmation) -> Result<()> { if matches!(confirmation, AccountMutationConfirmation::Missing) { bail!( "this command installs and uninstalls a plugin on the active account; rerun with --confirm-account-mutation" ); } Ok(()) } #[derive(Clone, Copy, Debug)] enum ExpectedInstalledState { Installed, Uninstalled, } impl ExpectedInstalledState { fn is_installed(self) -> bool { matches!(self, Self::Installed) } } fn spawn_client( codex_bin: &Path, config_overrides: &[String], capture_path: &Path, ) -> Result { let mut overrides = config_overrides.to_vec(); overrides.extend([ "analytics.enabled=true".to_string(), "features.plugins=true".to_string(), "features.remote_plugin=true".to_string(), ]); let environment = vec![( OsString::from(ANALYTICS_CAPTURE_ENV_VAR), capture_path.as_os_str().to_os_string(), )]; CodexClient::spawn_stdio_with_env(codex_bin, &overrides, &environment) } #[derive(Clone, Debug)] struct RemotePluginExpectation { plugin_id: String, remote_plugin_id: String, plugin_name: String, marketplace_name: String, installed: bool, install_policy: PluginInstallPolicy, availability: PluginAvailability, } fn read_remote_plugin( client: &mut CodexClient, remote_plugin_id: &str, ) -> Result { let request_id = client.request_id(); let response: PluginReadResponse = client.send_request( ClientRequest::PluginRead { request_id: request_id.clone(), params: PluginReadParams { marketplace_path: None, remote_marketplace_name: Some(REMOTE_MARKETPLACE_HINT.to_string()), plugin_name: remote_plugin_id.to_string(), }, }, request_id, "plugin/read", )?; let summary = response.plugin.summary; let actual_remote_plugin_id = summary .remote_plugin_id .with_context(|| format!("plugin/read returned no remote id for `{remote_plugin_id}`"))?; if actual_remote_plugin_id != remote_plugin_id { bail!( "plugin/read returned remote id `{actual_remote_plugin_id}` for requested id `{remote_plugin_id}`" ); } Ok(RemotePluginExpectation { plugin_id: summary.id, remote_plugin_id: actual_remote_plugin_id, plugin_name: summary.name, marketplace_name: response.plugin.marketplace_name, installed: summary.installed, install_policy: summary.install_policy, availability: summary.availability, }) } fn validate_initial_plugin(plugin: &RemotePluginExpectation, remote_plugin_id: &str) -> Result<()> { if plugin.installed { bail!( "refusing to run: remote plugin `{remote_plugin_id}` is already installed; choose an initially uninstalled plugin" ); } if plugin.availability != PluginAvailability::Available { bail!( "remote plugin `{remote_plugin_id}` is not available: {:?}", plugin.availability ); } if plugin.install_policy == PluginInstallPolicy::NotAvailable { bail!("remote plugin `{remote_plugin_id}` is not available for install"); } Ok(()) } struct MutationSequenceResult { result: Result>, uninstall_rpc_failed: bool, } fn run_mutation_sequence( client: &mut CodexClient, capture_path: &Path, expected: &RemotePluginExpectation, ) -> MutationSequenceResult { let mut uninstall_rpc_failed = false; let result = (|| { install_remote_plugin(client, expected)?; wait_for_installed_state( client, &expected.remote_plugin_id, ExpectedInstalledState::Installed, )?; wait_for_remote_plugin_event( capture_path, &expected.remote_plugin_id, "codex_plugin_installed", )?; let uninstall_error = uninstall_remote_plugin(client, &expected.remote_plugin_id).err(); uninstall_rpc_failed = uninstall_error.is_some(); wait_for_installed_state( client, &expected.remote_plugin_id, ExpectedInstalledState::Uninstalled, ) .map_err(|state_err| { if let Some(err) = uninstall_error.as_ref() { anyhow!("plugin/uninstall failed: {err:#}; final state check failed: {state_err:#}") } else { state_err } })?; let captured_events = read_events_for_remote_plugin(capture_path, &expected.remote_plugin_id)?; let events = validate_mutation_events( captured_events, PluginEventIdentity { plugin_id: &expected.remote_plugin_id, plugin_name: &expected.plugin_name, marketplace_name: &expected.marketplace_name, }, )?; if let Some(err) = uninstall_error { return Err(err.context( "plugin/uninstall reported an error after the backend became uninstalled", )); } Ok(events) })(); MutationSequenceResult { result, uninstall_rpc_failed, } } fn install_remote_plugin(client: &mut CodexClient, plugin: &RemotePluginExpectation) -> Result<()> { let request_id = client.request_id(); let _: PluginInstallResponse = client.send_request( ClientRequest::PluginInstall { request_id: request_id.clone(), params: PluginInstallParams { marketplace_path: None, remote_marketplace_name: Some(plugin.marketplace_name.clone()), plugin_name: plugin.remote_plugin_id.clone(), }, }, request_id, "plugin/install", )?; Ok(()) } fn uninstall_remote_plugin(client: &mut CodexClient, remote_plugin_id: &str) -> Result<()> { let request_id = client.request_id(); let _: PluginUninstallResponse = client.send_request( ClientRequest::PluginUninstall { request_id: request_id.clone(), params: PluginUninstallParams { plugin_id: remote_plugin_id.to_string(), }, }, request_id, "plugin/uninstall", )?; Ok(()) } fn wait_for_installed_state( client: &mut CodexClient, remote_plugin_id: &str, expected_state: ExpectedInstalledState, ) -> Result { let deadline = Instant::now() + STATE_TIMEOUT; loop { match read_remote_plugin(client, remote_plugin_id) { Ok(plugin) if plugin.installed == expected_state.is_installed() => return Ok(plugin), Ok(_) => {} Err(err) if Instant::now() >= deadline => return Err(err), Err(_) => {} } if Instant::now() >= deadline { bail!( "timed out waiting for remote plugin `{remote_plugin_id}` to become {expected_state:?}" ); } thread::sleep(POLL_INTERVAL); } } enum RestorationStatus { Clean, LocalCleanupFailure(anyhow::Error), Dirty(anyhow::Error), Unknown(anyhow::Error), } fn restore_uninstalled_state( client: &mut CodexClient, remote_plugin_id: &str, ) -> RestorationStatus { let current = match read_remote_plugin(client, remote_plugin_id) { Ok(current) => current, Err(err) => return RestorationStatus::Unknown(err), }; if !current.installed { return RestorationStatus::Clean; } let uninstall_result = uninstall_remote_plugin(client, remote_plugin_id); match wait_for_installed_state( client, remote_plugin_id, ExpectedInstalledState::Uninstalled, ) { Ok(_) => match uninstall_result { Ok(()) => RestorationStatus::Clean, Err(err) => RestorationStatus::LocalCleanupFailure(err), }, Err(state_err) => { let error = match uninstall_result { Ok(()) => state_err, Err(uninstall_err) => anyhow!( "cleanup uninstall failed: {uninstall_err:#}; state verification failed: {state_err:#}" ), }; RestorationStatus::Dirty(error) } } } fn wait_for_remote_plugin_event( path: &Path, remote_plugin_id: &str, event_type: &str, ) -> Result<()> { let deadline = Instant::now() + CAPTURE_TIMEOUT; loop { let events = read_events_for_remote_plugin(path, remote_plugin_id)?; if events.iter().any(|event| event["event_type"] == event_type) { return Ok(()); } if Instant::now() >= deadline { bail!("timed out waiting for `{event_type}` for remote plugin `{remote_plugin_id}`"); } thread::sleep(POLL_INTERVAL); } } fn print_dirty_recovery( codex_bin: &Path, config_overrides: &[String], remote_plugin_id: &str, err: &anyhow::Error, ) { eprintln!( "FAIL-DIRTY: remote plugin `{remote_plugin_id}` still appears installed after cleanup: {err:#}" ); print_recovery_command(codex_bin, config_overrides, remote_plugin_id); } fn print_recovery_command(codex_bin: &Path, config_overrides: &[String], remote_plugin_id: &str) { let test_client = std::env::current_exe() .map(|path| path.display().to_string()) .unwrap_or_else(|_| "codex-app-server-test-client".to_string()); let mut command = format!( "{} --codex-bin {}", shell_quote(&test_client), shell_quote(&codex_bin.display().to_string()) ); for override_kv in config_overrides { command.push_str(&format!(" --config {}", shell_quote(override_kv))); } command.push_str(&format!( " plugin-remote-uninstall --remote-plugin-id {} --confirm-account-mutation", shell_quote(remote_plugin_id) )); eprintln!("Recovery command:"); eprintln!(" {command}"); }