mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
## What changed Support `plugins._default.enabled` with explicit per-plugin `enabled` overrides. Omission preserves existing source and account enablement; configuration cannot activate a plugin disabled at its source. Plugin settings such as remembered tool approvals inherit the default unless `enabled` is explicitly set. Apply the policy to plugin skills, MCP servers, Apps, and hooks, including selected executor roots and runtime configuration refreshes. Preserve the latest plugin policy when adding MCP dependencies and rebuild hooks when concurrent configuration updates replace their inputs. Report effective enablement in `plugin/list`, `plugin/installed`, `plugin/read`, and `plugin/share/list`. Remote installation still installs disabled packages, but skips MCP and Apps authentication setup without writing an enablement override. ## Testing Add regression coverage for default inheritance and explicit overrides, source-disabled plugins, policy refreshes during active turns, disabled or malformed selected plugin roots, and remote installation authentication behavior. GitOrigin-RevId: 83b70c5b47fc95474fec292f248683f1f887221e
5856 lines
193 KiB
Rust
5856 lines
193 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::sync::Mutex;
|
|
use std::sync::OnceLock;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use anyhow::bail;
|
|
use app_test_support::ChatGptAuthFixture;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::to_response;
|
|
use app_test_support::write_chatgpt_auth;
|
|
use chrono::Duration as ChronoDuration;
|
|
use chrono::Utc;
|
|
use codex_app_server_protocol::HookMetadata;
|
|
use codex_app_server_protocol::HookTrustStatus;
|
|
use codex_app_server_protocol::HooksListParams;
|
|
use codex_app_server_protocol::HooksListResponse;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::LoginAccountResponse;
|
|
use codex_app_server_protocol::PluginAuthPolicy;
|
|
use codex_app_server_protocol::PluginDisabledReason;
|
|
use codex_app_server_protocol::PluginInstallParams;
|
|
use codex_app_server_protocol::PluginInstallPolicy;
|
|
use codex_app_server_protocol::PluginInstallPolicySource;
|
|
use codex_app_server_protocol::PluginInstalledParams;
|
|
use codex_app_server_protocol::PluginInstalledResponse;
|
|
use codex_app_server_protocol::PluginListMarketplaceKind;
|
|
use codex_app_server_protocol::PluginListParams;
|
|
use codex_app_server_protocol::PluginListResponse;
|
|
use codex_app_server_protocol::PluginMarketplaceEntry;
|
|
use codex_app_server_protocol::PluginShareDiscoverability;
|
|
use codex_app_server_protocol::PluginSource;
|
|
use codex_app_server_protocol::PluginSummary;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use codex_core::config::set_project_trust_level;
|
|
use codex_login::AuthKeyringBackendKind;
|
|
use codex_login::login_with_api_key;
|
|
use codex_protocol::config_types::TrustLevel;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use flate2::Compression;
|
|
use flate2::write::GzEncoder;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
use test_case::test_case;
|
|
use tokio::time::sleep;
|
|
use tokio::time::timeout;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::header;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
use wiremock::matchers::query_param;
|
|
use wiremock::matchers::query_param_is_missing;
|
|
|
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
|
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
|
|
const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
|
|
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
|
|
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
|
|
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
|
type RemoteInstalledPluginFixtures = BTreeMap<String, BTreeMap<String, Vec<serde_json::Value>>>;
|
|
static REMOTE_INSTALLED_PLUGIN_FIXTURES: OnceLock<Mutex<RemoteInstalledPluginFixtures>> =
|
|
OnceLock::new();
|
|
|
|
fn write_plugins_enabled_config(codex_home: &std::path::Path) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
)
|
|
}
|
|
|
|
fn write_plugins_enabled_config_with_base_url(
|
|
codex_home: &std::path::Path,
|
|
base_url: &str,
|
|
) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{base_url}"
|
|
|
|
[features]
|
|
plugins = true
|
|
"#,
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_remote_plugins_disabled_config_with_base_url(
|
|
codex_home: &std::path::Path,
|
|
base_url: &str,
|
|
) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{base_url}"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = false
|
|
"#,
|
|
),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
std::fs::write(marketplace_path.as_path(), "{not json")?;
|
|
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| { marketplace.path.as_ref() != Some(&marketplace_path) }),
|
|
"invalid marketplace should be skipped"
|
|
);
|
|
assert_eq!(response.marketplace_load_errors.len(), 1);
|
|
assert_eq!(
|
|
response.marketplace_load_errors[0].marketplace_path,
|
|
marketplace_path
|
|
);
|
|
assert!(
|
|
response.marketplace_load_errors[0]
|
|
.message
|
|
.contains("invalid marketplace file"),
|
|
"unexpected error: {:?}",
|
|
response.marketplace_load_errors
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_rpcs_reject_repository_spoofing_openai_curated() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repository = TempDir::new()?;
|
|
std::fs::create_dir_all(repository.path().join(".git"))?;
|
|
std::fs::create_dir_all(repository.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(repository.path().join("attacker/.codex-plugin"))?;
|
|
std::fs::write(
|
|
repository.path().join("attacker/.codex-plugin/plugin.json"),
|
|
r#"{"name":"attacker"}"#,
|
|
)?;
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repository.path().join(".agents/plugins/marketplace.json"))?;
|
|
std::fs::write(
|
|
marketplace_path.as_path(),
|
|
r#"{"name":"openai-curated","plugins":[{"name":"attacker","source":{"source":"local","path":"./attacker"}}]}"#,
|
|
)?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
let original_config = std::fs::read(codex_home.path().join("config.toml"))?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repository.path())?]),
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| marketplace.path.as_ref() != Some(&marketplace_path))
|
|
);
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path: Some(marketplace_path),
|
|
remote_marketplace_name: None,
|
|
install_attempt_id: None,
|
|
plugin_name: "attacker".to_string(),
|
|
})
|
|
.await?;
|
|
let error = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
assert!(error.error.message.contains("reserved"));
|
|
assert!(!codex_home.path().join("plugins/cache").exists());
|
|
assert_eq!(
|
|
std::fs::read(codex_home.path().join("config.toml"))?,
|
|
original_config
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_includes_installed_plugins_and_explicit_install_suggestions() -> Result<()>
|
|
{
|
|
let codex_home = TempDir::new()?;
|
|
write_openai_api_curated_marketplace(
|
|
codex_home.path(),
|
|
&["linear", "computer-use", "not-mentioned"],
|
|
)?;
|
|
write_installed_plugin(&codex_home, "openai-api-curated", "linear")?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."linear@openai-api-curated"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: Some(vec!["computer-use".to_string()]),
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(response.marketplaces.len(), 1);
|
|
assert_eq!(response.marketplaces[0].name, "openai-api-curated");
|
|
assert_eq!(
|
|
response.marketplaces[0]
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
("linear@openai-api-curated".to_string(), true, true),
|
|
("computer-use@openai-api-curated".to_string(), false, false),
|
|
]
|
|
);
|
|
assert_eq!(response.marketplace_load_errors, Vec::new());
|
|
assert!(
|
|
response.marketplaces[0]
|
|
.plugins
|
|
.iter()
|
|
.all(|plugin| plugin.install_policy_source.is_none())
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case("", true; "without default")]
|
|
#[test_case("[plugins._default]\nenabled = false", false; "default disabled")]
|
|
#[tokio::test]
|
|
async fn plugin_installed_prefers_remote_curated_conflicts_when_remote_plugin_enabled(
|
|
plugin_config: &str,
|
|
expected_remote_enabled: bool,
|
|
) -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_openai_curated_marketplace(codex_home.path(), &["linear", "calendar"])?;
|
|
write_installed_plugin(&codex_home, "openai-curated", "linear")?;
|
|
write_installed_plugin(&codex_home, "openai-curated", "calendar")?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
plugin_sharing = false
|
|
|
|
[plugins."linear@openai-curated"]
|
|
enabled = true
|
|
|
|
[plugins."calendar@openai-curated"]
|
|
enabled = true
|
|
|
|
{plugin_config}
|
|
"#,
|
|
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 mut global_installed_body: serde_json::Value = serde_json::from_str(
|
|
&remote_installed_plugin_body("", "1.2.3", /*enabled*/ true),
|
|
)?;
|
|
global_installed_body["plugins"][0]["must_show_installation_interstitial"] =
|
|
serde_json::json!(false);
|
|
let mut remote_only = global_installed_body["plugins"][0].clone();
|
|
remote_only["id"] = serde_json::json!("plugins~Plugin_11111111111111111111111111111111");
|
|
remote_only["name"] = serde_json::json!("remote-only");
|
|
remote_only["release"]["display_name"] = serde_json::json!("Remote Only");
|
|
global_installed_body["plugins"]
|
|
.as_array_mut()
|
|
.expect("installed plugins should be an array")
|
|
.push(remote_only);
|
|
let global_installed_body = serde_json::to_string(&global_installed_body)?;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = app_server
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??;
|
|
|
|
let local_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated")
|
|
.expect("expected openai-curated marketplace entry");
|
|
assert_eq!(
|
|
local_marketplace
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| (plugin.id.clone(), plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![("calendar@openai-curated".to_string(), true)]
|
|
);
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected openai-curated-remote marketplace entry");
|
|
assert_eq!(
|
|
remote_marketplace
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| {
|
|
(
|
|
plugin.id.clone(),
|
|
plugin.enabled,
|
|
plugin.install_policy_source,
|
|
plugin.must_show_installation_interstitial,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(
|
|
"linear@openai-curated-remote".to_string(),
|
|
expected_remote_enabled,
|
|
Some(PluginInstallPolicySource::WorkspaceSetting),
|
|
Some(false),
|
|
),
|
|
(
|
|
"remote-only@openai-curated-remote".to_string(),
|
|
expected_remote_enabled,
|
|
Some(PluginInstallPolicySource::WorkspaceSetting),
|
|
Some(false),
|
|
),
|
|
]
|
|
);
|
|
assert_eq!(response.marketplace_load_errors, Vec::new());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_prefers_api_curated_conflicts_after_switching_to_api_auth() -> Result<()>
|
|
{
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_openai_api_curated_marketplace(codex_home.path(), &["linear"])?;
|
|
write_installed_plugin(&codex_home, "openai-api-curated", "linear")?;
|
|
let config = format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
plugin_sharing = false
|
|
|
|
[plugins."linear@openai-api-curated"]
|
|
enabled = true
|
|
"#,
|
|
server.uri()
|
|
);
|
|
std::fs::write(codex_home.path().join("config.toml"), &config)?;
|
|
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,
|
|
)?;
|
|
mount_remote_installed_plugins(
|
|
&server,
|
|
"GLOBAL",
|
|
&remote_installed_plugin_body("", "1.2.3", /*enabled*/ true),
|
|
)
|
|
.await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = app_server
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??;
|
|
assert_eq!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| &marketplace.plugins)
|
|
.map(|plugin| plugin.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["linear@openai-curated-remote"]
|
|
);
|
|
|
|
// Keep the ChatGPT remote snapshot cached while changing auth to exercise endpoint-level
|
|
// filtering even when the account-change cache refresh cannot run.
|
|
std::fs::write(codex_home.path().join("config.toml"), "invalid config")?;
|
|
let request_id = app_server
|
|
.send_login_account_api_key_request("sk-test-key")
|
|
.await?;
|
|
let response: LoginAccountResponse =
|
|
timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??;
|
|
assert_eq!(response, LoginAccountResponse::ApiKey {});
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
app_server.read_stream_until_notification_message("account/updated"),
|
|
)
|
|
.await??;
|
|
std::fs::write(codex_home.path().join("config.toml"), config)?;
|
|
|
|
let request_id = app_server
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, app_server.read_response(request_id)).await??;
|
|
|
|
assert_eq!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| &marketplace.plugins)
|
|
.map(|plugin| plugin.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["linear@openai-api-curated"]
|
|
);
|
|
assert_eq!(response.marketplace_load_errors, Vec::new());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_ignores_local_cache_without_catalog() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
write_installed_plugin(&codex_home, "openai-curated", "linear")?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."linear@openai-curated"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(response.marketplaces, Vec::new());
|
|
assert_eq!(response.marketplace_load_errors, Vec::new());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_rejects_relative_cwds() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_raw_request(
|
|
"plugin/list",
|
|
Some(serde_json::json!({
|
|
"cwds": ["relative-root"],
|
|
})),
|
|
)
|
|
.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("Invalid request"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_load() -> Result<()>
|
|
{
|
|
let codex_home = TempDir::new()?;
|
|
let valid_repo_root = TempDir::new()?;
|
|
let invalid_repo_root = TempDir::new()?;
|
|
std::fs::create_dir_all(valid_repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(valid_repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(
|
|
valid_repo_root
|
|
.path()
|
|
.join("plugins/valid-plugin/.codex-plugin"),
|
|
)?;
|
|
std::fs::create_dir_all(invalid_repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(invalid_repo_root.path().join(".agents/plugins"))?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
|
|
let valid_marketplace_path = AbsolutePathBuf::try_from(
|
|
valid_repo_root
|
|
.path()
|
|
.join(".agents/plugins/marketplace.json"),
|
|
)?;
|
|
let invalid_marketplace_path = AbsolutePathBuf::try_from(
|
|
invalid_repo_root
|
|
.path()
|
|
.join(".agents/plugins/marketplace.json"),
|
|
)?;
|
|
let valid_plugin_path =
|
|
AbsolutePathBuf::try_from(valid_repo_root.path().join("plugins/valid-plugin"))?;
|
|
|
|
std::fs::write(
|
|
valid_marketplace_path.as_path(),
|
|
r#"{
|
|
"name": "valid-marketplace",
|
|
"plugins": [
|
|
{
|
|
"name": "valid-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/valid-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
valid_repo_root
|
|
.path()
|
|
.join("plugins/valid-plugin/.codex-plugin/plugin.json"),
|
|
r#"{"name":"valid-plugin","keywords":["api-key","developer tools"]}"#,
|
|
)?;
|
|
std::fs::write(invalid_marketplace_path.as_path(), "{not json")?;
|
|
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![
|
|
AbsolutePathBuf::try_from(valid_repo_root.path())?,
|
|
AbsolutePathBuf::try_from(invalid_repo_root.path())?,
|
|
]),
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(
|
|
response.marketplaces,
|
|
vec![PluginMarketplaceEntry {
|
|
name: "valid-marketplace".to_string(),
|
|
path: Some(valid_marketplace_path),
|
|
interface: None,
|
|
plugins: vec![PluginSummary {
|
|
id: "valid-plugin@valid-marketplace".to_string(),
|
|
remote_plugin_id: None,
|
|
version: None,
|
|
local_version: None,
|
|
name: "valid-plugin".to_string(),
|
|
share_context: None,
|
|
source: PluginSource::Local {
|
|
path: valid_plugin_path,
|
|
},
|
|
installed: false,
|
|
installed_at: None,
|
|
enabled: false,
|
|
install_policy: PluginInstallPolicy::Available,
|
|
install_policy_source: None,
|
|
must_show_installation_interstitial: None,
|
|
auth_policy: PluginAuthPolicy::OnInstall,
|
|
availability: codex_app_server_protocol::PluginAvailability::Available,
|
|
disabled_reason: None,
|
|
eligible_plan_types: None,
|
|
interface: None,
|
|
keywords: vec!["api-key".to_string(), "developer tools".to_string()],
|
|
}],
|
|
}]
|
|
);
|
|
assert_eq!(response.marketplace_load_errors.len(), 1);
|
|
assert_eq!(
|
|
response.marketplace_load_errors[0].marketplace_path,
|
|
invalid_marketplace_path
|
|
);
|
|
assert!(
|
|
response.marketplace_load_errors[0]
|
|
.message
|
|
.contains("invalid marketplace file"),
|
|
"unexpected error: {:?}",
|
|
response.marketplace_load_errors
|
|
);
|
|
assert!(response.featured_plugin_ids.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverable_plugins()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let valid_plugin_root = repo_root.path().join("plugins/valid-plugin");
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(
|
|
repo_root
|
|
.path()
|
|
.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH)
|
|
.parent()
|
|
.unwrap(),
|
|
)?;
|
|
std::fs::create_dir_all(
|
|
valid_plugin_root
|
|
.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH)
|
|
.parent()
|
|
.unwrap(),
|
|
)?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(ALTERNATE_MARKETPLACE_RELATIVE_PATH))?;
|
|
let valid_plugin_path = AbsolutePathBuf::try_from(valid_plugin_root.clone())?;
|
|
|
|
std::fs::write(
|
|
marketplace_path.as_path(),
|
|
r#"{
|
|
"name": "alternate-marketplace",
|
|
"plugins": [
|
|
{
|
|
"name": "valid-plugin",
|
|
"source": "./plugins/valid-plugin"
|
|
},
|
|
{
|
|
"name": "missing-plugin",
|
|
"source": "./plugins/missing-plugin"
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
valid_plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH),
|
|
r#"{
|
|
"name": "valid-plugin",
|
|
"interface": {
|
|
"displayName": "Valid Plugin"
|
|
}
|
|
}"#,
|
|
)?;
|
|
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(
|
|
response.marketplaces,
|
|
vec![PluginMarketplaceEntry {
|
|
name: "alternate-marketplace".to_string(),
|
|
path: Some(marketplace_path),
|
|
interface: None,
|
|
plugins: vec![
|
|
PluginSummary {
|
|
id: "valid-plugin@alternate-marketplace".to_string(),
|
|
remote_plugin_id: None,
|
|
version: None,
|
|
local_version: None,
|
|
name: "valid-plugin".to_string(),
|
|
share_context: None,
|
|
source: PluginSource::Local {
|
|
path: valid_plugin_path,
|
|
},
|
|
installed: false,
|
|
installed_at: None,
|
|
enabled: false,
|
|
install_policy: PluginInstallPolicy::Available,
|
|
install_policy_source: None,
|
|
must_show_installation_interstitial: None,
|
|
auth_policy: PluginAuthPolicy::OnInstall,
|
|
availability: codex_app_server_protocol::PluginAvailability::Available,
|
|
disabled_reason: None,
|
|
eligible_plan_types: None,
|
|
interface: Some(codex_app_server_protocol::PluginInterface {
|
|
display_name: Some("Valid Plugin".to_string()),
|
|
short_description: None,
|
|
long_description: None,
|
|
developer_name: None,
|
|
category: None,
|
|
capabilities: Vec::new(),
|
|
website_url: None,
|
|
privacy_policy_url: None,
|
|
terms_of_service_url: None,
|
|
default_prompt: None,
|
|
brand_color: None,
|
|
composer_icon: None,
|
|
composer_icon_url: None,
|
|
logo: None,
|
|
logo_dark: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
screenshots: Vec::new(),
|
|
screenshot_urls: Vec::new(),
|
|
}),
|
|
keywords: Vec::new(),
|
|
},
|
|
PluginSummary {
|
|
id: "missing-plugin@alternate-marketplace".to_string(),
|
|
remote_plugin_id: None,
|
|
version: None,
|
|
local_version: None,
|
|
name: "missing-plugin".to_string(),
|
|
share_context: None,
|
|
source: PluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(
|
|
repo_root.path().join("plugins/missing-plugin"),
|
|
)?,
|
|
},
|
|
installed: false,
|
|
installed_at: None,
|
|
enabled: false,
|
|
install_policy: PluginInstallPolicy::Available,
|
|
install_policy_source: None,
|
|
must_show_installation_interstitial: None,
|
|
auth_policy: PluginAuthPolicy::OnInstall,
|
|
availability: codex_app_server_protocol::PluginAvailability::Available,
|
|
disabled_reason: None,
|
|
eligible_plan_types: None,
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
},
|
|
],
|
|
}]
|
|
);
|
|
assert!(response.marketplace_load_errors.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_omitted_cwds_excludes_server_project_config() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let project_marketplace = TempDir::new()?;
|
|
std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(codex_home.path().join(".git"))?;
|
|
std::fs::create_dir_all(codex_home.path().join(".codex"))?;
|
|
std::fs::create_dir_all(project_marketplace.path().join(".agents/plugins"))?;
|
|
write_installed_plugin(&codex_home, "home-marketplace", "home-plugin")?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
"[features]\nplugins = true\n[plugins.\"home-plugin@home-marketplace\"]\nenabled = true\n",
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "home-marketplace",
|
|
"plugins": [
|
|
{
|
|
"name": "home-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./home-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
project_marketplace
|
|
.path()
|
|
.join(".agents/plugins/marketplace.json"),
|
|
r#"{"name":"project-marketplace","plugins":[{"name":"project-plugin","source":{"source":"local","path":"./project-plugin"}}]}"#,
|
|
)?;
|
|
let source = serde_json::to_string(&project_marketplace.path().to_string_lossy())?;
|
|
std::fs::write(
|
|
codex_home.path().join(".codex/config.toml"),
|
|
format!(
|
|
"[marketplaces.project-marketplace]\nsource_type = \"local\"\nsource = {source}\n\n[plugins.\"home-plugin@home-marketplace\"]\nenabled = false\n"
|
|
),
|
|
)?;
|
|
set_project_trust_level(codex_home.path(), codex_home.path(), TrustLevel::Trusted)?;
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
for (cwds, expected) in [
|
|
(None, vec![("home-plugin@home-marketplace", true, true)]),
|
|
(
|
|
Some(Vec::new()),
|
|
vec![("home-plugin@home-marketplace", true, true)],
|
|
),
|
|
(
|
|
Some(vec![AbsolutePathBuf::try_from(codex_home.path())?]),
|
|
vec![
|
|
("home-plugin@home-marketplace", true, false),
|
|
("project-plugin@project-marketplace", false, false),
|
|
],
|
|
),
|
|
] {
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
let mut plugins = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| &marketplace.plugins)
|
|
.map(|plugin| (plugin.id.as_str(), plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>();
|
|
plugins.sort_unstable();
|
|
assert_eq!(plugins, expected);
|
|
assert_eq!(response.marketplace_load_errors, Vec::new());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/demo-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"demo-plugin","version":"1.2.3"}"#,
|
|
)?;
|
|
write_plugin_share_local_path_mapping(
|
|
codex_home.path(),
|
|
"plugins_123",
|
|
&AbsolutePathBuf::try_from(plugin_root)?,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
let plugin = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| marketplace.plugins.iter())
|
|
.find(|plugin| plugin.name == "demo-plugin")
|
|
.expect("expected demo-plugin entry");
|
|
assert_eq!(plugin.remote_plugin_id, None);
|
|
assert_eq!(plugin.local_version.as_deref(), Some("1.2.3"));
|
|
let share_context = plugin
|
|
.share_context
|
|
.as_ref()
|
|
.expect("expected share context");
|
|
assert_eq!(share_context.remote_plugin_id, "plugins_123");
|
|
assert_eq!(share_context.remote_version, None);
|
|
assert_eq!(share_context.discoverability, None);
|
|
assert_eq!(share_context.share_url, None);
|
|
assert_eq!(share_context.creator_account_user_id, None);
|
|
assert_eq!(share_context.creator_name, None);
|
|
assert_eq!(share_context.share_principals, None);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_force_refetch_waits_for_same_path_local_plugin_upgrade() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let marketplace_root = TempDir::new()?;
|
|
std::fs::create_dir_all(marketplace_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(marketplace_root.path().join(".agents/plugins"))?;
|
|
let source_manifest = marketplace_root
|
|
.path()
|
|
.join("sample-plugin/.codex-plugin/plugin.json");
|
|
std::fs::create_dir_all(source_manifest.parent().expect("source manifest parent"))?;
|
|
std::fs::write(
|
|
&source_manifest,
|
|
r#"{"name":"sample-plugin","version":"1.0.0"}"#,
|
|
)?;
|
|
std::fs::write(
|
|
marketplace_root
|
|
.path()
|
|
.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "sample-marketplace",
|
|
"plugins": [
|
|
{
|
|
"name": "sample-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./sample-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
remote_plugin = false
|
|
|
|
[plugins."sample-plugin@sample-marketplace"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
write_installed_plugin_with_version(
|
|
&codex_home,
|
|
"sample-marketplace",
|
|
"sample-plugin",
|
|
"1.0.0",
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build()
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(marketplace_root.path())?]),
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]),
|
|
force_refetch: true,
|
|
})
|
|
.await?;
|
|
let initial_response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let _: PluginListResponse = to_response(initial_response)?;
|
|
|
|
std::fs::write(
|
|
&source_manifest,
|
|
r#"{"name":"sample-plugin","version":"1.1.0"}"#,
|
|
)?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(marketplace_root.path())?]),
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]),
|
|
force_refetch: true,
|
|
})
|
|
.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 == "sample-marketplace")
|
|
.and_then(|marketplace| {
|
|
marketplace
|
|
.plugins
|
|
.iter()
|
|
.find(|plugin| plugin.name == "sample-plugin")
|
|
})
|
|
.expect("upgraded local plugin should appear in its marketplace response");
|
|
assert!(plugin.installed);
|
|
assert!(plugin.enabled);
|
|
assert_eq!(plugin.local_version.as_deref(), Some("1.1.0"));
|
|
|
|
let plugin_cache = codex_home
|
|
.path()
|
|
.join("plugins/cache/sample-marketplace/sample-plugin");
|
|
let installed_manifest = plugin_cache.join("1.1.0/.codex-plugin/plugin.json");
|
|
assert!(
|
|
installed_manifest.is_file(),
|
|
"force-refetched plugin/list must finish installing the newer local plugin before responding"
|
|
);
|
|
assert!(
|
|
!plugin_cache.join("1.0.0").exists(),
|
|
"force-refetched plugin/list must remove the superseded local plugin before responding"
|
|
);
|
|
let installed_manifest: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(installed_manifest)?)?;
|
|
assert_eq!(installed_manifest["version"], serde_json::json!("1.1.0"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum MarketplaceRefreshScenario {
|
|
Distinct,
|
|
Duplicate,
|
|
DuplicateConfiguredInLaterCwd,
|
|
}
|
|
|
|
#[test_case(true, true, MarketplaceRefreshScenario::Distinct; "forced")]
|
|
#[test_case(false, true, MarketplaceRefreshScenario::Distinct; "background")]
|
|
#[test_case(true, false, MarketplaceRefreshScenario::Distinct; "forced with project-enabled plugins")]
|
|
#[test_case(false, false, MarketplaceRefreshScenario::Distinct; "background with project-enabled plugins")]
|
|
#[test_case(true, true, MarketplaceRefreshScenario::Duplicate; "forced preserves source precedence")]
|
|
#[test_case(false, true, MarketplaceRefreshScenario::Duplicate; "background preserves source precedence")]
|
|
#[test_case(true, true, MarketplaceRefreshScenario::DuplicateConfiguredInLaterCwd; "forced merges later repository configuration")]
|
|
#[test_case(false, true, MarketplaceRefreshScenario::DuplicateConfiguredInLaterCwd; "background merges later repository configuration")]
|
|
#[tokio::test]
|
|
async fn plugin_list_refreshes_plugins_from_each_cwd(
|
|
force_refetch: bool,
|
|
home_plugins_enabled: bool,
|
|
scenario: MarketplaceRefreshScenario,
|
|
) -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!("[features]\nplugins = {home_plugins_enabled}\n"),
|
|
)?;
|
|
let workspace = TempDir::new()?;
|
|
let repos = [
|
|
workspace.path().join("z_repo"),
|
|
workspace.path().join("a_repo"),
|
|
];
|
|
let names = if scenario == MarketplaceRefreshScenario::Distinct {
|
|
["first", "second"]
|
|
} else {
|
|
["first", "first"]
|
|
};
|
|
let versions = ["1.1.0", "2.0.0"];
|
|
for ((repo, name), version) in repos.iter().zip(names).zip(versions) {
|
|
for directory in [".git", ".codex", ".agents/plugins", "sample/.codex-plugin"] {
|
|
std::fs::create_dir_all(repo.join(directory))?;
|
|
}
|
|
std::fs::write(
|
|
repo.join("sample/.codex-plugin/plugin.json"),
|
|
serde_json::to_vec(&serde_json::json!({"name": "sample", "version": version}))?,
|
|
)?;
|
|
std::fs::write(
|
|
repo.join(".agents/plugins/marketplace.json"),
|
|
serde_json::to_vec(&serde_json::json!({"name": name, "plugins": [{
|
|
"name": "sample", "source": {"source": "local", "path": "./sample"}
|
|
}]}))?,
|
|
)?;
|
|
let plugin_config = if scenario == MarketplaceRefreshScenario::DuplicateConfiguredInLaterCwd
|
|
&& repo == &repos[0]
|
|
{
|
|
String::new()
|
|
} else {
|
|
format!("[plugins.\"sample@{name}\"]\nenabled = true\n")
|
|
};
|
|
std::fs::write(
|
|
repo.join(".codex/config.toml"),
|
|
format!("[features]\nplugins = true\n{plugin_config}"),
|
|
)?;
|
|
set_project_trust_level(codex_home.path(), repo, TrustLevel::Trusted)?;
|
|
write_installed_plugin_with_version(&codex_home, name, "sample", "1.0.0")?;
|
|
}
|
|
let mut server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
let cwds = repos
|
|
.iter()
|
|
.map(|repo| AbsolutePathBuf::try_from(repo.as_path()))
|
|
.collect::<std::io::Result<Vec<_>>>()?;
|
|
let id = server
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(cwds.clone()),
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]),
|
|
force_refetch,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = timeout(DEFAULT_TIMEOUT, server.read_response(id)).await??;
|
|
assert_eq!(response.marketplace_load_errors, Vec::new());
|
|
let expected_sources = names
|
|
.into_iter()
|
|
.zip(versions)
|
|
.take(if scenario == MarketplaceRefreshScenario::Distinct {
|
|
2
|
|
} else {
|
|
1
|
|
})
|
|
.collect::<Vec<_>>();
|
|
for (name, version) in &expected_sources {
|
|
let manifest = codex_home.path().join(format!(
|
|
"plugins/cache/{name}/sample/{version}/.codex-plugin/plugin.json"
|
|
));
|
|
if !force_refetch {
|
|
wait_for_path_exists(&manifest).await?;
|
|
}
|
|
let value: serde_json::Value = serde_json::from_slice(&std::fs::read(manifest)?)?;
|
|
assert_eq!(value["version"], *version);
|
|
}
|
|
let id = server
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: Some(cwds),
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, server.read_response(id)).await??;
|
|
let mut plugins = response
|
|
.marketplaces
|
|
.iter()
|
|
.filter(|marketplace| ["first", "second"].contains(&marketplace.name.as_str()))
|
|
.flat_map(|marketplace| &marketplace.plugins)
|
|
.map(|plugin| (plugin.id.clone(), plugin.enabled))
|
|
.collect::<Vec<_>>();
|
|
plugins.sort_unstable();
|
|
let expected_plugins = expected_sources
|
|
.iter()
|
|
.map(|(name, _)| (format!("sample@{name}"), true))
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(plugins, expected_plugins);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_catalogs_skip_invalid_project_config_and_report_cwd_error() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
let workspace = TempDir::new()?;
|
|
let invalid_repo = workspace.path().join("invalid");
|
|
let valid_repo = workspace.path().join("valid");
|
|
for repo in [&invalid_repo, &valid_repo] {
|
|
for directory in [".git", ".codex", ".agents/plugins"] {
|
|
std::fs::create_dir_all(repo.join(directory))?;
|
|
}
|
|
set_project_trust_level(codex_home.path(), repo, TrustLevel::Trusted)?;
|
|
}
|
|
std::fs::write(invalid_repo.join(".codex/config.toml"), "invalid = [\n")?;
|
|
std::fs::write(
|
|
valid_repo.join(".codex/config.toml"),
|
|
"[plugins.\"sample@valid-marketplace\"]\nenabled = true\n",
|
|
)?;
|
|
std::fs::write(
|
|
valid_repo.join(".agents/plugins/marketplace.json"),
|
|
r#"{"name":"valid-marketplace","plugins":[{"name":"sample","source":{"source":"local","path":"./sample"}}]}"#,
|
|
)?;
|
|
write_installed_plugin(&codex_home, "valid-marketplace", "sample")?;
|
|
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
let invalid_cwd = AbsolutePathBuf::try_from(invalid_repo.as_path())?;
|
|
let cwds = vec![
|
|
invalid_cwd.clone(),
|
|
AbsolutePathBuf::try_from(valid_repo.as_path())?,
|
|
];
|
|
|
|
let request_id = server
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(cwds.clone()),
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, server.read_response(request_id)).await??;
|
|
assert_eq!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| &marketplace.plugins)
|
|
.map(|plugin| (plugin.id.as_str(), plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![("sample@valid-marketplace", true, true)]
|
|
);
|
|
assert_eq!(response.marketplace_load_errors.len(), 1);
|
|
assert_eq!(
|
|
response.marketplace_load_errors[0].marketplace_path,
|
|
invalid_cwd
|
|
);
|
|
assert!(
|
|
response.marketplace_load_errors[0]
|
|
.message
|
|
.contains("failed to reload config")
|
|
);
|
|
|
|
let request_id = server
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: Some(cwds),
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, server.read_response(request_id)).await??;
|
|
assert_eq!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| &marketplace.plugins)
|
|
.map(|plugin| (plugin.id.as_str(), plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![("sample@valid-marketplace", true)]
|
|
);
|
|
assert_eq!(response.marketplace_load_errors.len(), 1);
|
|
assert_eq!(
|
|
response.marketplace_load_errors[0].marketplace_path,
|
|
invalid_cwd
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case("", "enabled = true", true; "legacy explicit enabled")]
|
|
#[test_case("[plugins._default]\nenabled = false", "mcp_servers.example.tools.search.approval_mode = 'approve'", false; "default disabled approval-only entry")]
|
|
#[test_case("[plugins._default]\nenabled = false", "enabled = true", true; "explicitly enabled overrides default")]
|
|
#[tokio::test]
|
|
async fn plugin_list_includes_install_and_enabled_state_from_config(
|
|
plugin_default: &str,
|
|
plugin_settings: &str,
|
|
expected_enabled: bool,
|
|
) -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
write_installed_plugin(&codex_home, "codex-curated", "enabled-plugin")?;
|
|
write_installed_plugin(&codex_home, "codex-curated", "disabled-plugin")?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"interface": {
|
|
"displayName": "ChatGPT Official"
|
|
},
|
|
"plugins": [
|
|
{
|
|
"name": "enabled-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./enabled-plugin"
|
|
}
|
|
},
|
|
{
|
|
"name": "disabled-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./disabled-plugin"
|
|
}
|
|
},
|
|
{
|
|
"name": "uninstalled-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./uninstalled-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
{plugin_default}
|
|
|
|
[plugins."enabled-plugin@codex-curated"]
|
|
{plugin_settings}
|
|
|
|
[plugins."disabled-plugin@codex-curated"]
|
|
enabled = false
|
|
"#,
|
|
),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
let marketplace = response
|
|
.marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| {
|
|
marketplace.path.as_ref()
|
|
== Some(
|
|
&AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.expect("absolute marketplace path"),
|
|
)
|
|
})
|
|
.expect("expected repo marketplace entry");
|
|
|
|
assert_eq!(marketplace.name, "codex-curated");
|
|
assert_eq!(
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("ChatGPT Official")
|
|
);
|
|
assert_eq!(marketplace.plugins.len(), 3);
|
|
assert_eq!(marketplace.plugins[0].id, "enabled-plugin@codex-curated");
|
|
assert_eq!(marketplace.plugins[0].name, "enabled-plugin");
|
|
assert_eq!(marketplace.plugins[0].installed, true);
|
|
assert_eq!(marketplace.plugins[0].enabled, expected_enabled);
|
|
assert_eq!(
|
|
marketplace.plugins[0].install_policy,
|
|
PluginInstallPolicy::Available
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[0].auth_policy,
|
|
PluginAuthPolicy::OnInstall
|
|
);
|
|
assert_eq!(marketplace.plugins[1].id, "disabled-plugin@codex-curated");
|
|
assert_eq!(marketplace.plugins[1].name, "disabled-plugin");
|
|
assert_eq!(marketplace.plugins[1].installed, true);
|
|
assert_eq!(marketplace.plugins[1].enabled, false);
|
|
assert_eq!(
|
|
marketplace.plugins[1].install_policy,
|
|
PluginInstallPolicy::Available
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[1].auth_policy,
|
|
PluginAuthPolicy::OnInstall
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[2].id,
|
|
"uninstalled-plugin@codex-curated"
|
|
);
|
|
assert_eq!(marketplace.plugins[2].name, "uninstalled-plugin");
|
|
assert_eq!(marketplace.plugins[2].installed, false);
|
|
assert_eq!(marketplace.plugins[2].enabled, false);
|
|
assert_eq!(
|
|
marketplace.plugins[2].install_policy,
|
|
PluginInstallPolicy::Available
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[2].auth_policy,
|
|
PluginAuthPolicy::OnInstall
|
|
);
|
|
|
|
let request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
let installed = response
|
|
.marketplaces
|
|
.into_iter()
|
|
.find(|entry| entry.path == marketplace.path)
|
|
.expect("expected installed repo marketplace entry");
|
|
assert_eq!(
|
|
installed
|
|
.plugins
|
|
.into_iter()
|
|
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(
|
|
"enabled-plugin@codex-curated".to_string(),
|
|
true,
|
|
expected_enabled,
|
|
),
|
|
("disabled-plugin@codex-curated".to_string(), true, false),
|
|
]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_deduplicates_sources_and_merges_enabled_state() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?;
|
|
write_installed_plugin(&codex_home, "codex-curated", "shared-plugin")?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."shared-plugin@codex-curated"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
|
|
let workspace_enabled = TempDir::new()?;
|
|
std::fs::create_dir_all(workspace_enabled.path().join(".git"))?;
|
|
std::fs::create_dir_all(workspace_enabled.path().join(".agents/plugins"))?;
|
|
std::fs::write(
|
|
workspace_enabled
|
|
.path()
|
|
.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "shared-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./shared-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::create_dir_all(workspace_enabled.path().join(".codex"))?;
|
|
std::fs::write(
|
|
workspace_enabled.path().join(".codex/config.toml"),
|
|
r#"[plugins."shared-plugin@codex-curated"]
|
|
enabled = false
|
|
"#,
|
|
)?;
|
|
set_project_trust_level(
|
|
codex_home.path(),
|
|
workspace_enabled.path(),
|
|
TrustLevel::Trusted,
|
|
)?;
|
|
|
|
let workspace_default = TempDir::new()?;
|
|
std::fs::create_dir_all(workspace_default.path().join(".git"))?;
|
|
std::fs::create_dir_all(workspace_default.path().join(".agents/plugins"))?;
|
|
std::fs::copy(
|
|
workspace_enabled
|
|
.path()
|
|
.join(".agents/plugins/marketplace.json"),
|
|
workspace_default
|
|
.path()
|
|
.join(".agents/plugins/marketplace.json"),
|
|
)?;
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![
|
|
AbsolutePathBuf::try_from(workspace_enabled.path())?,
|
|
AbsolutePathBuf::try_from(workspace_default.path())?,
|
|
]),
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
let marketplaces = response
|
|
.marketplaces
|
|
.iter()
|
|
.filter(|marketplace| marketplace.name == "codex-curated")
|
|
.map(|marketplace| {
|
|
(
|
|
marketplace.name.as_str(),
|
|
marketplace
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| (plugin.id.as_str(), plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
marketplaces,
|
|
vec![(
|
|
"codex-curated",
|
|
vec![("shared-plugin@codex-curated", true, true)]
|
|
)]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/demo-plugin"
|
|
},
|
|
"policy": {
|
|
"installation": "AVAILABLE",
|
|
"authentication": "ON_INSTALL"
|
|
},
|
|
"category": "Design"
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r##"{
|
|
"name": "demo-plugin",
|
|
"interface": {
|
|
"displayName": "Plugin Display Name",
|
|
"shortDescription": "Short description for subtitle",
|
|
"longDescription": "Long description for details page",
|
|
"developerName": "OpenAI",
|
|
"category": "Productivity",
|
|
"capabilities": ["Interactive", "Write"],
|
|
"websiteURL": "https://openai.com/",
|
|
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
|
|
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
|
|
"defaultPrompt": [
|
|
"Starter prompt for trying a plugin",
|
|
"Find my next action"
|
|
],
|
|
"brandColor": "#3B82F6",
|
|
"composerIcon": "./assets/icon.png",
|
|
"logo": "./assets/logo.png",
|
|
"screenshots": ["./assets/screenshot1.png", "./assets/screenshot2.png"]
|
|
}
|
|
}"##,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
let plugin = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| marketplace.plugins.iter())
|
|
.find(|plugin| plugin.name == "demo-plugin")
|
|
.expect("expected demo-plugin entry");
|
|
|
|
assert_eq!(plugin.id, "demo-plugin@codex-curated");
|
|
assert_eq!(plugin.installed, false);
|
|
assert_eq!(plugin.enabled, false);
|
|
assert_eq!(plugin.install_policy, PluginInstallPolicy::Available);
|
|
assert_eq!(plugin.auth_policy, PluginAuthPolicy::OnInstall);
|
|
let interface = plugin
|
|
.interface
|
|
.as_ref()
|
|
.expect("expected plugin interface");
|
|
assert_eq!(
|
|
interface.display_name.as_deref(),
|
|
Some("Plugin Display Name")
|
|
);
|
|
assert_eq!(interface.category.as_deref(), Some("Design"));
|
|
assert_eq!(
|
|
interface.website_url.as_deref(),
|
|
Some("https://openai.com/")
|
|
);
|
|
assert_eq!(
|
|
interface.privacy_policy_url.as_deref(),
|
|
Some("https://openai.com/policies/row-privacy-policy/")
|
|
);
|
|
assert_eq!(
|
|
interface.terms_of_service_url.as_deref(),
|
|
Some("https://openai.com/policies/row-terms-of-use/")
|
|
);
|
|
assert_eq!(
|
|
interface.default_prompt,
|
|
Some(vec![
|
|
"Starter prompt for trying a plugin".to_string(),
|
|
"Find my next action".to_string()
|
|
])
|
|
);
|
|
assert_eq!(
|
|
interface.composer_icon,
|
|
Some(AbsolutePathBuf::try_from(
|
|
plugin_root.join("assets/icon.png")
|
|
)?)
|
|
);
|
|
assert_eq!(
|
|
interface.logo,
|
|
Some(AbsolutePathBuf::try_from(
|
|
plugin_root.join("assets/logo.png")
|
|
)?)
|
|
);
|
|
assert_eq!(
|
|
interface.screenshots,
|
|
vec![
|
|
AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot1.png"))?,
|
|
AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot2.png"))?,
|
|
]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/demo-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r##"{
|
|
"name": "demo-plugin",
|
|
"interface": {
|
|
"defaultPrompt": "Starter prompt for trying a plugin"
|
|
}
|
|
}"##,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
let plugin = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| marketplace.plugins.iter())
|
|
.find(|plugin| plugin.name == "demo-plugin")
|
|
.expect("expected demo-plugin entry");
|
|
assert_eq!(
|
|
plugin
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.default_prompt.clone()),
|
|
Some(vec!["Starter prompt for trying a plugin".to_string()])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case(false; "configured globally")]
|
|
#[test_case(true; "configured in later repository")]
|
|
#[tokio::test]
|
|
async fn plugin_list_returns_installed_git_source_interface_from_cache(
|
|
configured_in_later_cwd: bool,
|
|
) -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let missing_remote_repo = repo_root.path().join("missing-remote-plugin-repo");
|
|
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
|
|
.expect("temporary repository path should produce a file URL")
|
|
.to_string();
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
format!(
|
|
r#"{{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{{
|
|
"name": "toolkit",
|
|
"source": {{
|
|
"source": "git-subdir",
|
|
"url": "{missing_remote_repo_url}",
|
|
"path": "plugins/toolkit"
|
|
}},
|
|
"category": "Developer Tools"
|
|
}}
|
|
]
|
|
}}"#
|
|
),
|
|
)?;
|
|
let cached_plugin_root = codex_home.path().join("plugins/cache/debug/toolkit/local");
|
|
std::fs::create_dir_all(cached_plugin_root.join(".codex-plugin"))?;
|
|
std::fs::write(
|
|
cached_plugin_root.join(".codex-plugin/plugin.json"),
|
|
r##"{
|
|
"name": "toolkit",
|
|
"interface": {
|
|
"displayName": "Toolkit",
|
|
"shortDescription": "Search cached data",
|
|
"category": "Cached Category",
|
|
"brandColor": "#3B82F6",
|
|
"composerIcon": "./assets/icon.png",
|
|
"logo": "./assets/logo.png"
|
|
}
|
|
}"##,
|
|
)?;
|
|
let plugin_config = r#"[plugins."toolkit@debug"]
|
|
enabled = true
|
|
"#;
|
|
let user_plugin_config = if configured_in_later_cwd {
|
|
""
|
|
} else {
|
|
plugin_config
|
|
};
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!("[features]\nplugins = true\n\n{user_plugin_config}"),
|
|
)?;
|
|
let later_repo = TempDir::new()?;
|
|
let mut cwds = vec![AbsolutePathBuf::try_from(repo_root.path())?];
|
|
if configured_in_later_cwd {
|
|
for directory in [".git", ".codex", ".agents/plugins"] {
|
|
std::fs::create_dir_all(later_repo.path().join(directory))?;
|
|
}
|
|
std::fs::copy(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
later_repo.path().join(".agents/plugins/marketplace.json"),
|
|
)?;
|
|
std::fs::write(later_repo.path().join(".codex/config.toml"), plugin_config)?;
|
|
set_project_trust_level(codex_home.path(), later_repo.path(), TrustLevel::Trusted)?;
|
|
cwds.push(AbsolutePathBuf::try_from(later_repo.path())?);
|
|
}
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(cwds),
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
let plugin = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| marketplace.plugins.iter())
|
|
.find(|plugin| plugin.name == "toolkit")
|
|
.expect("expected toolkit entry");
|
|
|
|
assert_eq!(plugin.id, "toolkit@debug");
|
|
assert_eq!(plugin.installed, true);
|
|
assert_eq!(plugin.enabled, true);
|
|
assert_eq!(
|
|
plugin.source,
|
|
PluginSource::Git {
|
|
url: missing_remote_repo_url,
|
|
path: Some("plugins/toolkit".to_string()),
|
|
ref_name: None,
|
|
sha: None,
|
|
}
|
|
);
|
|
let interface = plugin
|
|
.interface
|
|
.as_ref()
|
|
.expect("expected cached plugin interface");
|
|
assert_eq!(interface.display_name.as_deref(), Some("Toolkit"));
|
|
assert_eq!(
|
|
interface.short_description.as_deref(),
|
|
Some("Search cached data")
|
|
);
|
|
assert_eq!(interface.category.as_deref(), Some("Developer Tools"));
|
|
assert_eq!(interface.brand_color.as_deref(), Some("#3B82F6"));
|
|
let canonical_cached_plugin_root = std::fs::canonicalize(&cached_plugin_root)?;
|
|
assert_eq!(
|
|
interface.composer_icon,
|
|
Some(AbsolutePathBuf::try_from(
|
|
canonical_cached_plugin_root.join("assets/icon.png")
|
|
)?)
|
|
);
|
|
assert_eq!(
|
|
interface.logo,
|
|
Some(AbsolutePathBuf::try_from(
|
|
canonical_cached_plugin_root.join("assets/logo.png")
|
|
)?)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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 bundle_url = mount_remote_plugin_bundle(
|
|
&server,
|
|
"linear",
|
|
remote_plugin_bundle_tar_gz_bytes("linear", /*hooks_json*/ None)?,
|
|
)
|
|
.await;
|
|
let remote_app_manifest = serde_json::json!({
|
|
"apps": {
|
|
"linear-remote": {
|
|
"id": "remote-linear-app"
|
|
}
|
|
}
|
|
});
|
|
let global_installed_body = remote_installed_plugin_body_with_app_manifest(
|
|
&bundle_url,
|
|
"1.2.3",
|
|
/*enabled*/ true,
|
|
remote_app_manifest.clone(),
|
|
);
|
|
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let installed_path = codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear/1.2.3");
|
|
let _mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_plugin_startup_tasks()
|
|
.with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
wait_for_path_exists(&installed_path.join(".codex-plugin/plugin.json")).await?;
|
|
let installed_plugin_manifest: serde_json::Value = serde_json::from_str(
|
|
&std::fs::read_to_string(installed_path.join(".codex-plugin/plugin.json"))?,
|
|
)?;
|
|
assert_eq!(
|
|
installed_plugin_manifest["version"],
|
|
serde_json::json!("1.2.3")
|
|
);
|
|
let installed_app_manifest: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?;
|
|
assert_eq!(installed_app_manifest, remote_app_manifest);
|
|
assert!(installed_path.join("skills/plan-work/SKILL.md").is_file());
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(!config.contains("linear@openai-curated-remote"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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,
|
|
)?;
|
|
write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "linear", "1.0.0")?;
|
|
write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "stale", "1.0.0")?;
|
|
|
|
let bundle_url = mount_remote_plugin_bundle(
|
|
&server,
|
|
"linear",
|
|
remote_plugin_bundle_tar_gz_bytes("linear", /*hooks_json*/ None)?,
|
|
)
|
|
.await;
|
|
let remote_app_manifest = serde_json::json!({
|
|
"apps": {
|
|
"linear-remote": {
|
|
"id": "remote-linear-app"
|
|
}
|
|
}
|
|
});
|
|
let global_installed_body = remote_installed_plugin_body_with_app_manifest(
|
|
&bundle_url,
|
|
"1.2.3",
|
|
/*enabled*/ true,
|
|
remote_app_manifest.clone(),
|
|
);
|
|
mount_remote_plugin_list(&server, "GLOBAL", &global_installed_body).await;
|
|
mount_remote_plugin_list(&server, "WORKSPACE", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let old_path = codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear/1.0.0");
|
|
let new_path = codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear/1.2.3");
|
|
let stale_path = codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/stale");
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected openai-curated-remote marketplace entry");
|
|
assert_eq!(
|
|
remote_marketplace
|
|
.plugins
|
|
.into_iter()
|
|
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![("linear@openai-curated-remote".to_string(), true, true)]
|
|
);
|
|
|
|
wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?;
|
|
let installed_plugin_manifest: serde_json::Value = serde_json::from_str(
|
|
&std::fs::read_to_string(new_path.join(".codex-plugin/plugin.json"))?,
|
|
)?;
|
|
assert_eq!(
|
|
installed_plugin_manifest["version"],
|
|
serde_json::json!("1.2.3")
|
|
);
|
|
let installed_app_manifest: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(new_path.join(".app.json"))?)?;
|
|
assert_eq!(installed_app_manifest, remote_app_manifest);
|
|
wait_for_path_missing(&old_path).await?;
|
|
wait_for_path_missing(&stale_path).await?;
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(!config.contains("linear@openai-curated-remote"));
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum ProjectPluginConfiguration {
|
|
None,
|
|
Disabled,
|
|
Invalid,
|
|
}
|
|
|
|
#[test_case(ProjectPluginConfiguration::None, None, "", true, true; "without project override")]
|
|
#[test_case(ProjectPluginConfiguration::Disabled, None, "", true, true; "project disables local plugins")]
|
|
#[test_case(ProjectPluginConfiguration::Invalid, None, "", true, true; "invalid project preserves remote plugins")]
|
|
#[test_case(ProjectPluginConfiguration::None, Some("tpp"), "", true, true; "configured product sku")]
|
|
#[test_case(ProjectPluginConfiguration::None, None, "[plugins._default]\nenabled = false", true, false; "default disabled")]
|
|
#[test_case(ProjectPluginConfiguration::None, None, "[plugins._default]\nenabled = false\n[plugins.\"linear@openai-curated-remote\"]\nenabled = true", true, true; "explicitly enabled")]
|
|
#[test_case(ProjectPluginConfiguration::None, None, "[plugins._default]\nenabled = false\n[plugins.\"linear@openai-curated-remote\"]\nenabled = true", false, false; "source disabled")]
|
|
#[tokio::test]
|
|
async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled(
|
|
project_configuration: ProjectPluginConfiguration,
|
|
product_sku: Option<&str>,
|
|
plugin_config: &str,
|
|
source_enabled: bool,
|
|
expected_enabled: bool,
|
|
) -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
let config_path = codex_home.path().join("config.toml");
|
|
let config = std::fs::read_to_string(&config_path)?;
|
|
std::fs::write(config_path, format!("{config}\n{plugin_config}\n"))?;
|
|
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,
|
|
)?;
|
|
write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "linear", "1.2.3")?;
|
|
|
|
let repo = TempDir::new()?;
|
|
let cwds = if project_configuration != ProjectPluginConfiguration::None {
|
|
for directory in [".git", ".codex", ".agents/plugins"] {
|
|
std::fs::create_dir_all(repo.path().join(directory))?;
|
|
}
|
|
let config = if project_configuration == ProjectPluginConfiguration::Invalid {
|
|
"invalid = [\n"
|
|
} else {
|
|
"[features]\nplugins = false\n[plugins.\"sample@disabled-local\"]\nenabled = true\n"
|
|
};
|
|
std::fs::write(repo.path().join(".codex/config.toml"), config)?;
|
|
std::fs::write(
|
|
repo.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{"name":"disabled-local","plugins":[{"name":"sample","source":{"source":"local","path":"./sample"}}]}"#,
|
|
)?;
|
|
set_project_trust_level(codex_home.path(), repo.path(), TrustLevel::Trusted)?;
|
|
Some(vec![AbsolutePathBuf::try_from(repo.path())?])
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let global_directory_body = r#"{
|
|
"plugins": [
|
|
{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"installation_policy_source": "IMPLICIT_CANONICAL_APP",
|
|
"must_show_installation_interstitial": true,
|
|
"authentication_policy": "ON_USE",
|
|
"status": "ENABLED",
|
|
"release": {
|
|
"version": "1.2.3",
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
"keywords": ["issue-tracking", "project management"],
|
|
"interface": {
|
|
"short_description": "Plan and track work",
|
|
"capabilities": ["Read", "Write"],
|
|
"default_prompt": "Use the legacy Linear prompt",
|
|
"default_prompts": ["Create a Linear issue", "Review my Linear projects"],
|
|
"logo_url": "https://example.com/linear.png",
|
|
"screenshot_urls": ["https://example.com/linear-shot.png"]
|
|
},
|
|
"skills": []
|
|
}
|
|
}
|
|
],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
let empty_page_body = r#"{
|
|
"plugins": [],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
let global_installed_body = r#"{
|
|
"plugins": [
|
|
{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"installation_policy_source": "WORKSPACE_SETTING",
|
|
"installed_at": "2026-01-02T00:00:00Z",
|
|
"must_show_installation_interstitial": false,
|
|
"authentication_policy": "ON_USE",
|
|
"status": "ENABLED",
|
|
"release": {
|
|
"version": "1.2.3",
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
"interface": {
|
|
"short_description": "Plan and track work",
|
|
"capabilities": ["Read", "Write"],
|
|
"logo_url": "https://example.com/linear.png",
|
|
"screenshot_urls": ["https://example.com/linear-shot.png"]
|
|
},
|
|
"skills": []
|
|
},
|
|
"enabled": true,
|
|
"disabled_skill_names": []
|
|
}
|
|
],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
|
|
let mut global_installed_body: serde_json::Value = serde_json::from_str(global_installed_body)?;
|
|
global_installed_body["plugins"][0]["enabled"] = serde_json::json!(source_enabled);
|
|
let expected_product_sku = product_sku.unwrap_or("codex");
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.and(header("oai-product-sku", expected_product_sku))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(global_directory_body))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", "WORKSPACE"))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.and(header("oai-product-sku", expected_product_sku))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.and(header("oai-product-sku", expected_product_sku))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(global_installed_body))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param("scope", "WORKSPACE"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.and(header("oai-product-sku", expected_product_sku))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/plugins/featured"))
|
|
.and(query_param("platform", "codex"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated-remote"]"#),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut builder = TestAppServer::builder().with_codex_home(codex_home.path());
|
|
if let Some(product_sku) = product_sku {
|
|
builder = builder.with_args(&["-c", &format!("apps_mcp_product_sku=\"{product_sku}\"")]);
|
|
}
|
|
let mut mcp = builder
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
if project_configuration == ProjectPluginConfiguration::Invalid {
|
|
assert_eq!(response.marketplace_load_errors.len(), 1);
|
|
assert_eq!(
|
|
response.marketplace_load_errors[0].marketplace_path,
|
|
AbsolutePathBuf::try_from(repo.path())?
|
|
);
|
|
} else {
|
|
assert!(response.marketplace_load_errors.is_empty());
|
|
}
|
|
assert!(
|
|
!response
|
|
.marketplaces
|
|
.iter()
|
|
.any(|marketplace| marketplace.name == "disabled-local")
|
|
);
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected openai-curated remote marketplace");
|
|
assert_eq!(remote_marketplace.path, None);
|
|
assert_eq!(
|
|
remote_marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("OpenAI Curated Remote")
|
|
);
|
|
assert_eq!(remote_marketplace.plugins.len(), 1);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"linear@openai-curated-remote"
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_00000000000000000000000000000000")
|
|
);
|
|
assert_eq!(remote_marketplace.plugins[0].name, "linear");
|
|
assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].version.as_deref(),
|
|
Some("1.2.3")
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].local_version.as_deref(),
|
|
Some("1.2.3")
|
|
);
|
|
assert_eq!(remote_marketplace.plugins[0].installed, true);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].installed_at,
|
|
Some(1_767_312_000)
|
|
);
|
|
assert_eq!(remote_marketplace.plugins[0].enabled, expected_enabled);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].install_policy_source,
|
|
Some(PluginInstallPolicySource::ImplicitCanonicalApp)
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].must_show_installation_interstitial,
|
|
Some(true)
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].availability,
|
|
codex_app_server_protocol::PluginAvailability::Available
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0]
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Linear")
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0]
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.default_prompt.clone()),
|
|
Some(vec![
|
|
"Create a Linear issue".to_string(),
|
|
"Review my Linear projects".to_string(),
|
|
])
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].keywords,
|
|
vec![
|
|
"issue-tracking".to_string(),
|
|
"project management".to_string()
|
|
]
|
|
);
|
|
let cache_files = std::fs::read_dir(codex_home.path().join("cache/remote_plugin_catalog"))?
|
|
.map(|entry| entry.map(|entry| entry.path()))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
assert_eq!(cache_files.len(), 1);
|
|
let cached_catalog: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&cache_files[0])?)?;
|
|
assert_eq!(cached_catalog["schema_version"], serde_json::json!(1));
|
|
assert!(cached_catalog["fetched_at"].as_str().is_some());
|
|
assert_eq!(
|
|
cached_catalog["plugins"][0]["installation_policy_source"],
|
|
serde_json::json!("IMPLICIT_CANONICAL_APP")
|
|
);
|
|
assert_eq!(
|
|
cached_catalog["plugins"][0]["must_show_installation_interstitial"],
|
|
serde_json::json!(true)
|
|
);
|
|
assert_eq!(
|
|
cached_catalog["plugins"][0]["release"]["interface"]["default_prompts"],
|
|
serde_json::json!(["Create a Linear issue", "Review my Linear projects"])
|
|
);
|
|
let cached_plugin_ids = cached_catalog["plugins"]
|
|
.as_array()
|
|
.expect("cached plugins should be an array")
|
|
.iter()
|
|
.map(|plugin| plugin["id"].as_str().expect("cached plugin id").to_string())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
cached_plugin_ids,
|
|
vec!["plugins~Plugin_00000000000000000000000000000000".to_string()]
|
|
);
|
|
assert_eq!(
|
|
response.featured_plugin_ids,
|
|
vec!["linear@openai-curated-remote".to_string()]
|
|
);
|
|
assert!(
|
|
!server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests")
|
|
.iter()
|
|
.any(|request| request
|
|
.url
|
|
.query_pairs()
|
|
.any(|(name, value)| name == "collection" && value == "vertical"))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_honors_global_remote_catalog_cache_ttl() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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 cached_remote_plugin_id = "plugins~Plugin_00000000000000000000000000000000";
|
|
let refreshed_remote_plugin_id = "plugins~Plugin_11111111111111111111111111111111";
|
|
let cached_body =
|
|
remote_plugin_list_body(cached_remote_plugin_id, "linear", "Linear", "Plan work");
|
|
let refreshed_body = remote_plugin_list_body(
|
|
refreshed_remote_plugin_id,
|
|
"notion",
|
|
"Notion",
|
|
"Capture notes",
|
|
);
|
|
mount_remote_plugin_list(&server, "GLOBAL", &cached_body).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected warmed remote marketplace");
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"linear@openai-curated-remote"
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].must_show_installation_interstitial,
|
|
None
|
|
);
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
|
|
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[cached_remote_plugin_id])
|
|
.await?;
|
|
|
|
server.reset().await;
|
|
mount_remote_plugin_list(&server, "GLOBAL", &refreshed_body).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected cached remote marketplace");
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"linear@openai-curated-remote"
|
|
);
|
|
sleep(Duration::from_millis(100)).await;
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
|
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[cached_remote_plugin_id])
|
|
.await?;
|
|
|
|
rewrite_cached_remote_catalog_fetched_at(
|
|
codex_home.path(),
|
|
Utc::now() - ChronoDuration::hours(4),
|
|
)?;
|
|
server.reset().await;
|
|
mount_delayed_remote_plugin_list(&server, "GLOBAL", &refreshed_body).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected stale cached remote marketplace");
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"linear@openai-curated-remote"
|
|
);
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected stale cached remote marketplace");
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"linear@openai-curated-remote"
|
|
);
|
|
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
|
|
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[refreshed_remote_plugin_id])
|
|
.await?;
|
|
sleep(Duration::from_millis(100)).await;
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_server_startup_refreshes_cached_remote_catalog_without_blocking_plugin_list()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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 cached_remote_plugin_id = "plugins~Plugin_00000000000000000000000000000000";
|
|
let refreshed_remote_plugin_id = "plugins~Plugin_11111111111111111111111111111111";
|
|
mount_remote_plugin_list(
|
|
&server,
|
|
"GLOBAL",
|
|
&remote_plugin_list_body(cached_remote_plugin_id, "linear", "Linear", "Plan work"),
|
|
)
|
|
.await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build()
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??;
|
|
let request_id = app_server
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let _: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[cached_remote_plugin_id])
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, app_server.shutdown_gracefully()).await??;
|
|
|
|
server.reset().await;
|
|
let refreshed_body = remote_plugin_list_body(
|
|
refreshed_remote_plugin_id,
|
|
"notion",
|
|
"Notion",
|
|
"Capture notes",
|
|
);
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_string(refreshed_body)
|
|
.set_delay(Duration::from_secs(/*secs*/ 2)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_plugin_startup_tasks()
|
|
.build()
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??;
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
|
|
|
|
let request_id = app_server
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected cached remote marketplace");
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"linear@openai-curated-remote"
|
|
);
|
|
|
|
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[refreshed_remote_plugin_id])
|
|
.await?;
|
|
let request_id = app_server
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected refreshed remote marketplace");
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"notion@openai-curated-remote"
|
|
);
|
|
sleep(Duration::from_millis(100)).await;
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_server_startup_skips_disabled_remote_plugin_catalog_scopes() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
let base_url = format!("{}/backend-api/", server.uri());
|
|
write_remote_plugin_catalog_config(codex_home.path(), &base_url)?;
|
|
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 global_plugin_id = "plugins~Plugin_00000000000000000000000000000000";
|
|
let user_plugin_id = "plugins~Plugin_11111111111111111111111111111111";
|
|
let workspace_plugin_id = "plugins~Plugin_22222222222222222222222222222222";
|
|
let global_body = remote_plugin_list_body(global_plugin_id, "global-linear", "Linear", "Plan");
|
|
let user_body = user_remote_plugin_page_body(
|
|
user_plugin_id,
|
|
"private-linear",
|
|
"Private Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ None,
|
|
);
|
|
let workspace_body = workspace_remote_plugin_page_body(
|
|
workspace_plugin_id,
|
|
"workspace-linear",
|
|
"Workspace Linear",
|
|
"LISTED",
|
|
/*enabled*/ None,
|
|
);
|
|
mount_remote_plugin_list(&server, "GLOBAL", &global_body).await;
|
|
mount_remote_plugin_list(&server, "USER", &user_body).await;
|
|
mount_remote_plugin_list(&server, "WORKSPACE", &workspace_body).await;
|
|
for scope in ["GLOBAL", "USER", "WORKSPACE"] {
|
|
mount_remote_installed_plugins(&server, scope, empty_remote_installed_plugins_body()).await;
|
|
}
|
|
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build()
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??;
|
|
for marketplace_kinds in [
|
|
None,
|
|
Some(vec![
|
|
PluginListMarketplaceKind::CreatedByMeRemote,
|
|
PluginListMarketplaceKind::WorkspaceDirectory,
|
|
]),
|
|
] {
|
|
let request_id = app_server
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let _: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
}
|
|
wait_for_cached_remote_catalog_plugin_ids(
|
|
codex_home.path(),
|
|
&[global_plugin_id, user_plugin_id, workspace_plugin_id],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, app_server.shutdown_gracefully()).await??;
|
|
|
|
write_remote_plugins_disabled_config_with_base_url(codex_home.path(), &base_url)?;
|
|
server.reset().await;
|
|
mount_remote_plugin_list(&server, "GLOBAL", &global_body).await;
|
|
mount_remote_plugin_list(&server, "USER", &user_body).await;
|
|
mount_remote_plugin_list(&server, "WORKSPACE", &workspace_body).await;
|
|
for scope in ["GLOBAL", "USER", "WORKSPACE"] {
|
|
mount_remote_installed_plugins(&server, scope, empty_remote_installed_plugins_body()).await;
|
|
}
|
|
|
|
let mut app_server = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_plugin_startup_tasks()
|
|
.build()
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??;
|
|
wait_for_remote_plugin_list_scope_request_count(
|
|
&server,
|
|
"WORKSPACE",
|
|
/*expected_count*/ 1,
|
|
)
|
|
.await?;
|
|
|
|
let requested_scopes = server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests")
|
|
.into_iter()
|
|
.filter(|request| {
|
|
request.method == "GET" && request.url.path().ends_with("/ps/plugins/list")
|
|
})
|
|
.filter_map(|request| {
|
|
request
|
|
.url
|
|
.query_pairs()
|
|
.find(|(name, _)| name == "scope")
|
|
.map(|(_, scope)| scope.into_owned())
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(requested_scopes, vec!["WORKSPACE".to_string()]);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_force_refetch_bypasses_fresh_global_remote_catalog_cache() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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 cached_remote_plugin_id = "plugins~Plugin_00000000000000000000000000000000";
|
|
let refreshed_remote_plugin_id = "plugins~Plugin_11111111111111111111111111111111";
|
|
mount_remote_plugin_list(
|
|
&server,
|
|
"GLOBAL",
|
|
&remote_plugin_list_body(cached_remote_plugin_id, "linear", "Linear", "Plan work"),
|
|
)
|
|
.await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build()
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let _: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[cached_remote_plugin_id])
|
|
.await?;
|
|
|
|
server.reset().await;
|
|
mount_delayed_remote_plugin_list(
|
|
&server,
|
|
"GLOBAL",
|
|
&remote_plugin_list_body(
|
|
refreshed_remote_plugin_id,
|
|
"notion",
|
|
"Notion",
|
|
"Capture notes",
|
|
),
|
|
)
|
|
.await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: true,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected refreshed remote marketplace");
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"notion@openai-curated-remote"
|
|
);
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
|
|
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[refreshed_remote_plugin_id])
|
|
.await?;
|
|
|
|
server.reset().await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected cached refreshed remote marketplace");
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"notion@openai-curated-remote"
|
|
);
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_includes_openai_curated_remote_collection_when_remote_plugin_disabled_and_requested()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugins_disabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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 collection_body = r#"{
|
|
"plugins": [
|
|
{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"status": "ENABLED",
|
|
"release": {
|
|
"version": "1.2.3",
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
"interface": {
|
|
"short_description": "Plan and track work",
|
|
"capabilities": ["Read", "Write"]
|
|
},
|
|
"skills": []
|
|
}
|
|
}
|
|
],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
mount_openai_curated_remote_collection_plugin_list(&server, collection_body).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected openai-curated remote marketplace");
|
|
assert_eq!(remote_marketplace.path, None);
|
|
assert_eq!(
|
|
remote_marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("OpenAI Curated Remote")
|
|
);
|
|
assert_eq!(remote_marketplace.plugins.len(), 1);
|
|
let plugin = &remote_marketplace.plugins[0];
|
|
assert_eq!(plugin.id, "linear@openai-curated-remote");
|
|
assert_eq!(
|
|
plugin.remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_00000000000000000000000000000000")
|
|
);
|
|
assert_eq!(plugin.name, "linear");
|
|
assert_eq!(plugin.source, PluginSource::Remote);
|
|
assert_eq!(plugin.version.as_deref(), Some("1.2.3"));
|
|
assert_eq!(plugin.installed, false);
|
|
assert_eq!(plugin.enabled, false);
|
|
|
|
let requests = server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests");
|
|
assert!(requests.iter().any(|request| {
|
|
request.method == "GET"
|
|
&& request.url.path().ends_with("/ps/plugins/list")
|
|
&& request
|
|
.url
|
|
.query_pairs()
|
|
.any(|(name, value)| name == "collection" && value == "vertical")
|
|
}));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_propagates_openai_curated_remote_collection_errors_when_remote_plugin_disabled()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugins_disabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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,
|
|
)?;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.and(query_param("limit", "200"))
|
|
.and(query_param("collection", "vertical"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(500).set_body_string("temporary failure"))
|
|
.mount(&server)
|
|
.await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32603);
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("list OpenAI Curated remote plugin catalog")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_skips_openai_curated_remote_collection_for_api_auth_when_remote_plugin_disabled()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugins_disabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
login_with_api_key(
|
|
codex_home.path(),
|
|
"sk-test-key",
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert!(response.marketplaces.is_empty());
|
|
assert!(response.marketplace_load_errors.is_empty());
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_includes_api_curated_marketplace_for_api_auth_when_remote_plugin_enabled()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_openai_api_curated_marketplace(codex_home.path(), &["api-plugin"])?;
|
|
login_with_api_key(
|
|
codex_home.path(),
|
|
"sk-test-key",
|
|
AuthCredentialsStoreMode::File,
|
|
AuthKeyringBackendKind::default(),
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
let api_curated_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-api-curated")
|
|
.expect("expected API curated marketplace");
|
|
assert_eq!(
|
|
api_curated_marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("OpenAI Curated")
|
|
);
|
|
assert_eq!(api_curated_marketplace.plugins.len(), 1);
|
|
assert_eq!(
|
|
api_curated_marketplace.plugins[0].id,
|
|
"api-plugin@openai-api-curated"
|
|
);
|
|
assert!(response.marketplace_load_errors.is_empty());
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_includes_api_curated_marketplace_for_bedrock_without_codex_auth() -> Result<()>
|
|
{
|
|
let codex_home = TempDir::new()?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"model_provider = "amazon-bedrock"
|
|
|
|
[model_providers.amazon-bedrock.aws]
|
|
region = "us-east-2"
|
|
profile = "default"
|
|
|
|
[features]
|
|
plugins = true
|
|
"#,
|
|
)?;
|
|
write_openai_curated_marketplace(codex_home.path(), &["chatgpt-plugin"])?;
|
|
write_openai_api_curated_marketplace(codex_home.path(), &["api-plugin"])?;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert!(!codex_home.path().join("auth.json").exists());
|
|
let api_curated_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-api-curated")
|
|
.expect("expected API curated marketplace");
|
|
assert_eq!(api_curated_marketplace.plugins.len(), 1);
|
|
assert_eq!(
|
|
api_curated_marketplace.plugins[0].id,
|
|
"api-plugin@openai-api-curated"
|
|
);
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| marketplace.name != "openai-curated")
|
|
);
|
|
assert!(response.marketplace_load_errors.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_includes_chatgpt_curated_marketplace_for_bedrock_with_chatgpt_auth()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"model_provider = "amazon-bedrock"
|
|
|
|
[model_providers.amazon-bedrock.aws]
|
|
region = "us-east-2"
|
|
profile = "default"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = false
|
|
"#,
|
|
)?;
|
|
write_openai_curated_marketplace(codex_home.path(), &["chatgpt-plugin"])?;
|
|
write_openai_api_curated_marketplace(codex_home.path(), &["api-plugin"])?;
|
|
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 mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
let chatgpt_curated_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated")
|
|
.expect("expected ChatGPT curated marketplace");
|
|
assert_eq!(chatgpt_curated_marketplace.plugins.len(), 1);
|
|
assert_eq!(
|
|
chatgpt_curated_marketplace.plugins[0].id,
|
|
"chatgpt-plugin@openai-curated"
|
|
);
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| marketplace.name != "openai-api-curated")
|
|
);
|
|
assert!(response.marketplace_load_errors.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_plugins_enabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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 mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| marketplace.name != "openai-curated-remote")
|
|
);
|
|
assert!(
|
|
server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests")
|
|
.iter()
|
|
.all(|request| !request
|
|
.url
|
|
.query_pairs()
|
|
.any(|(name, value)| name == "collection" && value == "vertical"))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_vertical_kind_noops_when_remote_plugin_enabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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 mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| marketplace.name != "openai-curated-remote")
|
|
);
|
|
assert!(
|
|
server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests")
|
|
.iter()
|
|
.all(|request| !request
|
|
.url
|
|
.query_pairs()
|
|
.any(|(name, value)| name == "collection" && value == "vertical"))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_explicit()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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 mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| marketplace.name != "openai-curated-remote")
|
|
);
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_includes_remote_shared_with_me_plugins_when_remote_plugin_disabled()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = false
|
|
plugin_sharing = true
|
|
"#,
|
|
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 mut workspace_installed_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_22222222222222222222222222222222",
|
|
"shared-linear",
|
|
"Shared Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ Some(true),
|
|
))?;
|
|
let unlisted_installed_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_33333333333333333333333333333333",
|
|
"unlisted-linear",
|
|
"Unlisted Linear",
|
|
"UNLISTED",
|
|
/*enabled*/ Some(false),
|
|
))?;
|
|
workspace_installed_body["plugins"]
|
|
.as_array_mut()
|
|
.expect("installed plugins should be an array")
|
|
.push(unlisted_installed_body["plugins"][0].clone());
|
|
let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?;
|
|
let global_installed_body = remote_installed_plugin_body("", "1.2.3", /*enabled*/ true);
|
|
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(response.marketplaces.len(), 1);
|
|
let marketplace = &response.marketplaces[0];
|
|
assert_eq!(marketplace.name, "workspace-shared-with-me");
|
|
assert_eq!(
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Shared with me")
|
|
);
|
|
assert_eq!(
|
|
marketplace
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| {
|
|
(
|
|
plugin.id.clone(),
|
|
plugin.version.clone(),
|
|
plugin.installed,
|
|
plugin.enabled,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(
|
|
"shared-linear@workspace-shared-with-me".to_string(),
|
|
Some("1.2.3".to_string()),
|
|
true,
|
|
true
|
|
),
|
|
(
|
|
"unlisted-linear@workspace-shared-with-me".to_string(),
|
|
Some("1.2.3".to_string()),
|
|
true,
|
|
false
|
|
)
|
|
]
|
|
);
|
|
wait_for_remote_installed_snapshot_request(&server).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_includes_workspace_directory_without_plugin_sharing_when_remote_plugin_disabled()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = false
|
|
plugin_sharing = false
|
|
"#,
|
|
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 mut workspace_installed_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_11111111111111111111111111111111",
|
|
"workspace-linear",
|
|
"Workspace Linear",
|
|
"LISTED",
|
|
/*enabled*/ Some(true),
|
|
))?;
|
|
let shared_installed_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_22222222222222222222222222222222",
|
|
"shared-linear",
|
|
"Shared Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ Some(true),
|
|
))?;
|
|
workspace_installed_body["plugins"]
|
|
.as_array_mut()
|
|
.expect("installed plugins should be an array")
|
|
.push(shared_installed_body["plugins"][0].clone());
|
|
let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(response.marketplaces.len(), 1);
|
|
let marketplace = &response.marketplaces[0];
|
|
assert_eq!(marketplace.name, "workspace-directory");
|
|
assert_eq!(
|
|
marketplace
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![(
|
|
"workspace-linear@workspace-directory".to_string(),
|
|
true,
|
|
true
|
|
)]
|
|
);
|
|
wait_for_remote_installed_snapshot_request(&server).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_includes_created_by_me_when_remote_plugins_enabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
plugin_sharing = false
|
|
"#,
|
|
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,
|
|
)?;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
let bundle_url = mount_remote_plugin_bundle(
|
|
&server,
|
|
"private-linear",
|
|
remote_plugin_bundle_tar_gz_bytes("private-linear", /*hooks_json*/ None)?,
|
|
)
|
|
.await;
|
|
let mut user_installed_body: serde_json::Value =
|
|
serde_json::from_str(&user_remote_plugin_page_body(
|
|
"plugins~Plugin_55555555555555555555555555555555",
|
|
"private-linear",
|
|
"Private Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ Some(true),
|
|
))?;
|
|
user_installed_body["plugins"][0]["release"]["bundle_download_url"] =
|
|
serde_json::json!(bundle_url);
|
|
mount_remote_installed_plugins(
|
|
&server,
|
|
"USER",
|
|
&serde_json::to_string(&user_installed_body)?,
|
|
)
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(response.marketplaces.len(), 1);
|
|
assert_eq!(response.marketplaces[0].name, "created-by-me-remote");
|
|
assert_eq!(
|
|
response.marketplaces[0]
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| (plugin.id.as_str(), plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![("private-linear@created-by-me-remote", true, true)]
|
|
);
|
|
wait_for_path_exists(
|
|
&codex_home.path().join(
|
|
"plugins/cache/created-by-me-remote/private-linear/1.2.3/.codex-plugin/plugin.json",
|
|
),
|
|
)
|
|
.await?;
|
|
wait_for_remote_installed_snapshot_request(&server).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_trusts_new_workspace_listed_plugin_hooks() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
let disabled_hook_key = "available-hooks@workspace-directory:hooks/hooks.json:pre_tool_use:0:0";
|
|
let unrelated_hook_key = "unrelated@test:hooks/hooks.json:session_start:0:0";
|
|
write_remote_plugin_hook_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
&format!(
|
|
r#"
|
|
[hooks.state."{disabled_hook_key}"]
|
|
enabled = false
|
|
|
|
[hooks.state."{unrelated_hook_key}"]
|
|
enabled = false
|
|
trusted_hash = "sha256:unrelated"
|
|
"#,
|
|
),
|
|
)?;
|
|
write_remote_plugin_test_auth(codex_home.path())?;
|
|
|
|
let available_bundle_url = mount_remote_plugin_bundle_with_hooks(
|
|
&server,
|
|
"available-hooks",
|
|
Some(
|
|
r#"{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"echo available"}]}]}}"#,
|
|
),
|
|
)
|
|
.await?;
|
|
let default_bundle_url = mount_remote_plugin_bundle_with_hooks(
|
|
&server,
|
|
"default-hooks",
|
|
Some(
|
|
r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo default"}]}]}}"#,
|
|
),
|
|
)
|
|
.await?;
|
|
let no_hooks_bundle_url =
|
|
mount_remote_plugin_bundle_with_hooks(&server, "no-hooks", /*hooks_json*/ None).await?;
|
|
mount_workspace_bundle_sync(
|
|
&server,
|
|
&[
|
|
("available-hooks", "AVAILABLE", &available_bundle_url),
|
|
("default-hooks", "INSTALLED_BY_DEFAULT", &default_bundle_url),
|
|
("no-hooks", "AVAILABLE", &no_hooks_bundle_url),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
trigger_plugin_installed_sync(&mut mcp).await?;
|
|
let plugin_ids = [
|
|
"available-hooks@workspace-directory",
|
|
"default-hooks@workspace-directory",
|
|
];
|
|
let hooks = wait_for_plugin_hooks(
|
|
&mut mcp,
|
|
codex_home.path(),
|
|
&plugin_ids,
|
|
HookTrustStatus::Trusted,
|
|
)
|
|
.await?;
|
|
for plugin_id in plugin_ids {
|
|
assert!(
|
|
hooks
|
|
.iter()
|
|
.any(|hook| hook.plugin_id.as_deref() == Some(plugin_id))
|
|
);
|
|
}
|
|
wait_for_path_exists(
|
|
&codex_home
|
|
.path()
|
|
.join("plugins/cache/workspace-directory/no-hooks/1.2.3/.codex-plugin/plugin.json"),
|
|
)
|
|
.await?;
|
|
let config: toml::Value = toml::from_str(&std::fs::read_to_string(
|
|
codex_home.path().join("config.toml"),
|
|
)?)?;
|
|
let hook_states = config["hooks"]["state"].as_table().expect("hook states");
|
|
for hook in &hooks {
|
|
assert_eq!(
|
|
hook_states[hook.key.as_str()]["trusted_hash"].as_str(),
|
|
Some(hook.current_hash.as_str())
|
|
);
|
|
}
|
|
assert!(
|
|
!hooks
|
|
.iter()
|
|
.find(|hook| hook.key == disabled_hook_key)
|
|
.expect("disabled hook")
|
|
.enabled
|
|
);
|
|
assert_eq!(
|
|
hook_states[disabled_hook_key]["enabled"].as_bool(),
|
|
Some(false)
|
|
);
|
|
assert_eq!(
|
|
hook_states[unrelated_hook_key]["trusted_hash"].as_str(),
|
|
Some("sha256:unrelated")
|
|
);
|
|
assert_eq!(
|
|
hook_states[unrelated_hook_key]["enabled"].as_bool(),
|
|
Some(false)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn plugin_installed_hook_trust_write_failure_stays_untrusted() -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::os::unix::fs::symlink;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
let config_target_dir = TempDir::new()?;
|
|
let config_target = config_target_dir.path().join("config.toml");
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_hook_config(
|
|
config_target_dir.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
"",
|
|
)?;
|
|
symlink(&config_target, codex_home.path().join("config.toml"))?;
|
|
write_remote_plugin_test_auth(codex_home.path())?;
|
|
|
|
let bundle_url = mount_remote_plugin_bundle_with_hooks(
|
|
&server,
|
|
"failed-trust",
|
|
Some(
|
|
r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo fail closed"}]}]}}"#,
|
|
),
|
|
)
|
|
.await?;
|
|
mount_workspace_bundle_sync(&server, &[("failed-trust", "AVAILABLE", &bundle_url)]).await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let original_permissions = std::fs::metadata(config_target_dir.path())?.permissions();
|
|
let _permission_guard = RestorePermissions(
|
|
config_target_dir.path().to_path_buf(),
|
|
original_permissions.clone(),
|
|
);
|
|
let mut read_only_permissions = original_permissions;
|
|
read_only_permissions.set_mode(read_only_permissions.mode() & !0o222);
|
|
std::fs::set_permissions(config_target_dir.path(), read_only_permissions)?;
|
|
|
|
trigger_plugin_installed_sync(&mut mcp).await?;
|
|
let plugin_ids = ["failed-trust@workspace-directory"];
|
|
let before = wait_for_plugin_hooks(
|
|
&mut mcp,
|
|
codex_home.path(),
|
|
&plugin_ids,
|
|
HookTrustStatus::Untrusted,
|
|
)
|
|
.await?;
|
|
sleep(Duration::from_millis(300)).await;
|
|
let after = wait_for_plugin_hooks(
|
|
&mut mcp,
|
|
codex_home.path(),
|
|
&plugin_ids,
|
|
HookTrustStatus::Untrusted,
|
|
)
|
|
.await?;
|
|
|
|
assert_eq!(after[0].current_hash, before[0].current_hash);
|
|
assert!(!std::fs::read_to_string(config_target)?.contains("trusted_hash"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_fetches_workspace_directory_kind_when_remote_plugin_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugins_disabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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 workspace_plugin_body = workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_11111111111111111111111111111111",
|
|
"workspace-linear",
|
|
"Workspace Linear",
|
|
"LISTED",
|
|
/*enabled*/ None,
|
|
);
|
|
let workspace_installed_body = workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_11111111111111111111111111111111",
|
|
"workspace-linear",
|
|
"Workspace Linear",
|
|
"LISTED",
|
|
/*enabled*/ Some(false),
|
|
);
|
|
let refreshed_workspace_plugin_body = workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_22222222222222222222222222222222",
|
|
"workspace-notion",
|
|
"Workspace Notion",
|
|
"LISTED",
|
|
/*enabled*/ None,
|
|
);
|
|
mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(response.marketplaces.len(), 1);
|
|
let marketplace = &response.marketplaces[0];
|
|
assert_eq!(marketplace.name, "workspace-directory");
|
|
assert_eq!(
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Workspace Directory")
|
|
);
|
|
assert_eq!(marketplace.plugins.len(), 1);
|
|
assert_eq!(
|
|
marketplace.plugins[0].id,
|
|
"workspace-linear@workspace-directory"
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[0].remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_11111111111111111111111111111111")
|
|
);
|
|
assert_eq!(marketplace.plugins[0].name, "workspace-linear");
|
|
assert_eq!(marketplace.plugins[0].installed, true);
|
|
assert_eq!(marketplace.plugins[0].enabled, false);
|
|
assert!(
|
|
!server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests")
|
|
.iter()
|
|
.any(|request| request
|
|
.url
|
|
.query()
|
|
.is_some_and(|query| query.contains("scope=GLOBAL")))
|
|
);
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
assert_eq!(
|
|
response.marketplaces[0].plugins[0].id,
|
|
"workspace-linear@workspace-directory"
|
|
);
|
|
sleep(Duration::from_millis(100)).await;
|
|
wait_for_remote_plugin_list_scope_request_count(
|
|
&server,
|
|
"WORKSPACE",
|
|
/*expected_count*/ 1,
|
|
)
|
|
.await?;
|
|
|
|
rewrite_cached_remote_catalog_fetched_at(
|
|
codex_home.path(),
|
|
Utc::now() - ChronoDuration::hours(4),
|
|
)?;
|
|
server.reset().await;
|
|
mount_delayed_remote_plugin_list(&server, "WORKSPACE", &refreshed_workspace_plugin_body).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
assert_eq!(
|
|
response.marketplaces[0].plugins[0].id,
|
|
"workspace-linear@workspace-directory"
|
|
);
|
|
|
|
wait_for_remote_plugin_list_scope_request_count(
|
|
&server,
|
|
"WORKSPACE",
|
|
/*expected_count*/ 1,
|
|
)
|
|
.await?;
|
|
wait_for_cached_remote_catalog_plugin_ids(
|
|
codex_home.path(),
|
|
&["plugins~Plugin_22222222222222222222222222222222"],
|
|
)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
assert_eq!(
|
|
response.marketplaces[0].plugins[0].id,
|
|
"workspace-notion@workspace-directory"
|
|
);
|
|
sleep(Duration::from_millis(100)).await;
|
|
wait_for_remote_plugin_list_scope_request_count(
|
|
&server,
|
|
"WORKSPACE",
|
|
/*expected_count*/ 1,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_fetches_user_plugins_in_created_by_me_remote_marketplace() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
plugin_sharing = false
|
|
"#,
|
|
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 mut private_page: serde_json::Value = serde_json::from_str(&user_remote_plugin_page_body(
|
|
"plugins~Plugin_55555555555555555555555555555555",
|
|
"private-linear",
|
|
"Private Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ None,
|
|
))?;
|
|
private_page["pagination"]["next_page_token"] = serde_json::json!("page-2");
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", "USER"))
|
|
.and(query_param("limit", "200"))
|
|
.and(query_param_is_missing("pageToken"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(private_page))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", "USER"))
|
|
.and(query_param("limit", "200"))
|
|
.and(query_param("pageToken", "page-2"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_string(user_remote_plugin_page_body(
|
|
"plugins~Plugin_66666666666666666666666666666666",
|
|
"second-private-linear",
|
|
"Second Private Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ None,
|
|
)),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
mount_remote_installed_plugins(
|
|
&server,
|
|
"USER",
|
|
&user_remote_plugin_page_body(
|
|
"plugins~Plugin_55555555555555555555555555555555",
|
|
"private-linear",
|
|
"Private Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ Some(true),
|
|
),
|
|
)
|
|
.await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::CreatedByMeRemote]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(response.marketplaces.len(), 1);
|
|
let marketplace = &response.marketplaces[0];
|
|
assert_eq!(marketplace.name, "created-by-me-remote");
|
|
assert_eq!(
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Created by me")
|
|
);
|
|
assert_eq!(marketplace.plugins.len(), 2);
|
|
assert_eq!(
|
|
marketplace.plugins[0].id,
|
|
"private-linear@created-by-me-remote"
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[0].remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_55555555555555555555555555555555")
|
|
);
|
|
assert_eq!(marketplace.plugins[0].installed, true);
|
|
assert_eq!(marketplace.plugins[0].enabled, true);
|
|
assert_eq!(marketplace.plugins[0].share_context, None);
|
|
assert_eq!(
|
|
marketplace.plugins[1].id,
|
|
"second-private-linear@created-by-me-remote"
|
|
);
|
|
assert_eq!(marketplace.plugins[1].installed, false);
|
|
assert_eq!(marketplace.plugins[1].enabled, false);
|
|
assert!(
|
|
!server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests")
|
|
.iter()
|
|
.any(|request| {
|
|
request.url.path().ends_with("/ps/plugins/list")
|
|
&& request
|
|
.url
|
|
.query_pairs()
|
|
.any(|(key, value)| key == "scope" && value != "USER")
|
|
})
|
|
);
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::CreatedByMeRemote]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
assert_eq!(response.marketplaces[0].plugins.len(), 2);
|
|
sleep(Duration::from_millis(100)).await;
|
|
wait_for_remote_plugin_list_scope_request_count(&server, "USER", /*expected_count*/ 2).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_plugins_enabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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 mut shared_plugin_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_22222222222222222222222222222222",
|
|
"shared-linear",
|
|
"Shared Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ None,
|
|
))?;
|
|
shared_plugin_body["plugins"][0]["share_principals"] = serde_json::Value::Null;
|
|
let shared_unlisted_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_44444444444444444444444444444444",
|
|
"shared-unlisted-linear",
|
|
"Shared Unlisted Linear",
|
|
"UNLISTED",
|
|
/*enabled*/ None,
|
|
))?;
|
|
shared_plugin_body["plugins"]
|
|
.as_array_mut()
|
|
.expect("shared plugins should be an array")
|
|
.push(shared_unlisted_body["plugins"][0].clone());
|
|
let shared_plugin_body = serde_json::to_string(&shared_plugin_body)?;
|
|
let mut workspace_installed_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_22222222222222222222222222222222",
|
|
"shared-linear",
|
|
"Shared Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ Some(true),
|
|
))?;
|
|
let unlisted_installed_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_33333333333333333333333333333333",
|
|
"unlisted-linear",
|
|
"Unlisted Linear",
|
|
"UNLISTED",
|
|
/*enabled*/ Some(false),
|
|
))?;
|
|
workspace_installed_body["plugins"]
|
|
.as_array_mut()
|
|
.expect("installed plugins should be an array")
|
|
.push(unlisted_installed_body["plugins"][0].clone());
|
|
let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?;
|
|
mount_shared_workspace_plugins(&server, &shared_plugin_body).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
|
|
mount_empty_user_installed_plugins(&server).await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(response.marketplaces.len(), 2);
|
|
let marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "workspace-shared-with-me-private")
|
|
.expect("expected private shared-with-me marketplace");
|
|
assert_eq!(
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Shared with me")
|
|
);
|
|
assert_eq!(marketplace.plugins.len(), 2);
|
|
assert_eq!(
|
|
marketplace.plugins[0].id,
|
|
"shared-linear@workspace-shared-with-me"
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[0].remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_22222222222222222222222222222222")
|
|
);
|
|
assert_eq!(marketplace.plugins[0].name, "shared-linear");
|
|
assert_eq!(marketplace.plugins[0].installed, true);
|
|
assert_eq!(marketplace.plugins[0].enabled, true);
|
|
let share_context = marketplace.plugins[0]
|
|
.share_context
|
|
.as_ref()
|
|
.expect("expected share context");
|
|
assert_eq!(
|
|
share_context.remote_plugin_id,
|
|
"plugins~Plugin_22222222222222222222222222222222"
|
|
);
|
|
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3"));
|
|
assert_eq!(
|
|
share_context.discoverability,
|
|
Some(PluginShareDiscoverability::Private)
|
|
);
|
|
assert_eq!(
|
|
share_context.creator_account_user_id.as_deref(),
|
|
Some("user-gavin__account-123")
|
|
);
|
|
assert_eq!(share_context.creator_name.as_deref(), Some("Gavin"));
|
|
assert_eq!(
|
|
share_context.share_url.as_deref(),
|
|
Some("https://chatgpt.example/plugins/share/share-key-1")
|
|
);
|
|
assert_eq!(share_context.share_principals, None);
|
|
assert_eq!(
|
|
marketplace.plugins[1].id,
|
|
"shared-unlisted-linear@workspace-shared-with-me"
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[1].remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_44444444444444444444444444444444")
|
|
);
|
|
assert_eq!(marketplace.plugins[1].name, "shared-unlisted-linear");
|
|
assert_eq!(marketplace.plugins[1].installed, false);
|
|
assert_eq!(marketplace.plugins[1].enabled, false);
|
|
let share_context = marketplace.plugins[1]
|
|
.share_context
|
|
.as_ref()
|
|
.expect("expected share context");
|
|
assert_eq!(
|
|
share_context.remote_plugin_id,
|
|
"plugins~Plugin_44444444444444444444444444444444"
|
|
);
|
|
assert_eq!(
|
|
share_context.discoverability,
|
|
Some(PluginShareDiscoverability::Unlisted)
|
|
);
|
|
|
|
let marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "workspace-shared-with-me-unlisted")
|
|
.expect("expected unlisted shared-with-me marketplace");
|
|
assert_eq!(
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Shared with me (unlisted)")
|
|
);
|
|
assert_eq!(marketplace.plugins.len(), 1);
|
|
assert_eq!(
|
|
marketplace.plugins[0].id,
|
|
"unlisted-linear@workspace-shared-with-me"
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[0].remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_33333333333333333333333333333333")
|
|
);
|
|
assert_eq!(marketplace.plugins[0].name, "unlisted-linear");
|
|
assert_eq!(marketplace.plugins[0].installed, true);
|
|
assert_eq!(marketplace.plugins[0].enabled, false);
|
|
let share_context = marketplace.plugins[0]
|
|
.share_context
|
|
.as_ref()
|
|
.expect("expected share context");
|
|
assert_eq!(
|
|
share_context.remote_plugin_id,
|
|
"plugins~Plugin_33333333333333333333333333333333"
|
|
);
|
|
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3"));
|
|
assert_eq!(
|
|
share_context.discoverability,
|
|
Some(PluginShareDiscoverability::Unlisted)
|
|
);
|
|
wait_for_remote_installed_snapshot_request(&server).await?;
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_omits_shared_with_me_kind_when_plugin_sharing_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
plugin_sharing = false
|
|
"#,
|
|
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 mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginListResponse {
|
|
marketplaces: Vec::new(),
|
|
marketplace_load_errors: Vec::new(),
|
|
featured_plugin_ids: Vec::new(),
|
|
}
|
|
);
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"/ps/plugins/workspace/shared",
|
|
/*expected_count*/ 0,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_omits_created_by_me_when_remote_plugins_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = false
|
|
plugin_sharing = true
|
|
"#,
|
|
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 mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::CreatedByMeRemote]),
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginListResponse {
|
|
marketplaces: Vec::new(),
|
|
marketplace_load_errors: Vec::new(),
|
|
featured_plugin_ids: Vec::new(),
|
|
}
|
|
);
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
|
|
assert_disabled_remote_plugin_metadata(
|
|
PluginDisabledReason::DisabledByAdmin,
|
|
/*eligible_plan_types*/ None,
|
|
PluginInstallPolicy::Available,
|
|
)
|
|
.await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_preserves_plan_ineligible_remote_plugin_metadata() -> Result<()> {
|
|
assert_disabled_remote_plugin_metadata(
|
|
PluginDisabledReason::PlanNotEligible,
|
|
Some(vec![
|
|
"plus".to_string(),
|
|
"pro".to_string(),
|
|
"enterprise_cbp_automation".to_string(),
|
|
]),
|
|
PluginInstallPolicy::NotAvailable,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn assert_disabled_remote_plugin_metadata(
|
|
disabled_reason: PluginDisabledReason,
|
|
eligible_plan_types: Option<Vec<String>>,
|
|
installation_policy: PluginInstallPolicy,
|
|
) -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", 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 plugin = serde_json::json!({
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "gmail",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": installation_policy,
|
|
"authentication_policy": "ON_USE",
|
|
"status": "DISABLED_BY_ADMIN",
|
|
"disabled_reason": disabled_reason,
|
|
"eligible_plan_types": eligible_plan_types,
|
|
"release": {
|
|
"display_name": "Gmail",
|
|
"description": "Search and manage email",
|
|
"app_ids": [],
|
|
"interface": {},
|
|
"skills": [],
|
|
},
|
|
});
|
|
let global_directory_body = serde_json::json!({
|
|
"plugins": [plugin.clone()],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null,
|
|
},
|
|
});
|
|
let mut installed_plugin = plugin;
|
|
installed_plugin["enabled"] = serde_json::json!(true);
|
|
installed_plugin["disabled_skill_names"] = serde_json::json!([]);
|
|
let global_installed_body = serde_json::json!({
|
|
"plugins": [installed_plugin],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null,
|
|
},
|
|
});
|
|
let empty_page_body = serde_json::json!({
|
|
"plugins": [],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null,
|
|
},
|
|
});
|
|
|
|
for (scope, body) in [
|
|
("GLOBAL", &global_directory_body),
|
|
("WORKSPACE", &empty_page_body),
|
|
] {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", scope))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(body))
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
for (scope, body) in [
|
|
("GLOBAL", &global_installed_body),
|
|
("WORKSPACE", &empty_page_body),
|
|
] {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param("scope", scope))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(body))
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected ChatGPT remote marketplace");
|
|
let plugin = remote_marketplace
|
|
.plugins
|
|
.first()
|
|
.expect("expected remote plugin");
|
|
assert_eq!(plugin.installed, true);
|
|
assert_eq!(plugin.enabled, true);
|
|
assert_eq!(
|
|
plugin.availability,
|
|
codex_app_server_protocol::PluginAvailability::DisabledByAdmin
|
|
);
|
|
assert_eq!(plugin.disabled_reason, Some(disabled_reason));
|
|
assert_eq!(plugin.eligible_plan_types, eligible_plan_types);
|
|
assert_eq!(plugin.install_policy, installation_policy);
|
|
Ok(())
|
|
}
|
|
|
|
#[test_case(false; "no project override")]
|
|
#[test_case(true; "project enables local plugins only")]
|
|
#[tokio::test]
|
|
async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled(
|
|
project_enables_plugins: bool,
|
|
) -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"
|
|
chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = false
|
|
remote_plugin = true
|
|
"#,
|
|
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 = TempDir::new()?;
|
|
for directory in [".git", ".codex", ".agents/plugins"] {
|
|
std::fs::create_dir_all(repo.path().join(directory))?;
|
|
}
|
|
std::fs::write(
|
|
repo.path().join(".codex/config.toml"),
|
|
"[features]\nplugins = true\n[plugins.\"sample@local\"]\nenabled = true\n",
|
|
)?;
|
|
std::fs::write(
|
|
repo.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{"name":"local","plugins":[{"name":"sample","source":{"source":"local","path":"./sample"}}]}"#,
|
|
)?;
|
|
write_installed_plugin(&codex_home, "local", "sample")?;
|
|
set_project_trust_level(codex_home.path(), repo.path(), TrustLevel::Trusted)?;
|
|
let repo_cwd = AbsolutePathBuf::try_from(repo.path())?;
|
|
let cwds = project_enables_plugins.then(|| vec![repo_cwd]);
|
|
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
for marketplace_kinds in [
|
|
None,
|
|
Some(vec![
|
|
PluginListMarketplaceKind::Local,
|
|
PluginListMarketplaceKind::WorkspaceDirectory,
|
|
PluginListMarketplaceKind::CreatedByMeRemote,
|
|
PluginListMarketplaceKind::SharedWithMe,
|
|
PluginListMarketplaceKind::Vertical,
|
|
]),
|
|
] {
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: cwds.clone(),
|
|
marketplace_kinds,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
let ids = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| &marketplace.plugins)
|
|
.map(|plugin| plugin.id.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
ids,
|
|
if project_enables_plugins {
|
|
vec!["sample@local"]
|
|
} else {
|
|
vec![]
|
|
}
|
|
);
|
|
assert_eq!(response.featured_plugin_ids, Vec::<String>::new());
|
|
}
|
|
let request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
let response: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
let ids = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| &marketplace.plugins)
|
|
.map(|plugin| plugin.id.as_str())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
ids,
|
|
if project_enables_plugins {
|
|
vec!["sample@local"]
|
|
} else {
|
|
vec![]
|
|
}
|
|
);
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_omits_featured_plugin_ids_without_chatgpt_auth() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?;
|
|
write_openai_api_curated_marketplace(codex_home.path(), &["linear", "gmail"])?;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/plugins/featured"))
|
|
.and(query_param("platform", "codex"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200).set_body_string(r#"["linear@openai-api-curated"]"#),
|
|
)
|
|
.expect(0)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_env_overrides(&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
])
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(response.featured_plugin_ids, Vec::<String>::new());
|
|
assert_eq!(response.marketplaces[0].name, "openai-api-curated");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?;
|
|
write_openai_curated_marketplace(codex_home.path(), &["linear", "gmail"])?;
|
|
write_remote_plugin_test_auth(codex_home.path())?;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/plugins/featured"))
|
|
.and(query_param("platform", "codex"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::builder()
|
|
.with_codex_home(codex_home.path())
|
|
.with_plugin_startup_tasks()
|
|
.build_initialized_with_timeout(DEFAULT_TIMEOUT)
|
|
.await?;
|
|
wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
force_refetch: false,
|
|
})
|
|
.await?;
|
|
|
|
let response: PluginListResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
|
|
assert_eq!(
|
|
response.featured_plugin_ids,
|
|
vec!["linear@openai-curated".to_string()]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_featured_plugin_request_count(
|
|
server: &MockServer,
|
|
expected_count: usize,
|
|
) -> Result<()> {
|
|
wait_for_remote_plugin_request_count(server, "/plugins/featured", expected_count).await
|
|
}
|
|
|
|
async fn wait_for_remote_plugin_request_count(
|
|
server: &MockServer,
|
|
path_suffix: &str,
|
|
expected_count: usize,
|
|
) -> Result<()> {
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
let Some(requests) = server.received_requests().await else {
|
|
bail!("wiremock did not record requests");
|
|
};
|
|
let request_count = requests
|
|
.iter()
|
|
.filter(|request| {
|
|
request.method == "GET" && request.url.path().ends_with(path_suffix)
|
|
})
|
|
.count();
|
|
if request_count == expected_count {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
if request_count > expected_count {
|
|
bail!(
|
|
"expected exactly {expected_count} {path_suffix} requests, got {request_count}"
|
|
);
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_remote_plugin_list_scope_request_count(
|
|
server: &MockServer,
|
|
scope: &str,
|
|
expected_count: usize,
|
|
) -> Result<()> {
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
let Some(requests) = server.received_requests().await else {
|
|
bail!("wiremock did not record requests");
|
|
};
|
|
let request_count = requests
|
|
.iter()
|
|
.filter(|request| {
|
|
request.method == "GET"
|
|
&& request.url.path().ends_with("/ps/plugins/list")
|
|
&& request
|
|
.url
|
|
.query_pairs()
|
|
.any(|(name, value)| name == "scope" && value == scope)
|
|
})
|
|
.count();
|
|
if request_count == expected_count {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
if request_count > expected_count {
|
|
bail!(
|
|
"expected exactly {expected_count} /ps/plugins/list requests for scope {scope}, got {request_count}"
|
|
);
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_remote_installed_snapshot_request(server: &MockServer) -> Result<()> {
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
let Some(requests) = server.received_requests().await else {
|
|
bail!("wiremock did not record requests");
|
|
};
|
|
if requests.iter().any(|request| {
|
|
request.method == "GET"
|
|
&& request.url.path().ends_with("/ps/plugins/installed")
|
|
&& request.url.query_pairs().all(|(name, _)| name != "scope")
|
|
}) {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_cached_remote_catalog_plugin_ids(
|
|
codex_home: &std::path::Path,
|
|
expected_plugin_ids: &[&str],
|
|
) -> Result<()> {
|
|
let mut expected_plugin_ids = expected_plugin_ids
|
|
.iter()
|
|
.copied()
|
|
.map(str::to_string)
|
|
.collect::<Vec<_>>();
|
|
expected_plugin_ids.sort();
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
let plugin_ids = cached_remote_catalog_plugin_ids(codex_home)?;
|
|
if plugin_ids == expected_plugin_ids {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
fn cached_remote_catalog_plugin_ids(codex_home: &std::path::Path) -> Result<Vec<String>> {
|
|
let cache_dir = codex_home.join("cache/remote_plugin_catalog");
|
|
if !cache_dir.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut plugin_ids = Vec::new();
|
|
for entry in std::fs::read_dir(cache_dir)? {
|
|
let path = entry?.path();
|
|
if path.extension().is_none_or(|ext| ext != "json") {
|
|
continue;
|
|
}
|
|
let cached_catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(path)?)?;
|
|
let Some(plugins) = cached_catalog["plugins"].as_array() else {
|
|
continue;
|
|
};
|
|
plugin_ids.extend(
|
|
plugins
|
|
.iter()
|
|
.filter_map(|plugin| plugin["id"].as_str())
|
|
.map(str::to_string),
|
|
);
|
|
}
|
|
plugin_ids.sort();
|
|
Ok(plugin_ids)
|
|
}
|
|
|
|
fn rewrite_cached_remote_catalog_fetched_at(
|
|
codex_home: &std::path::Path,
|
|
fetched_at: chrono::DateTime<Utc>,
|
|
) -> Result<()> {
|
|
let cache_dir = codex_home.join("cache/remote_plugin_catalog");
|
|
for entry in std::fs::read_dir(cache_dir)? {
|
|
let path = entry?.path();
|
|
if path.extension().is_none_or(|ext| ext != "json") {
|
|
continue;
|
|
}
|
|
let mut cached_catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(&path)?)?;
|
|
cached_catalog["fetched_at"] = serde_json::json!(fetched_at);
|
|
std::fs::write(path, serde_json::to_vec_pretty(&cached_catalog)?)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> {
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
if path.exists() {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn trigger_plugin_installed_sync(mcp: &mut TestAppServer) -> Result<()> {
|
|
let request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
let _: PluginInstalledResponse =
|
|
timeout(DEFAULT_TIMEOUT, mcp.read_response(request_id)).await??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_plugin_hooks(
|
|
mcp: &mut TestAppServer,
|
|
cwd: &std::path::Path,
|
|
plugin_ids: &[&str],
|
|
expected_status: HookTrustStatus,
|
|
) -> Result<Vec<HookMetadata>> {
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
let request_id = mcp
|
|
.send_hooks_list_request(HooksListParams {
|
|
cwds: vec![cwd.to_path_buf()],
|
|
})
|
|
.await?;
|
|
let HooksListResponse { data } = mcp.read_response(request_id).await?;
|
|
let hooks = data
|
|
.into_iter()
|
|
.flat_map(|entry| entry.hooks)
|
|
.filter(|hook| {
|
|
hook.plugin_id
|
|
.as_deref()
|
|
.is_some_and(|plugin_id| plugin_ids.contains(&plugin_id))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if hooks.len() == plugin_ids.len()
|
|
&& hooks
|
|
.iter()
|
|
.all(|hook| hook.trust_status == expected_status)
|
|
{
|
|
return Ok::<_, anyhow::Error>(hooks);
|
|
}
|
|
sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await?
|
|
}
|
|
|
|
async fn wait_for_path_missing(path: &std::path::Path) -> Result<()> {
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
if !path.exists() {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str) {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", scope))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_delayed_remote_plugin_list(server: &MockServer, scope: &str, body: &str) {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", scope))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_string(body)
|
|
.set_delay(Duration::from_millis(/*millis*/ 200)),
|
|
)
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
fn remote_plugin_list_body(
|
|
remote_plugin_id: &str,
|
|
plugin_name: &str,
|
|
display_name: &str,
|
|
short_description: &str,
|
|
) -> String {
|
|
format!(
|
|
r#"{{
|
|
"plugins": [
|
|
{{
|
|
"id": "{remote_plugin_id}",
|
|
"name": "{plugin_name}",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"status": "ENABLED",
|
|
"release": {{
|
|
"version": "1.2.3",
|
|
"display_name": "{display_name}",
|
|
"description": "{display_name}",
|
|
"app_ids": [],
|
|
"interface": {{
|
|
"short_description": "{short_description}",
|
|
"capabilities": ["Read"]
|
|
}},
|
|
"skills": []
|
|
}}
|
|
}}
|
|
],
|
|
"pagination": {{
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}}
|
|
}}"#
|
|
)
|
|
}
|
|
|
|
async fn mount_openai_curated_remote_collection_plugin_list(server: &MockServer, body: &str) {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.and(query_param("limit", "200"))
|
|
.and(query_param("collection", "vertical"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/workspace/shared"))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: &str) {
|
|
let plugins = serde_json::from_str::<serde_json::Value>(body)
|
|
.expect("installed plugin fixture should be valid JSON")["plugins"]
|
|
.as_array()
|
|
.expect("installed plugin fixture should contain plugins")
|
|
.clone();
|
|
REMOTE_INSTALLED_PLUGIN_FIXTURES
|
|
.get_or_init(Default::default)
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.entry(server.uri())
|
|
.or_default()
|
|
.insert(scope.to_string(), plugins);
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param("scope", scope))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(server)
|
|
.await;
|
|
|
|
let server_uri = server.uri();
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param_is_missing("scope"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(move |_request: &wiremock::Request| {
|
|
let fixtures = REMOTE_INSTALLED_PLUGIN_FIXTURES
|
|
.get()
|
|
.expect("installed plugin fixtures should exist")
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
let scoped_plugins = fixtures
|
|
.get(&server_uri)
|
|
.expect("installed plugin fixtures should exist for this server");
|
|
let plugins = ["GLOBAL", "WORKSPACE", "USER"]
|
|
.into_iter()
|
|
.flat_map(|scope| scoped_plugins.get(scope).into_iter().flatten())
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"plugins": plugins,
|
|
"pagination": {"limit": 50, "next_page_token": null},
|
|
}))
|
|
})
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_empty_user_installed_plugins(server: &MockServer) {
|
|
mount_remote_installed_plugins(server, "USER", empty_remote_installed_plugins_body()).await;
|
|
}
|
|
|
|
fn empty_remote_installed_plugins_body() -> &'static str {
|
|
r#"{
|
|
"plugins": [],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#
|
|
}
|
|
|
|
fn workspace_remote_plugin_page_body(
|
|
remote_plugin_id: &str,
|
|
plugin_name: &str,
|
|
display_name: &str,
|
|
discoverability: &str,
|
|
enabled: Option<bool>,
|
|
) -> String {
|
|
let enabled_field = enabled
|
|
.map(|enabled| format!(r#", "enabled": {enabled}, "disabled_skill_names": []"#))
|
|
.unwrap_or_default();
|
|
format!(
|
|
r#"{{
|
|
"plugins": [
|
|
{{
|
|
"id": "{remote_plugin_id}",
|
|
"name": "{plugin_name}",
|
|
"scope": "WORKSPACE",
|
|
"discoverability": "{discoverability}",
|
|
"creator_account_user_id": "user-gavin__account-123",
|
|
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"status": "ENABLED",
|
|
"creator_name": "Gavin",
|
|
"share_principals": [
|
|
{{
|
|
"principal_type": "user",
|
|
"principal_id": "user-gavin__account-123",
|
|
"role": "owner",
|
|
"name": "Gavin"
|
|
}},
|
|
{{
|
|
"principal_type": "user",
|
|
"principal_id": "user-ada__account-123",
|
|
"role": "reader",
|
|
"name": "Ada"
|
|
}}
|
|
],
|
|
"release": {{
|
|
"version": "1.2.3",
|
|
"display_name": "{display_name}",
|
|
"description": "Track work",
|
|
"app_ids": [],
|
|
"interface": {{}},
|
|
"skills": []
|
|
}}{enabled_field}
|
|
}}
|
|
],
|
|
"pagination": {{
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}}
|
|
}}"#
|
|
)
|
|
}
|
|
|
|
async fn mount_workspace_bundle_sync(server: &MockServer, plugins: &[(&str, &str, &str)]) {
|
|
let plugins = plugins
|
|
.iter()
|
|
.map(|(name, install_policy, bundle_url)| {
|
|
let body: serde_json::Value = serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
&format!("plugins~Plugin_{name}"),
|
|
name,
|
|
name,
|
|
"LISTED",
|
|
/*enabled*/ Some(true),
|
|
))
|
|
.expect("workspace plugin body");
|
|
let mut plugin = body["plugins"][0].clone();
|
|
plugin["installation_policy"] = serde_json::json!(install_policy);
|
|
plugin["release"]["bundle_download_url"] = serde_json::json!(bundle_url);
|
|
plugin
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let body = serde_json::json!({
|
|
"plugins": plugins,
|
|
"pagination": {"next_page_token": null},
|
|
})
|
|
.to_string();
|
|
mount_remote_installed_plugins(server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(server, "WORKSPACE", &body).await;
|
|
mount_empty_user_installed_plugins(server).await;
|
|
}
|
|
|
|
fn user_remote_plugin_page_body(
|
|
remote_plugin_id: &str,
|
|
plugin_name: &str,
|
|
display_name: &str,
|
|
discoverability: &str,
|
|
enabled: Option<bool>,
|
|
) -> String {
|
|
workspace_remote_plugin_page_body(
|
|
remote_plugin_id,
|
|
plugin_name,
|
|
display_name,
|
|
discoverability,
|
|
enabled,
|
|
)
|
|
.replacen(r#""scope": "WORKSPACE""#, r#""scope": "USER""#, 1)
|
|
}
|
|
|
|
fn remote_installed_plugin_body(
|
|
bundle_download_url: &str,
|
|
release_version: &str,
|
|
enabled: bool,
|
|
) -> String {
|
|
remote_installed_plugin_body_with_optional_app_manifest(
|
|
bundle_download_url,
|
|
release_version,
|
|
enabled,
|
|
/*app_manifest*/ None,
|
|
)
|
|
}
|
|
|
|
fn remote_installed_plugin_body_with_app_manifest(
|
|
bundle_download_url: &str,
|
|
release_version: &str,
|
|
enabled: bool,
|
|
app_manifest: serde_json::Value,
|
|
) -> String {
|
|
remote_installed_plugin_body_with_optional_app_manifest(
|
|
bundle_download_url,
|
|
release_version,
|
|
enabled,
|
|
Some(app_manifest),
|
|
)
|
|
}
|
|
|
|
fn remote_installed_plugin_body_with_optional_app_manifest(
|
|
bundle_download_url: &str,
|
|
release_version: &str,
|
|
enabled: bool,
|
|
app_manifest: Option<serde_json::Value>,
|
|
) -> String {
|
|
let app_manifest_field = app_manifest
|
|
.map(|manifest| format!(r#" "app_manifest": {manifest},"#))
|
|
.unwrap_or_default();
|
|
format!(
|
|
r#"{{
|
|
"plugins": [
|
|
{{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"installation_policy_source": "WORKSPACE_SETTING",
|
|
"authentication_policy": "ON_USE",
|
|
"release": {{
|
|
"version": "{release_version}",
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"bundle_download_url": "{bundle_download_url}",
|
|
"app_ids": [],
|
|
{app_manifest_field}
|
|
"interface": {{}},
|
|
"skills": []
|
|
}},
|
|
"enabled": {enabled},
|
|
"disabled_skill_names": []
|
|
}}
|
|
],
|
|
"pagination": {{
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}}
|
|
}}"#
|
|
)
|
|
}
|
|
|
|
async fn mount_remote_plugin_bundle(
|
|
server: &MockServer,
|
|
plugin_name: &str,
|
|
body: Vec<u8>,
|
|
) -> String {
|
|
let bundle_path = format!("/bundles/{plugin_name}.tar.gz");
|
|
Mock::given(method("GET"))
|
|
.and(path(bundle_path.as_str()))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.insert_header("content-type", "application/gzip")
|
|
.set_body_bytes(body),
|
|
)
|
|
.mount(server)
|
|
.await;
|
|
format!("{}{bundle_path}", server.uri())
|
|
}
|
|
|
|
async fn mount_remote_plugin_bundle_with_hooks(
|
|
server: &MockServer,
|
|
plugin_name: &str,
|
|
hooks_json: Option<&str>,
|
|
) -> Result<String> {
|
|
Ok(mount_remote_plugin_bundle(
|
|
server,
|
|
plugin_name,
|
|
remote_plugin_bundle_tar_gz_bytes(plugin_name, hooks_json)?,
|
|
)
|
|
.await)
|
|
}
|
|
|
|
fn remote_plugin_bundle_tar_gz_bytes(
|
|
plugin_name: &str,
|
|
hooks_json: Option<&str>,
|
|
) -> Result<Vec<u8>> {
|
|
let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);
|
|
let skill = "---\nname: plan-work\ndescription: Track work in Linear.\n---\n\n# Plan Work\n";
|
|
let encoder = GzEncoder::new(Vec::new(), Compression::default());
|
|
let mut tar = tar::Builder::new(encoder);
|
|
let mut entries = vec![
|
|
(
|
|
".codex-plugin/plugin.json",
|
|
manifest.as_bytes(),
|
|
/*mode*/ 0o644,
|
|
),
|
|
(
|
|
"skills/plan-work/SKILL.md",
|
|
skill.as_bytes(),
|
|
/*mode*/ 0o644,
|
|
),
|
|
];
|
|
if let Some(hooks_json) = hooks_json {
|
|
entries.push((
|
|
"hooks/hooks.json",
|
|
hooks_json.as_bytes(),
|
|
/*mode*/ 0o644,
|
|
));
|
|
}
|
|
for (path, contents, mode) in entries {
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_size(contents.len() as u64);
|
|
header.set_mode(mode);
|
|
header.set_cksum();
|
|
tar.append_data(&mut header, path, contents)?;
|
|
}
|
|
Ok(tar.into_inner()?.finish()?)
|
|
}
|
|
|
|
fn write_installed_plugin(
|
|
codex_home: &TempDir,
|
|
marketplace_name: &str,
|
|
plugin_name: &str,
|
|
) -> Result<()> {
|
|
write_installed_plugin_with_version(codex_home, marketplace_name, plugin_name, "local")
|
|
}
|
|
|
|
fn write_installed_plugin_with_version(
|
|
codex_home: &TempDir,
|
|
marketplace_name: &str,
|
|
plugin_name: &str,
|
|
plugin_version: &str,
|
|
) -> Result<()> {
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join(marketplace_name)
|
|
.join(plugin_name)
|
|
.join(plugin_version)
|
|
.join(".codex-plugin");
|
|
std::fs::create_dir_all(&plugin_root)?;
|
|
std::fs::write(
|
|
plugin_root.join("plugin.json"),
|
|
format!(r#"{{"name":"{plugin_name}"}}"#),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_plugin_sync_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"
|
|
chatgpt_base_url = "{base_url}"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = false
|
|
|
|
[plugins."linear@openai-curated"]
|
|
enabled = false
|
|
|
|
[plugins."gmail@openai-curated"]
|
|
enabled = false
|
|
|
|
[plugins."calendar@openai-curated"]
|
|
enabled = true
|
|
"#
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_remote_plugin_catalog_config(
|
|
codex_home: &std::path::Path,
|
|
base_url: &str,
|
|
) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"
|
|
chatgpt_base_url = "{base_url}"
|
|
|
|
[features]
|
|
plugins = true
|
|
"#
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_remote_plugin_hook_config(
|
|
codex_home: &std::path::Path,
|
|
base_url: &str,
|
|
hook_state: &str,
|
|
) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{base_url}"
|
|
|
|
[features]
|
|
plugins = true
|
|
hooks = true
|
|
{hook_state}"#,
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_remote_plugin_test_auth(codex_home: &std::path::Path) -> Result<()> {
|
|
write_chatgpt_auth(
|
|
codex_home,
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)
|
|
}
|
|
|
|
fn write_openai_curated_marketplace(
|
|
codex_home: &std::path::Path,
|
|
plugin_names: &[&str],
|
|
) -> std::io::Result<()> {
|
|
write_curated_marketplace(
|
|
codex_home,
|
|
"marketplace.json",
|
|
"openai-curated",
|
|
/*display_name*/ None,
|
|
plugin_names,
|
|
)
|
|
}
|
|
|
|
fn write_openai_api_curated_marketplace(
|
|
codex_home: &std::path::Path,
|
|
plugin_names: &[&str],
|
|
) -> std::io::Result<()> {
|
|
write_curated_marketplace(
|
|
codex_home,
|
|
"api_marketplace.json",
|
|
"openai-api-curated",
|
|
Some("OpenAI Curated"),
|
|
plugin_names,
|
|
)
|
|
}
|
|
|
|
fn write_curated_marketplace(
|
|
codex_home: &std::path::Path,
|
|
manifest_name: &str,
|
|
marketplace_name: &str,
|
|
display_name: Option<&str>,
|
|
plugin_names: &[&str],
|
|
) -> std::io::Result<()> {
|
|
let curated_root = codex_home.join(".tmp/plugins");
|
|
std::fs::create_dir_all(curated_root.join(".git"))?;
|
|
std::fs::create_dir_all(curated_root.join(".agents/plugins"))?;
|
|
let plugins = plugin_names
|
|
.iter()
|
|
.map(|plugin_name| {
|
|
format!(
|
|
r#"{{
|
|
"name": "{plugin_name}",
|
|
"source": {{
|
|
"source": "local",
|
|
"path": "./plugins/{plugin_name}"
|
|
}}
|
|
}}"#
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(",\n");
|
|
let interface = display_name
|
|
.map(|display_name| {
|
|
format!(
|
|
r#"
|
|
"interface": {{
|
|
"displayName": "{display_name}"
|
|
}},"#
|
|
)
|
|
})
|
|
.unwrap_or_default();
|
|
std::fs::write(
|
|
curated_root.join(".agents/plugins").join(manifest_name),
|
|
format!(
|
|
r#"{{
|
|
"name": "{marketplace_name}",{interface}
|
|
"plugins": [
|
|
{plugins}
|
|
]
|
|
}}"#
|
|
),
|
|
)?;
|
|
|
|
for plugin_name in plugin_names {
|
|
let plugin_root = curated_root.join(format!("plugins/{plugin_name}/.codex-plugin"));
|
|
std::fs::create_dir_all(&plugin_root)?;
|
|
std::fs::write(
|
|
plugin_root.join("plugin.json"),
|
|
format!(r#"{{"name":"{plugin_name}"}}"#),
|
|
)?;
|
|
}
|
|
std::fs::create_dir_all(codex_home.join(".tmp"))?;
|
|
std::fs::write(
|
|
codex_home.join(".tmp/plugins.sha"),
|
|
format!("{TEST_CURATED_PLUGIN_SHA}\n"),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_plugin_share_local_path_mapping(
|
|
codex_home: &std::path::Path,
|
|
remote_plugin_id: &str,
|
|
plugin_path: &AbsolutePathBuf,
|
|
) -> std::io::Result<()> {
|
|
let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new();
|
|
local_plugin_paths_by_remote_plugin_id.insert(
|
|
remote_plugin_id.to_string(),
|
|
serde_json::to_value(plugin_path).map_err(std::io::Error::other)?,
|
|
);
|
|
let contents = serde_json::to_string_pretty(&serde_json::json!({
|
|
"localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id,
|
|
}))
|
|
.map_err(std::io::Error::other)?;
|
|
std::fs::create_dir_all(codex_home.join(".tmp"))?;
|
|
std::fs::write(
|
|
codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
|
format!("{contents}\n"),
|
|
)
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
struct RestorePermissions(std::path::PathBuf, std::fs::Permissions);
|
|
|
|
#[cfg(unix)]
|
|
impl Drop for RestorePermissions {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::set_permissions(&self.0, self.1.clone());
|
|
}
|
|
}
|