Files
codex/codex-rs/app-server/tests/suite/v2/external_agent_config.rs
Michael Bolin ac2bffa443 test: harden app-server integration tests (#19683)
## Why

Windows Bazel runs in the permissions stack exposed that app-server
integration tests were launching normal plugin startup warmups in every
subprocess. Those warmups can call
`https://chatgpt.com/backend-api/plugins/featured` when a test is not
specifically exercising plugin startup, which adds slow background work,
noisy stderr, and dependence on external network state. The relevant
startup/featured-plugin behavior was introduced across #15042 and
#15264.

A few app-server tests also had long optional waits or unbounded cleanup
paths, making failures expensive to diagnose and contributing to slow
Windows shards. One external-agent config test from #18246 used a
GitHub-style marketplace source, which was enough to exercise the
pending remote-import path but also meant the background completion task
could attempt a real clone.

## What Changed

- Adds explicit `AppServerRuntimeOptions` / `PluginStartupTasks`
plumbing and a hidden debug-only
`--disable-plugin-startup-tasks-for-tests` app-server flag, so
integration tests can suppress startup plugin warmups without adding a
production env-var gate.
- Has the app-server test harness pass that hidden flag by default,
while opting plugin-startup coverage back in for tests that
intentionally exercise startup sync and featured-plugin warmup behavior.
- Lowers normal app-server subprocess logging from `info`/`debug` to
`warn` to avoid multi-megabyte stderr output in Bazel logs.
- Prevents the external-agent config test from attempting a real
marketplace clone by using an invalid non-local source while still
exercising the pending-import completion path.
- Bounds optional filesystem/realtime waits and fake WebSocket
test-server shutdown so failures produce targeted timeouts instead of
hanging a shard.
- Fixes the Unix script-resolution test in `rmcp-client` to exercise
PATH resolution directly and include the actual spawn error in failures.

## Verification

- `cargo check -p codex-app-server`
- `cargo clippy -p codex-app-server --tests -- -D warnings`
- `cargo test -p codex-rmcp-client
program_resolver::tests::test_unix_executes_script_without_extension`
- `cargo test -p codex-app-server --test all
external_agent_config_import_sends_completion_notification_after_pending_plugins_finish
-- --nocapture`
- `cargo test -p codex-app-server --test all
plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request --
--nocapture`
- Windows Local Bazel passed with this test-hardening bundle before it
was extracted from #19606.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/19683).
* #19395
* #19394
* #19393
* #19392
* #19606
* __->__ #19683
2026-04-26 12:43:16 -07:00

186 lines
5.9 KiB
Rust

use std::time::Duration;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::ExternalAgentConfigImportResponse;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::RequestId;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
#[tokio::test]
async fn external_agent_config_import_sends_completion_notification_for_local_plugins() -> Result<()>
{
let codex_home = TempDir::new()?;
let marketplace_root = codex_home.path().join("marketplace");
let plugin_root = marketplace_root.join("plugins").join("sample");
std::fs::create_dir_all(marketplace_root.join(".agents/plugins"))?;
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)?;
std::fs::write(
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 settings = serde_json::json!({
"enabledPlugins": {
"sample@debug": true
},
"extraKnownMarketplaces": {
"debug": {
"source": "local",
"path": marketplace_root,
}
}
});
std::fs::write(
codex_home.path().join(".claude").join("settings.json"),
serde_json::to_string_pretty(&settings)?,
)?;
let home_dir = codex_home.path().display().to_string();
let mut mcp =
McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"externalAgentConfig/import",
Some(serde_json::json!({
"migrationItems": [{
"itemType": "PLUGINS",
"description": "Import plugins",
"cwd": null,
"details": {
"plugins": [{
"marketplaceName": "debug",
"pluginNames": ["sample"]
}]
}
}]
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ExternalAgentConfigImportResponse = to_response(response)?;
assert_eq!(response, ExternalAgentConfigImportResponse {});
let notification = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
)
.await??;
assert_eq!(notification.method, "externalAgentConfig/import/completed");
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let plugin = response
.marketplaces
.iter()
.find(|marketplace| marketplace.name == "debug")
.and_then(|marketplace| {
marketplace
.plugins
.iter()
.find(|plugin| plugin.name == "sample")
})
.expect("expected imported plugin to be listed");
assert!(plugin.installed);
assert!(plugin.enabled);
Ok(())
}
#[tokio::test]
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"))?;
// 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"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "not a valid marketplace source"
}
}
}"#,
)?;
let home_dir = codex_home.path().display().to_string();
let mut mcp =
McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"externalAgentConfig/import",
Some(serde_json::json!({
"migrationItems": [{
"itemType": "PLUGINS",
"description": "Import plugins",
"cwd": null,
"details": {
"plugins": [{
"marketplaceName": "acme-tools",
"pluginNames": ["formatter"]
}]
}
}]
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: ExternalAgentConfigImportResponse = to_response(response)?;
assert_eq!(response, ExternalAgentConfigImportResponse {});
let notification = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_notification_message("externalAgentConfig/import/completed"),
)
.await??;
assert_eq!(notification.method, "externalAgentConfig/import/completed");
Ok(())
}