[codex-app-server-test-client] Plugin Install/Uninstall Analytics Smoke Test (#27100)

## This PR

The original [combined remote plugin analytics PR
#26281](https://github.com/openai/codex/pull/26281) mixed reusable
analytics test infrastructure, two manual smoke workflows, a metadata
refactor, and the final identity behavior. This PR adds the
account-mutating validation workflow separately so its cleanup and
recovery guarantees can be reviewed without the final analytics behavior
change.

- Add a manually invoked remote plugin install/uninstall smoke workflow.
- Require explicit account-mutation confirmation and an initially
uninstalled plugin.
- Validate the current `codex_plugin_installed` contract, where
`plugin_id` is the backend ID.
- Restore and verify the original uninstalled state, with a dedicated
recovery command.

This baseline intentionally does not require `codex_plugin_uninstalled`,
because production does not emit that event yet. The final PR will
update this smoke to require local `plugin_id`, `remote_plugin_id`, and
uninstall emission. Review this PR as the net diff against #27099.

## Testing

- `just test -p codex-app-server-test-client` (3 focused
capture/validation tests passed)
- The live workflow was previously exercised on the green combined
reference branch, and the original uninstalled account state was
restored.
- CI is green across the required platform matrix.

## Split Overview

```text
main
├── #27093  Debug analytics capture
│   └── #27099  Non-mutating plugin smoke
│       └── #27100  Remote install/uninstall smoke  ← you are here
└── #27102  Plugin telemetry metadata refactor

After #27093, #27099, #27100, and #27102 merge:
└── Final PR: add remote_plugin_id to plugin analytics
```

Review order and dependencies:

1. [#27093 Add debug-only analytics event
capture](https://github.com/openai/codex/pull/27093) (based on `main`)
2. [#27099 Add a plugin analytics smoke
workflow](https://github.com/openai/codex/pull/27099) (stacked on
#27093)
3. [#27100 Add a remote plugin analytics mutation smoke
workflow](https://github.com/openai/codex/pull/27100) **(this PR,
stacked on #27099)**
4. [#27102 Centralize plugin telemetry metadata
construction](https://github.com/openai/codex/pull/27102) (independent,
based on `main`)
5. Final remote-ID behavior PR (created after PRs 1-4 merge)

The original [#26281](https://github.com/openai/codex/pull/26281)
remains open as the green aggregate reference until the final PR is
published.
This commit is contained in:
jameswt-oai authored and GitHub committed 2026-06-16 12:28:45 -07:00
1 parent 7baf7e467e
commit 8a40200880
7 files changed
+798 -3

No files matched your search

@@ -25,5 +25,4 @@ url = { workspace = true }
uuid = { workspace = true, features = ["v4"] } uuid = { workspace = true, features = ["v4"] }
[lib] [lib]
test = false
doctest = false doctest = false
+52
View File
@@ -49,6 +49,58 @@ prints the events and leaves the JSONL file in place for inspection. It does not
install or uninstall plugins and does not modify the profile's persistent install or uninstall plugins and does not modify the profile's persistent
config. config.
### Testing remote install and uninstall analytics
`plugin-analytics-mutation-smoke` is a manually invoked live smoke test. It
contacts the configured remote plugin API and temporarily changes the active
account's installed-plugin state. It is not run by `cargo test`, `just test`,
or CI.
Choose a remote plugin that is available to the active account and is not
currently installed. The command refuses to run when the plugin is already
installed, installs it, validates `codex_plugin_installed`, uninstalls it, and
verifies that the original uninstalled state was restored. The current install
event uses the backend ID as `plugin_id`. Uninstall is part of cleanup but is
not yet an analytics assertion.
`--remote-plugin-id` takes the backend ID, such as `plugins~Plugin_...`, not the
local `<plugin>@<marketplace>` ID.
```bash
cargo run -p codex-app-server-test-client -- \
--codex-bin ./target/debug/codex \
plugin-analytics-mutation-smoke \
--remote-plugin-id <REMOTE_PLUGIN_ID> \
--confirm-account-mutation \
--capture-file /tmp/plugin-mutation-analytics.jsonl
```
Analytics use the normal queue, reduction, batching, and serialization path,
but the debug capture destination suppresses analytics network delivery. The
command prints one of these final states:
- `PASS`: the install event validated and the plugin is uninstalled.
- `FAIL-CLEAN`: validation failed, but the original uninstalled state was
restored.
- `FAIL-LOCAL-CACHE`: the backend is uninstalled, but local cleanup reported
an error.
- `FAIL-DIRTY`: cleanup failed and the plugin still appears installed.
- `FAIL-UNKNOWN`: the command could not verify the final installed state.
For a dirty or uncertain result, retry cleanup with:
```bash
cargo run -p codex-app-server-test-client -- \
--codex-bin ./target/debug/codex \
plugin-remote-uninstall \
--remote-plugin-id <REMOTE_PLUGIN_ID> \
--confirm-account-mutation
```
Cleanup does not require analytics capture or a debug Codex binary. When the
smoke uses global `--config` overrides, its printed recovery command preserves
them so cleanup targets the same backend and account.
## Watching Raw Inbound Traffic ## Watching Raw Inbound Traffic
Initialize a connection, then print every inbound JSON-RPC message until you stop it with Initialize a connection, then print every inbound JSON-RPC message until you stop it with
@@ -88,6 +88,8 @@ use url::Url;
use uuid::Uuid; use uuid::Uuid;
mod loopback_responses_server; mod loopback_responses_server;
mod plugin_analytics_capture;
mod plugin_analytics_mutation_smoke;
mod plugin_analytics_smoke; mod plugin_analytics_smoke;
const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[ const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[
@@ -285,6 +287,29 @@ enum CliCommand {
#[arg(long)] #[arg(long)]
capture_file: Option<PathBuf>, capture_file: Option<PathBuf>,
}, },
/// Install and uninstall one remote plugin while validating analytics capture.
#[command(name = "plugin-analytics-mutation-smoke")]
PluginAnalyticsMutationSmoke {
/// Backend remote plugin id. The plugin must be initially uninstalled.
#[arg(long)]
remote_plugin_id: String,
/// Acknowledge that this command mutates the active account's plugin state.
#[arg(long)]
confirm_account_mutation: bool,
/// JSONL output path. Defaults to a PID-specific file under the system temp directory.
#[arg(long)]
capture_file: Option<PathBuf>,
},
/// Best-effort recovery command that uninstalls one remote plugin.
#[command(name = "plugin-remote-uninstall")]
PluginRemoteUninstall {
/// Backend remote plugin id to uninstall.
#[arg(long)]
remote_plugin_id: String,
/// Acknowledge that this command mutates the active account's plugin state.
#[arg(long)]
confirm_account_mutation: bool,
},
} }
pub async fn run() -> Result<()> { pub async fn run() -> Result<()> {
@@ -447,6 +472,47 @@ pub async fn run() -> Result<()> {
let codex_bin = codex_bin.context("plugin-analytics-smoke requires --codex-bin")?; let codex_bin = codex_bin.context("plugin-analytics-smoke requires --codex-bin")?;
plugin_analytics_smoke::run(&codex_bin, &config_overrides, &plugin_id, capture_file) plugin_analytics_smoke::run(&codex_bin, &config_overrides, &plugin_id, capture_file)
} }
CliCommand::PluginAnalyticsMutationSmoke {
remote_plugin_id,
confirm_account_mutation,
capture_file,
} => {
ensure_dynamic_tools_unused(&dynamic_tools, "plugin-analytics-mutation-smoke")?;
if url.is_some() {
bail!(
"plugin-analytics-mutation-smoke requires --codex-bin and does not support --url"
);
}
let codex_bin =
codex_bin.context("plugin-analytics-mutation-smoke requires --codex-bin")?;
plugin_analytics_mutation_smoke::run(
&codex_bin,
&config_overrides,
&remote_plugin_id,
plugin_analytics_mutation_smoke::AccountMutationConfirmation::from_flag(
confirm_account_mutation,
),
capture_file,
)
}
CliCommand::PluginRemoteUninstall {
remote_plugin_id,
confirm_account_mutation,
} => {
ensure_dynamic_tools_unused(&dynamic_tools, "plugin-remote-uninstall")?;
if url.is_some() {
bail!("plugin-remote-uninstall requires --codex-bin and does not support --url");
}
let codex_bin = codex_bin.context("plugin-remote-uninstall requires --codex-bin")?;
plugin_analytics_mutation_smoke::run_cleanup(
&codex_bin,
&config_overrides,
&remote_plugin_id,
plugin_analytics_mutation_smoke::AccountMutationConfirmation::from_flag(
confirm_account_mutation,
),
)
}
} }
} }
@@ -0,0 +1,102 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use serde_json::Value;
use std::fs;
use std::io;
use std::path::Path;
pub(super) fn read_events_for_remote_plugin(
path: &Path,
remote_plugin_id: &str,
) -> Result<Vec<Value>> {
let contents = match fs::read_to_string(path) {
Ok(contents) => contents,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => {
return Err(err).with_context(|| format!("read capture file {}", path.display()));
}
};
let mut matching = Vec::new();
for (index, line) in contents.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let payload: Value = serde_json::from_str(line).with_context(|| {
format!(
"parse analytics capture line {} from {}",
index + 1,
path.display()
)
})?;
let events = payload["events"]
.as_array()
.context("analytics capture payload is missing events")?;
matching.extend(
events
.iter()
.filter(|event| event["event_params"]["plugin_id"] == remote_plugin_id)
.cloned(),
);
}
Ok(matching)
}
pub(super) struct PluginEventIdentity<'a> {
pub(super) plugin_id: &'a str,
pub(super) plugin_name: &'a str,
pub(super) marketplace_name: &'a str,
}
pub(super) fn validate_mutation_events(
events: Vec<Value>,
expected: PluginEventIdentity<'_>,
) -> Result<Vec<Value>> {
let event_type = "codex_plugin_installed";
let matching = events
.iter()
.filter(|event| event["event_type"] == event_type)
.collect::<Vec<_>>();
let [event] = matching.as_slice() else {
bail!(
"expected exactly one `{event_type}` event for `{}`, found {}",
expected.plugin_id,
matching.len()
);
};
validate_event(event, &expected)?;
Ok(vec![(*event).clone()])
}
fn validate_event(event: &Value, expected: &PluginEventIdentity<'_>) -> Result<()> {
let params = &event["event_params"];
require_string(params, "plugin_id", expected.plugin_id)?;
require_string(params, "plugin_name", expected.plugin_name)?;
require_string(params, "marketplace_name", expected.marketplace_name)?;
for field in [
"has_skills",
"mcp_server_count",
"connector_ids",
"product_client_id",
] {
if params.get(field).is_none_or(Value::is_null) {
bail!(
"{} event has null or missing `{field}`",
event["event_type"]
);
}
}
Ok(())
}
fn require_string(params: &Value, field: &str, expected: &str) -> Result<()> {
let actual = params.get(field).and_then(Value::as_str);
if actual != Some(expected) {
bail!("expected `{field}` to be `{expected}`, got {actual:?}");
}
Ok(())
}
#[cfg(test)]
#[path = "plugin_analytics_capture_tests.rs"]
mod tests;
@@ -0,0 +1,93 @@
use super::PluginEventIdentity;
use super::read_events_for_remote_plugin;
use super::validate_mutation_events;
use serde_json::Value;
use serde_json::json;
use std::fs;
use std::path::PathBuf;
use std::process;
use std::time::SystemTime;
const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_test";
#[test]
fn reads_and_validates_remote_plugin_mutation_events() {
let path = unique_capture_path("valid");
let installed = mutation_event("codex_plugin_installed");
let unrelated = json!({
"event_type": "codex_plugin_installed",
"event_params": {
"plugin_id": "plugins~Plugin_other"
}
});
let contents = [
json!({"events": [unrelated]}),
json!({"events": [installed]}),
]
.into_iter()
.map(|payload| serde_json::to_string(&payload).expect("serialize capture payload"))
.collect::<Vec<_>>()
.join("\n");
fs::write(&path, contents).expect("write capture file");
let events = read_events_for_remote_plugin(&path, REMOTE_PLUGIN_ID)
.expect("read matching plugin events");
let validated =
validate_mutation_events(events, expected_identity()).expect("validate mutation events");
assert_eq!(validated, vec![installed]);
fs::remove_file(path).expect("remove capture file");
}
#[test]
fn rejects_duplicate_mutation_events() {
let installed = mutation_event("codex_plugin_installed");
let error = validate_mutation_events(vec![installed.clone(), installed], expected_identity())
.expect_err("duplicate install events should fail validation");
assert!(error.to_string().contains("found 2"));
}
#[test]
fn rejects_missing_capability_metadata() {
let mut installed = mutation_event("codex_plugin_installed");
installed["event_params"]["has_skills"] = Value::Null;
let error = validate_mutation_events(vec![installed], expected_identity())
.expect_err("missing capability metadata should fail validation");
assert!(error.to_string().contains("has_skills"));
}
fn mutation_event(event_type: &str) -> Value {
json!({
"event_type": event_type,
"event_params": {
"plugin_id": REMOTE_PLUGIN_ID,
"plugin_name": "sample",
"marketplace_name": "openai-curated-remote",
"has_skills": true,
"mcp_server_count": 0,
"connector_ids": [],
"product_client_id": "test-client"
}
})
}
fn expected_identity() -> PluginEventIdentity<'static> {
PluginEventIdentity {
plugin_id: REMOTE_PLUGIN_ID,
plugin_name: "sample",
marketplace_name: "openai-curated-remote",
}
}
fn unique_capture_path(name: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.expect("system clock should be after Unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"codex-plugin-analytics-capture-{name}-{}-{nonce}.jsonl",
process::id()
))
}
@@ -0,0 +1,483 @@
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<PathBuf>,
) -> 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<CodexClient> {
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<RemotePluginExpectation> {
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<Vec<Value>>,
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<RemotePluginExpectation> {
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}");
}
@@ -27,7 +27,7 @@ use std::thread;
use std::time::Duration; use std::time::Duration;
use std::time::Instant; use std::time::Instant;
const ANALYTICS_CAPTURE_ENV_VAR: &str = "CODEX_ANALYTICS_EVENTS_CAPTURE_FILE"; pub(super) const ANALYTICS_CAPTURE_ENV_VAR: &str = "CODEX_ANALYTICS_EVENTS_CAPTURE_FILE";
const TEST_USER_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_TEST_USER_CONFIG_FILE"; const TEST_USER_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_TEST_USER_CONFIG_FILE";
const CAPTURE_READY_TIMEOUT: Duration = Duration::from_secs(5); const CAPTURE_READY_TIMEOUT: Duration = Duration::from_secs(5);
const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10); const CAPTURE_TIMEOUT: Duration = Duration::from_secs(10);
@@ -282,7 +282,7 @@ fn quoted(value: &str) -> Result<String> {
serde_json::to_string(value).context("serialize config string") serde_json::to_string(value).context("serialize config string")
} }
fn prepare_capture_file(path: &Path) -> Result<()> { pub(super) fn prepare_capture_file(path: &Path) -> Result<()> {
let parent = path let parent = path
.parent() .parent()
.context("capture file must have a parent directory")?; .context("capture file must have a parent directory")?;