mirror of
https://github.com/openai/codex.git
synced 2026-09-04 15:08:45 +00:00
Add remote plugin analytics smoke capture
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use crate::events::TrackEventsRequest;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
@@ -7,11 +8,21 @@ use std::path::Path;
|
||||
pub(crate) const ANALYTICS_EVENTS_CAPTURE_FILE_ENV_VAR: &str =
|
||||
"CODEX_ANALYTICS_EVENTS_CAPTURE_FILE";
|
||||
|
||||
pub(crate) fn initialize(path: &Path) -> io::Result<()> {
|
||||
open_capture_file(path).map(drop)
|
||||
}
|
||||
|
||||
pub(crate) fn append_payload(path: &Path, payload: &TrackEventsRequest) -> io::Result<()> {
|
||||
let mut line = serde_json::to_vec(payload)
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
|
||||
line.push(b'\n');
|
||||
|
||||
let mut file = open_capture_file(path)?;
|
||||
file.write_all(&line)?;
|
||||
file.flush()
|
||||
}
|
||||
|
||||
fn open_capture_file(path: &Path) -> io::Result<File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.create(true).append(true);
|
||||
#[cfg(unix)]
|
||||
@@ -19,8 +30,5 @@ pub(crate) fn append_payload(path: &Path, payload: &TrackEventsRequest) -> io::R
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
|
||||
let mut file = options.open(path)?;
|
||||
file.write_all(&line)?;
|
||||
file.flush()
|
||||
options.open(path)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,12 @@ impl AnalyticsEventsDestination {
|
||||
fn from_base_url_and_capture_file(base_url: String, capture_file: Option<PathBuf>) -> Self {
|
||||
#[cfg(debug_assertions)]
|
||||
if let Some(path) = capture_file {
|
||||
if let Err(err) = crate::analytics_capture::initialize(&path) {
|
||||
tracing::error!(
|
||||
path = %path.display(),
|
||||
"failed to initialize analytics event capture; network delivery remains disabled: {err}"
|
||||
);
|
||||
}
|
||||
tracing::warn!(
|
||||
path = %path.display(),
|
||||
"analytics event capture enabled; network delivery is disabled"
|
||||
|
||||
@@ -111,8 +111,25 @@ fn analytics_destination_uses_explicit_capture_file() {
|
||||
|
||||
assert_eq!(
|
||||
destination,
|
||||
AnalyticsEventsDestination::CaptureFile { path: capture_path }
|
||||
AnalyticsEventsDestination::CaptureFile {
|
||||
path: capture_path.clone()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(&capture_path).expect("read capture file"),
|
||||
""
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let mode = fs::metadata(&capture_path)
|
||||
.expect("read capture file metadata")
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
fs::remove_file(capture_path).expect("remove capture file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,5 +25,4 @@ url = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
|
||||
[lib]
|
||||
test = false
|
||||
doctest = false
|
||||
|
||||
@@ -45,6 +45,53 @@ The command validates one `codex_plugin_disabled`, `codex_plugin_enabled`, and
|
||||
inspection. It does not install or uninstall plugins and does not modify the
|
||||
profile's persistent 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,
|
||||
validates `codex_plugin_uninstalled`, and verifies that the original
|
||||
uninstalled state was restored.
|
||||
|
||||
`--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`: both events 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
|
||||
```
|
||||
|
||||
## Watching Raw Inbound Traffic
|
||||
|
||||
Initialize a connection, then print every inbound JSON-RPC message until you stop it with
|
||||
|
||||
@@ -87,6 +87,8 @@ use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
mod loopback_responses_server;
|
||||
mod plugin_analytics_capture;
|
||||
mod plugin_analytics_mutation_smoke;
|
||||
mod plugin_analytics_smoke;
|
||||
|
||||
const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[
|
||||
@@ -284,6 +286,32 @@ enum CliCommand {
|
||||
#[arg(long)]
|
||||
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,
|
||||
/// JSONL output path. Defaults to a PID-specific file under the system temp directory.
|
||||
#[arg(long)]
|
||||
capture_file: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
@@ -446,6 +474,49 @@ pub async fn run() -> Result<()> {
|
||||
let codex_bin = codex_bin.context("plugin-analytics-smoke requires --codex-bin")?;
|
||||
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,
|
||||
capture_file,
|
||||
} => {
|
||||
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,
|
||||
),
|
||||
capture_file,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
134
codex-rs/app-server-test-client/src/plugin_analytics_capture.rs
Normal file
134
codex-rs/app-server-test-client/src/plugin_analytics_capture.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
const CAPTURE_READY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const CAPTURE_READY_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
|
||||
pub(super) fn wait_until_capture_is_ready(path: &Path) -> Result<()> {
|
||||
let deadline = Instant::now() + CAPTURE_READY_TIMEOUT;
|
||||
loop {
|
||||
match fs::metadata(path) {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(err) => {
|
||||
return Err(err)
|
||||
.with_context(|| format!("inspect capture file {}", path.display()));
|
||||
}
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"analytics capture did not become ready at {}; use an authenticated profile and a debug Codex binary",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
thread::sleep(CAPTURE_READY_POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
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"]["remote_plugin_id"] == remote_plugin_id)
|
||||
.cloned(),
|
||||
);
|
||||
}
|
||||
Ok(matching)
|
||||
}
|
||||
|
||||
pub(super) struct PluginEventIdentity<'a> {
|
||||
pub(super) plugin_id: &'a str,
|
||||
pub(super) remote_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 mut validated = Vec::new();
|
||||
for event_type in ["codex_plugin_installed", "codex_plugin_uninstalled"] {
|
||||
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.remote_plugin_id,
|
||||
matching.len()
|
||||
);
|
||||
};
|
||||
validate_event(event, &expected)?;
|
||||
validated.push((*event).clone());
|
||||
}
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
fn validate_event(event: &Value, expected: &PluginEventIdentity<'_>) -> Result<()> {
|
||||
let params = &event["event_params"];
|
||||
require_string(params, "plugin_id", expected.plugin_id)?;
|
||||
require_string(params, "remote_plugin_id", expected.remote_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,107 @@
|
||||
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 uninstalled = mutation_event("codex_plugin_uninstalled");
|
||||
let unrelated = json!({
|
||||
"event_type": "codex_plugin_installed",
|
||||
"event_params": {
|
||||
"remote_plugin_id": "plugins~Plugin_other"
|
||||
}
|
||||
});
|
||||
let contents = [
|
||||
json!({"events": [unrelated]}),
|
||||
json!({"events": [installed]}),
|
||||
json!({"events": [uninstalled]}),
|
||||
]
|
||||
.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, uninstalled]);
|
||||
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,
|
||||
mutation_event("codex_plugin_uninstalled"),
|
||||
],
|
||||
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, mutation_event("codex_plugin_uninstalled")],
|
||||
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": "sample@openai-curated-remote",
|
||||
"remote_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: "sample@openai-curated-remote",
|
||||
remote_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,497 @@
|
||||
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_capture::wait_until_capture_is_ready;
|
||||
use super::plugin_analytics_smoke::ANALYTICS_CAPTURE_ENV_VAR;
|
||||
use super::plugin_analytics_smoke::prepare_capture_file;
|
||||
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)?;
|
||||
|
||||
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, 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, remote_plugin_id);
|
||||
Err(cleanup_err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn run_cleanup(
|
||||
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-remote-uninstall-{}.jsonl",
|
||||
process::id()
|
||||
))
|
||||
});
|
||||
prepare_capture_file(&capture_path)?;
|
||||
let mut client = spawn_client(codex_bin, config_overrides, &capture_path)?;
|
||||
println!("capture file: {}", capture_path.display());
|
||||
|
||||
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, 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(),
|
||||
)];
|
||||
let mut client = CodexClient::spawn_stdio_with_env(codex_bin, &overrides, &environment)?;
|
||||
client.initialize()?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
#[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 events = wait_for_remote_plugin_events(capture_path, &expected.remote_plugin_id)?;
|
||||
let events = validate_mutation_events(
|
||||
events,
|
||||
PluginEventIdentity {
|
||||
plugin_id: &expected.plugin_id,
|
||||
remote_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<Value> {
|
||||
let deadline = Instant::now() + CAPTURE_TIMEOUT;
|
||||
loop {
|
||||
let events = read_events_for_remote_plugin(path, remote_plugin_id)?;
|
||||
if let Some(event) = events
|
||||
.into_iter()
|
||||
.find(|event| event["event_type"] == event_type)
|
||||
{
|
||||
return Ok(event);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!("timed out waiting for `{event_type}` for remote plugin `{remote_plugin_id}`");
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_remote_plugin_events(path: &Path, remote_plugin_id: &str) -> Result<Vec<Value>> {
|
||||
let deadline = Instant::now() + CAPTURE_TIMEOUT;
|
||||
loop {
|
||||
let events = read_events_for_remote_plugin(path, remote_plugin_id)?;
|
||||
if ["codex_plugin_installed", "codex_plugin_uninstalled"]
|
||||
.iter()
|
||||
.all(|event_type| {
|
||||
events
|
||||
.iter()
|
||||
.any(|event| event["event_type"] == *event_type)
|
||||
})
|
||||
{
|
||||
return Ok(events);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!(
|
||||
"timed out waiting for install/uninstall analytics for remote plugin `{remote_plugin_id}`"
|
||||
);
|
||||
}
|
||||
thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn print_dirty_recovery(codex_bin: &Path, 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, remote_plugin_id);
|
||||
}
|
||||
|
||||
fn print_recovery_command(codex_bin: &Path, 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());
|
||||
eprintln!("Recovery command:");
|
||||
eprintln!(
|
||||
" {test_client} --codex-bin {} plugin-remote-uninstall --remote-plugin-id {remote_plugin_id} --confirm-account-mutation",
|
||||
codex_bin.display()
|
||||
);
|
||||
}
|
||||
@@ -27,7 +27,7 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
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 CAPTURE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const CAPTURE_POLL_INTERVAL: Duration = Duration::from_millis(50);
|
||||
@@ -229,7 +229,7 @@ fn quoted(value: &str) -> Result<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
|
||||
.parent()
|
||||
.context("capture file must have a parent directory")?;
|
||||
|
||||
Reference in New Issue
Block a user