Files
codex/codex-rs/core-plugins/src/manager_tests.rs
Matthew Zeng e6a3877e95 Harden remote installed plugin cache reconciliation (#40015)
## What changed

- Scope remote installed-plugin and loaded-plugin snapshots to the active account, and discard in-flight loads when the account changes.
- Serialize bundle reconciliation with direct installs and uninstalls, and use cache generations to prevent stale refreshes from overwriting newer state.
- Validate the complete installed-plugin snapshot before downloading bundles or removing stale cache entries, while retaining valid installed metadata when materialization fails.

## Testing

- Cover account changes during plugin loading, incomplete snapshots, refresh/reconciliation races, and reconciliation cancellation recovery.

GitOrigin-RevId: 694c25577b420b93dacc6c65e3c0ecb7b8cc64c3
2026-08-21 21:28:16 +00:00

7200 lines
222 KiB
Rust

use super::*;
use crate::LoadedPlugin;
use crate::OPENAI_API_CURATED_MARKETPLACE_NAME;
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::PluginLoadOutcome;
use crate::ToolSuggestDiscoverablePlugin;
use crate::ToolSuggestPluginDiscoveryInput;
use crate::installed_marketplaces::marketplace_install_root;
use crate::loader::load_plugin_skill_inventory;
use crate::loader::load_plugins_from_layer_stack;
use crate::loader::refresh_non_curated_plugin_cache;
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall;
use crate::marketplace::MarketplacePluginInstallPolicy;
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use crate::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME;
use crate::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME;
use crate::remote::RecommendedPlugin;
use crate::remote::RemoteInstalledPlugin;
use crate::startup_sync::curated_plugins_repo_path;
use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
use crate::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::test_support::load_plugins_config as load_plugins_config_input;
use crate::test_support::set_test_auth;
use crate::test_support::set_test_auth_mode;
use crate::test_support::test_auth_manager;
use crate::test_support::test_http_client_factory;
use crate::test_support::test_plugins_manager;
use crate::test_support::test_plugins_manager_with_auth_manager;
use crate::test_support::test_plugins_manager_with_options;
use crate::test_support::test_skill_root_loader;
use crate::test_support::write_curated_plugin;
use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
use crate::test_support::write_file;
use crate::test_support::write_openai_api_curated_marketplace;
use crate::test_support::write_openai_curated_marketplace;
use codex_config::AppToolApproval;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::McpServerConfig;
use codex_config::McpServerOAuthConfig;
use codex_config::McpServerToolConfig;
use codex_config::RequirementSource;
use codex_config::RequirementsLayerEntry;
use codex_config::SkillConfigRules;
use codex_config::compose_requirements;
use codex_config::types::McpServerTransportConfig;
use codex_login::CodexAuth;
use codex_login::test_support::auth_manager_with_agent_identity;
use codex_model_provider::AMAZON_BEDROCK_PROVIDER_ID;
use codex_plugin::AppDeclaration;
use codex_plugin::PluginId;
use codex_protocol::auth::AuthMode;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::Product;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_absolute_path::test_support::PathBufExt;
use codex_utils_plugins::SkillDiscoveryMode;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
use toml::Value;
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;
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
fn unrestricted_config_layer_stack() -> ConfigLayerStack {
ConfigLayerStack::default()
}
fn config_layer_stack_with_requirements(
codex_home: &Path,
user_config: &str,
requirements: &str,
) -> ConfigLayerStack {
let with_sources = compose_requirements([RequirementsLayerEntry::from_toml(
RequirementSource::Unknown,
requirements,
)])
.expect("compose requirements")
.expect("requirements should be present");
let requirements_toml = with_sources.clone().into_toml();
let requirements = ConfigRequirements::try_from(with_sources).expect("normalize requirements");
let config_file =
AbsolutePathBuf::try_from(codex_home.join(CONFIG_TOML_FILE)).expect("absolute config path");
ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::User {
file: config_file,
profile: None,
},
toml::from_str(user_config).expect("parse user config"),
)],
requirements,
requirements_toml,
)
.expect("build config layer stack")
}
fn plugins_config_input_with_requirements(
codex_home: &Path,
user_config: &str,
requirements: &str,
) -> PluginsConfigInput {
PluginsConfigInput::new(
config_layer_stack_with_requirements(codex_home, user_config, requirements),
String::new(),
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ false,
String::new(),
test_http_client_factory(),
)
}
#[tokio::test]
async fn plugins_manager_reads_auth_mode_from_auth_manager() {
let tmp = TempDir::new().unwrap();
let auth_manager = test_auth_manager(/*auth_mode*/ None);
let manager = test_plugins_manager_with_auth_manager(
tmp.path().to_path_buf(),
Some(Product::Codex),
Arc::clone(&auth_manager),
);
assert_eq!(manager.auth_mode(), None);
set_test_auth_mode(&auth_manager, Some(AuthMode::ApiKey)).await;
assert_eq!(manager.auth_mode(), Some(AuthMode::ApiKey));
set_test_auth_mode(&auth_manager, Some(AuthMode::ChatgptAuthTokens)).await;
assert_eq!(manager.auth_mode(), Some(AuthMode::ChatgptAuthTokens));
set_test_auth_mode(&auth_manager, /*auth_mode*/ None).await;
assert_eq!(manager.auth_mode(), None);
let manager_with_auth = test_plugins_manager_with_options(
tmp.path().join("auth"),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
assert_eq!(manager_with_auth.auth_mode(), Some(AuthMode::Chatgpt));
}
#[test]
fn curated_repo_sync_stays_deferred_for_remote_chatgpt_catalog() {
CURATED_REPO_SYNC_STARTED.store(false, std::sync::atomic::Ordering::SeqCst);
let tmp = TempDir::new().unwrap();
let config = PluginsConfigInput::new(
unrestricted_config_layer_stack(),
"openai".to_string(),
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ true,
"https://chatgpt.com".to_string(),
test_http_client_factory(),
);
let manager = Arc::new(test_plugins_manager_with_options(
tmp.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
));
manager.maybe_start_curated_repo_sync_for_config(
&config, /*on_effective_plugins_changed*/ None,
);
assert!(!CURATED_REPO_SYNC_STARTED.load(std::sync::atomic::Ordering::SeqCst));
}
#[test]
fn marketplace_source_refresh_notifies_only_after_installed_cache_changes() {
let tmp = TempDir::new().unwrap();
let manager = test_plugins_manager(tmp.path().to_path_buf());
let callback_count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let callback_count_for_callback = Arc::clone(&callback_count);
let callback: EffectivePluginsChangedCallback = Arc::new(move |_change| {
callback_count_for_callback.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
});
manager.clear_caches_after_marketplace_source_refresh(
/*installed_plugin_cache_refreshed*/ false,
Some(&callback),
);
assert_eq!(callback_count.load(std::sync::atomic::Ordering::Relaxed), 0);
manager.clear_caches_after_marketplace_source_refresh(
/*installed_plugin_cache_refreshed*/ true,
Some(&callback),
);
assert_eq!(callback_count.load(std::sync::atomic::Ordering::Relaxed), 1);
}
#[tokio::test]
async fn marketplace_policy_projection_disables_installed_plugin_and_invalidates_cache() {
let codex_home = TempDir::new().expect("create Codex home");
write_plugin(
&codex_home.path().join("plugins/cache/company"),
"sample/local",
"sample",
);
let user_config = r#"
[marketplaces.company]
source_type = "git"
source = "https://github.com/example/company.git"
[plugins."sample@company"]
enabled = true
"#;
let allowed = plugins_config_input_with_requirements(
codex_home.path(),
user_config,
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.company]
source = "git"
url = "https://github.com/example/company.git"
"#,
);
let blocked = plugins_config_input_with_requirements(
codex_home.path(),
user_config,
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.other]
source = "git"
url = "https://github.com/example/other.git"
"#,
);
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let allowed_outcome = manager.plugins_for_config(&allowed).await;
assert_eq!(allowed_outcome.plugins().len(), 1);
assert_eq!(allowed_outcome.plugins()[0].config_name, "sample@company");
let blocked_outcome = manager.plugins_for_config(&blocked).await;
assert_eq!(blocked_outcome, PluginLoadOutcome::default());
}
#[tokio::test]
async fn plugin_read_rejects_marketplace_blocked_by_requirements() {
let codex_home = TempDir::new().expect("create Codex home");
let marketplace_root = codex_home.path().join("marketplace");
write_plugin(&marketplace_root, "sample", "sample");
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "company",
"plugins": [
{
"name": "sample",
"source": {"source": "local", "path": "./sample"}
}
]
}"#,
);
let config = plugins_config_input_with_requirements(
codex_home.path(),
"",
r#"
[marketplaces]
restrict_to_allowed_sources = true
"#,
);
let marketplace_path =
AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json"))
.expect("absolute marketplace path");
let err = test_plugins_manager(codex_home.path().to_path_buf())
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "sample".to_string(),
marketplace_path,
},
)
.await
.expect_err("blocked marketplace should not be readable");
assert!(matches!(
err,
MarketplaceError::InvalidMarketplaceFile { .. }
));
}
#[test]
fn marketplace_policy_filters_discovered_marketplaces_by_configured_name() {
let codex_home = TempDir::new().expect("create Codex home");
let repo_root = codex_home.path().join("repo");
let subdirectory = repo_root.join("worktree/subdirectory");
fs::create_dir_all(&subdirectory).expect("create input subdirectory");
write_plugin(&repo_root, "sample", "sample");
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "company",
"plugins": [
{
"name": "sample",
"source": {"source": "local", "path": "./sample"}
}
]
}"#,
);
init_git_repo(&repo_root);
let repo_root = AbsolutePathBuf::try_from(repo_root).expect("absolute repository root");
let subdirectory =
AbsolutePathBuf::try_from(subdirectory).expect("absolute input subdirectory");
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let user_config = format!(
r#"
[marketplaces.company]
source_type = "local"
source = {:?}
"#,
repo_root.as_path()
);
let allowed = plugins_config_input_with_requirements(
codex_home.path(),
&user_config,
&format!(
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.company]
source = "local"
path = {:?}
"#,
repo_root.as_path()
),
);
let blocked = plugins_config_input_with_requirements(
codex_home.path(),
&user_config,
&format!(
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.subdirectory]
source = "local"
path = {:?}
"#,
subdirectory.as_path()
),
);
let allowed_outcome = manager
.list_marketplaces_for_config(
&allowed,
std::slice::from_ref(&subdirectory),
/*include_openai_curated*/ false,
)
.expect("list allowed marketplace");
assert_eq!(allowed_outcome.marketplaces.len(), 1);
assert_eq!(allowed_outcome.marketplaces[0].name, "company");
let blocked_outcome = manager
.list_marketplaces_for_config(
&blocked,
std::slice::from_ref(&subdirectory),
/*include_openai_curated*/ false,
)
.expect("list blocked marketplace");
assert_eq!(blocked_outcome.marketplaces, Vec::new());
}
fn write_auth_projection_plugin(codex_home: &Path, name: &str, include_app: bool) {
let plugin_root = codex_home
.join("plugins/cache")
.join("test")
.join(name)
.join("local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
&format!(r#"{{"name":"{name}"}}"#),
);
write_file(
&plugin_root.join(".mcp.json"),
&format!(
r#"{{
"mcpServers": {{
"{name}": {{
"type": "stdio",
"command": "{name}-mcp"
}}
}}
}}"#
),
);
if include_app {
write_auth_projection_app(codex_home, name, name);
}
}
fn write_auth_projection_app(codex_home: &Path, plugin_name: &str, app_name: &str) {
let plugin_root = codex_home
.join("plugins/cache")
.join("test")
.join(plugin_name)
.join("local");
write_file(
&plugin_root.join(".app.json"),
&format!(r#"{{"apps":{{"{app_name}":{{"id":"connector_{plugin_name}"}}}}}}"#),
);
}
fn app_declaration(name: &str, connector_id: &str) -> AppDeclaration {
AppDeclaration {
name: name.to_string(),
connector_id: AppConnectorId(connector_id.to_string()),
category: None,
}
}
async fn auth_projection_config(codex_home: &Path) -> PluginsConfigInput {
let config_toml = r#"[features]
plugins = true
[plugins."sample@test"]
enabled = true
[plugins."docs@test"]
enabled = true
"#
.to_string();
write_file(&codex_home.join(CONFIG_TOML_FILE), &config_toml);
load_config(codex_home, codex_home).await
}
fn sorted_effective_mcp_server_names(outcome: &PluginLoadOutcome) -> Vec<String> {
let mut names = outcome
.effective_mcp_servers()
.keys()
.cloned()
.collect::<Vec<_>>();
names.sort();
names
}
#[tokio::test]
async fn plugin_auth_projection_hides_apps_without_chatgpt_auth() {
let codex_home = TempDir::new().unwrap();
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
let config = auth_projection_config(codex_home.path()).await;
let manager = test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::ApiKey),
);
let outcome = manager.plugins_for_config(&config).await;
assert!(outcome.effective_apps().is_empty());
assert_eq!(
sorted_effective_mcp_server_names(&outcome),
vec!["docs".to_string(), "sample".to_string()]
);
let sample = outcome
.capability_summaries()
.iter()
.find(|plugin| plugin.config_name == "sample@test")
.expect("sample plugin summary should exist");
assert_eq!(sample.mcp_server_names, vec!["sample".to_string()]);
assert!(sample.app_connector_ids.is_empty());
}
#[tokio::test]
async fn plugin_auth_projection_hides_matching_mcp_with_chatgpt_apps_route() {
let codex_home = TempDir::new().unwrap();
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
let config = auth_projection_config(codex_home.path()).await;
let manager = test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(
outcome.effective_apps(),
vec![AppConnectorId("connector_sample".to_string())]
);
assert_eq!(
sorted_effective_mcp_server_names(&outcome),
vec!["docs".to_string()]
);
let sample = outcome
.capability_summaries()
.iter()
.find(|plugin| plugin.config_name == "sample@test")
.expect("sample plugin summary should exist");
assert!(sample.mcp_server_names.is_empty());
assert_eq!(
sample.app_connector_ids,
vec![AppConnectorId("connector_sample".to_string())]
);
let docs = outcome
.capability_summaries()
.iter()
.find(|plugin| plugin.config_name == "docs@test")
.expect("docs plugin summary should exist");
assert_eq!(docs.mcp_server_names, vec!["docs".to_string()]);
assert!(docs.app_connector_ids.is_empty());
}
#[tokio::test]
async fn plugin_auth_projection_hides_dual_surface_mcp_with_agent_identity_apps_route() {
let codex_home = TempDir::new().unwrap();
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
let config = auth_projection_config(codex_home.path()).await;
let manager = test_plugins_manager_with_auth_manager(
codex_home.path().to_path_buf(),
Some(Product::Codex),
auth_manager_with_agent_identity()
.await
.expect("create test Agent Identity auth manager"),
);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(
outcome.effective_apps(),
vec![AppConnectorId("connector_sample".to_string())]
);
assert_eq!(
sorted_effective_mcp_server_names(&outcome),
vec!["docs".to_string()]
);
}
#[tokio::test]
async fn plugin_auth_projection_keeps_non_conflicting_mcp_with_chatgpt_apps_route() {
let codex_home = TempDir::new().unwrap();
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ false);
write_auth_projection_app(codex_home.path(), "sample", "sample_app");
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
let config = auth_projection_config(codex_home.path()).await;
let manager = test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(
outcome.effective_apps(),
vec![AppConnectorId("connector_sample".to_string())]
);
assert_eq!(
sorted_effective_mcp_server_names(&outcome),
vec!["docs".to_string(), "sample".to_string()]
);
let sample = outcome
.capability_summaries()
.iter()
.find(|plugin| plugin.config_name == "sample@test")
.expect("sample plugin summary should exist");
assert_eq!(sample.mcp_server_names, vec!["sample".to_string()]);
assert_eq!(
sample.app_connector_ids,
vec![AppConnectorId("connector_sample".to_string())]
);
}
#[tokio::test]
async fn plugin_auth_projection_preserves_duplicate_connector_declaration_names() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test")
.join("sample")
.join("local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"foo": {
"type": "stdio",
"command": "foo-mcp"
},
"foo2": {
"type": "stdio",
"command": "foo2-mcp"
},
"other": {
"type": "stdio",
"command": "other-mcp"
}
}
}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"foo": {
"id": "connector_shared"
},
"foo2": {
"id": "connector_shared"
}
}
}"#,
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample@test"]
enabled = true
"#,
);
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(
outcome.effective_apps(),
vec![AppConnectorId("connector_shared".to_string())]
);
assert_eq!(
sorted_effective_mcp_server_names(&outcome),
vec!["other".to_string()]
);
let sample = outcome
.capability_summaries()
.iter()
.find(|plugin| plugin.config_name == "sample@test")
.expect("sample plugin summary should exist");
assert_eq!(sample.mcp_server_names, vec!["other".to_string()]);
assert_eq!(
sample.app_connector_ids,
vec![AppConnectorId("connector_shared".to_string())]
);
}
#[tokio::test]
async fn plugin_auth_projection_reprojects_cached_plugins_when_auth_changes() {
let codex_home = TempDir::new().unwrap();
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
let config = auth_projection_config(codex_home.path()).await;
let auth_manager = test_auth_manager(/*auth_mode*/ None);
set_test_auth_mode(&auth_manager, Some(AuthMode::Chatgpt)).await;
let manager = test_plugins_manager_with_auth_manager(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Arc::clone(&auth_manager),
);
let chatgpt_outcome = manager.plugins_for_config(&config).await;
assert_eq!(
sorted_effective_mcp_server_names(&chatgpt_outcome),
vec!["docs".to_string()]
);
assert_eq!(
chatgpt_outcome.effective_apps(),
vec![AppConnectorId("connector_sample".to_string())]
);
assert_eq!(
chatgpt_outcome.capability_summaries(),
&[
PluginCapabilitySummary {
config_name: "docs@test".to_string(),
display_name: "docs".to_string(),
plugin_namespace: Some("docs".to_string()),
description: None,
has_skills: false,
mcp_server_names: vec!["docs".to_string()],
app_connector_ids: Vec::new(),
},
PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
plugin_namespace: Some("sample".to_string()),
description: None,
has_skills: false,
mcp_server_names: Vec::new(),
app_connector_ids: vec![AppConnectorId("connector_sample".to_string())],
},
]
);
set_test_auth_mode(&auth_manager, Some(AuthMode::ApiKey)).await;
let api_key_outcome = manager.plugins_for_config(&config).await;
assert_eq!(
sorted_effective_mcp_server_names(&api_key_outcome),
vec!["docs".to_string(), "sample".to_string()]
);
assert!(api_key_outcome.effective_apps().is_empty());
assert_eq!(
api_key_outcome.capability_summaries(),
&[
PluginCapabilitySummary {
config_name: "docs@test".to_string(),
display_name: "docs".to_string(),
plugin_namespace: Some("docs".to_string()),
description: None,
has_skills: false,
mcp_server_names: vec!["docs".to_string()],
app_connector_ids: Vec::new(),
},
PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
plugin_namespace: Some("sample".to_string()),
description: None,
has_skills: false,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: Vec::new(),
},
]
);
}
fn write_plugin_with_version(
root: &Path,
dir_name: &str,
manifest_name: &str,
manifest_version: Option<&str>,
) {
let plugin_root = root.join(dir_name);
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::create_dir_all(plugin_root.join("skills")).unwrap();
let version = manifest_version
.map(|manifest_version| format!(r#","version":"{manifest_version}""#))
.unwrap_or_default();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(r#"{{"name":"{manifest_name}"{version}}}"#),
)
.unwrap();
fs::write(
plugin_root.join("skills/SKILL.md"),
format!("---\nname: {manifest_name}-skill\ndescription: test skill\n---\n\n# Test skill\n"),
)
.unwrap();
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
}
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
write_plugin_with_version(
root,
dir_name,
manifest_name,
/*manifest_version*/ None,
);
}
fn init_git_repo(repo: &Path) {
run_git(repo, &["init"]);
run_git(repo, &["config", "user.email", "codex-test@example.com"]);
run_git(repo, &["config", "user.name", "Codex Test"]);
run_git(repo, &["add", "."]);
run_git(repo, &["commit", "-m", "initial"]);
}
fn run_git(repo: &Path, args: &[&str]) {
let output = std::process::Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.unwrap_or_else(|err| panic!("git should run: {err}"));
assert!(
output.status.success(),
"git -C {} {} failed\nstdout:\n{}\nstderr:\n{}",
repo.display(),
args.join(" "),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new();
features.insert(
"plugins".to_string(),
Value::Boolean(plugins_feature_enabled),
);
root.insert("features".to_string(), Value::Table(features));
let mut plugin = toml::map::Map::new();
plugin.insert("enabled".to_string(), Value::Boolean(enabled));
let mut plugins = toml::map::Map::new();
plugins.insert("sample@test".to_string(), Value::Table(plugin));
root.insert("plugins".to_string(), Value::Table(plugins));
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize")
}
async fn load_plugins_from_config(
config_toml: &str,
codex_home: &Path,
auth_mode: Option<AuthMode>,
) -> PluginLoadOutcome {
write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml);
let config = load_config(codex_home, codex_home).await;
test_plugins_manager_with_options(codex_home.to_path_buf(), Some(Product::Codex), auth_mode)
.plugins_for_config(&config)
.await
}
async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput {
load_plugins_config_input(codex_home, cwd).await
}
fn remote_installed_linear_plugin() -> RemoteInstalledPlugin {
remote_installed_plugin("linear")
}
fn remote_installed_plugin(name: &str) -> RemoteInstalledPlugin {
remote_installed_plugin_in_marketplace(name, REMOTE_GLOBAL_MARKETPLACE_NAME)
}
fn remote_installed_plugin_in_marketplace(
name: &str,
marketplace_name: &str,
) -> RemoteInstalledPlugin {
RemoteInstalledPlugin {
marketplace_name: marketplace_name.to_string(),
id: format!("plugins~Plugin_{name}"),
version: None,
name: name.to_string(),
installed_at: None,
enabled: true,
install_policy: codex_app_server_protocol::PluginInstallPolicy::Available,
install_policy_source: None,
must_show_installation_interstitial: None,
auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
disabled_reason: None,
eligible_plan_types: None,
interface: None,
keywords: Vec::new(),
}
}
fn write_cached_plugin(codex_home: &Path, marketplace_name: &str, plugin_name: &str) {
write_plugin_with_version(
&codex_home
.join("plugins/cache")
.join(marketplace_name)
.join(plugin_name),
"local",
plugin_name,
/*manifest_version*/ Some("local"),
);
}
async fn loaded_plugin_names(manager: &PluginsManager, config: &PluginsConfigInput) -> Vec<String> {
manager
.plugins_for_config(config)
.await
.plugins()
.iter()
.map(|plugin| plugin.config_name.clone())
.collect()
}
#[tokio::test]
async fn load_plugins_loads_default_skills_and_mcp_servers() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "sample",
"description": "Plugin that includes the sample MCP server and Skills"
}"#,
);
write_file(
&plugin_root.join("skills/sample-search/SKILL.md"),
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp",
"oauth": {
"clientId": "client-id",
"callbackPort": 3118
}
}
}
}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"example": {
"id": "connector_example"
}
}
}"#,
);
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
Some(AuthMode::Chatgpt),
)
.await;
assert_eq!(
outcome.plugins(),
vec![LoadedPlugin {
config_name: "sample@test".to_string(),
remote_plugin_id: None,
manifest_name: Some("sample".to_string()),
plugin_namespace: Some("sample".to_string()),
manifest_description: Some(
"Plugin that includes the sample MCP server and Skills".to_string(),
),
root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(),
enabled: true,
skill_roots: vec![plugin_root.join("skills").abs()],
skill_discovery_mode: SkillDiscoveryMode::Recursive,
disabled_skill_paths: HashSet::new(),
has_enabled_skills: true,
mcp_servers: HashMap::from([(
"sample".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::StreamableHttp {
url: "https://sample.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
http_headers_helper: None,
},
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
omit_tools_from: None,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: Some(McpServerOAuthConfig {
client_id: Some("client-id".to_string()),
callback_port: Some(3118),
}),
oauth_resource: None,
tools: HashMap::new(),
},
)]),
apps: vec![app_declaration("example", "connector_example")],
hook_sources: Vec::new(),
hook_load_warnings: Vec::new(),
error: None,
}]
);
assert_eq!(
outcome.capability_summaries(),
&[PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
plugin_namespace: Some("sample".to_string()),
description: Some("Plugin that includes the sample MCP server and Skills".to_string(),),
has_skills: true,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: vec![AppConnectorId("connector_example".to_string())],
}]
);
assert_eq!(
outcome
.effective_plugin_skill_roots()
.into_iter()
.map(|root| root.path)
.collect::<Vec<_>>(),
vec![plugin_root.join("skills").abs()]
);
assert_eq!(outcome.effective_mcp_servers().len(), 1);
assert_eq!(
outcome.effective_apps(),
vec![AppConnectorId("connector_example".to_string())]
);
}
#[tokio::test]
async fn load_plugins_loads_manifest_mcp_server_objects() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/counter-sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "counter-sample",
"version": "1.1.1",
"description": "Plugin that declares MCP servers in the manifest",
"mcpServers": {
"counter": {
"type": "http",
"url": "https://sample.example/counter/mcp"
}
}
}"#,
);
let config_toml = r#"
[features]
plugins = true
[plugins."counter-sample@test"]
enabled = true
"#;
let outcome =
load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await;
assert_eq!(outcome.plugins()[0].error, None);
assert_eq!(
outcome.plugins()[0].mcp_servers,
HashMap::from([(
"counter".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::StreamableHttp {
url: "https://sample.example/counter/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
http_headers_helper: None,
},
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
omit_tools_from: None,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)])
);
}
#[tokio::test]
async fn load_plugins_applies_plugin_mcp_server_policy() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "sample"
}"#,
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp",
"default_tools_approval_mode": "prompt",
"enabled_tools": ["read", "search"],
"tools": {
"search": { "approval_mode": "prompt" }
}
}
}
}"#,
);
let config_toml = r#"
[features]
plugins = true
[plugins."sample@test"]
enabled = true
[plugins."sample@test".mcp_servers.sample]
enabled = false
default_tools_approval_mode = "approve"
enabled_tools = ["search"]
disabled_tools = ["delete"]
[plugins."sample@test".mcp_servers.sample.tools.search]
approval_mode = "approve"
"#;
let outcome =
load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await;
let server = outcome.plugins()[0]
.mcp_servers
.get("sample")
.expect("sample server");
assert!(!server.enabled);
assert_eq!(
server.default_tools_approval_mode,
Some(AppToolApproval::Approve)
);
assert_eq!(server.enabled_tools, Some(vec!["search".to_string()]));
assert_eq!(server.disabled_tools, Some(vec!["delete".to_string()]));
assert_eq!(
server.tools.get("search"),
Some(&McpServerToolConfig {
approval_mode: Some(AppToolApproval::Approve),
})
);
}
#[tokio::test]
async fn remote_installed_plugin_preserves_configured_mcp_server_policy() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache/openai-curated-remote/linear/local");
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"linear": {
"type": "http",
"url": "https://linear.example/mcp"
}
}
}"#,
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated-remote"]
enabled = false
[plugins."linear@openai-curated-remote".mcp_servers.linear]
enabled = false
default_tools_approval_mode = "approve"
enabled_tools = ["search"]
disabled_tools = ["delete"]
[plugins."linear@openai-curated-remote".mcp_servers.linear.tools.search]
approval_mode = "approve"
"#,
);
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]);
let outcome = manager.plugins_for_config(&config).await;
let plugin = outcome
.plugins()
.iter()
.find(|plugin| plugin.config_name == "linear@openai-curated-remote")
.expect("remote plugin should be loaded");
let expected_server = serde_json::from_value::<McpServerConfig>(serde_json::json!({
"url": "https://linear.example/mcp",
"enabled": false,
"default_tools_approval_mode": "approve",
"enabled_tools": ["search"],
"disabled_tools": ["delete"],
"tools": {
"search": { "approval_mode": "approve" }
}
}))
.expect("valid expected MCP server");
assert!(plugin.enabled);
assert_eq!(
plugin.mcp_servers,
HashMap::from([("linear".to_string(), expected_server)])
);
}
#[tokio::test]
async fn remote_installed_cache_ignores_plugins_missing_local_cache() {
let codex_home = TempDir::new().unwrap();
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = test_plugins_manager(codex_home.path().to_path_buf());
manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(outcome, PluginLoadOutcome::default());
}
#[tokio::test]
async fn installed_plugin_telemetry_metadata_collects_capabilities() {
let codex_home = TempDir::new().unwrap();
write_cached_plugin(codex_home.path(), "test", "sample");
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse");
let metadata = manager
.telemetry_metadata_for_installed_plugin(&plugin_id)
.await;
assert_eq!(
metadata,
PluginTelemetryMetadata {
plugin_id: Some(plugin_id),
remote_plugin_id: None,
capability_summary: Some(PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
plugin_namespace: Some("sample".to_string()),
description: None,
has_skills: true,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
}),
}
);
}
#[tokio::test]
async fn installed_agent_plugin_telemetry_metadata_uses_portable_capabilities() {
for (skill_path, has_skills) in [
("skills/direct/SKILL.md", true),
("skills/group/nested/SKILL.md", false),
] {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache/test/agent-plugin/local");
write_file(
&plugin_root.join("plugin.json"),
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"agent.tools"}"#,
);
write_file(
&plugin_root.join(skill_path),
"---\nname: portable\ndescription: Portable skill\n---\n",
);
write_file(
&plugin_root.join("mcp.json"),
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"portable":{"type":"stdio","command":"echo"}}}"#,
);
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"apps":"./.app.json"}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{"apps":{"legacy":{"id":"connector_legacy"}}}"#,
);
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let plugin_id = PluginId::parse("agent-plugin@test").expect("plugin id should parse");
let metadata = manager
.telemetry_metadata_for_installed_plugin(&plugin_id)
.await;
assert_eq!(
metadata,
PluginTelemetryMetadata {
plugin_id: Some(plugin_id),
remote_plugin_id: None,
capability_summary: Some(PluginCapabilitySummary {
config_name: "agent-plugin@test".to_string(),
display_name: "agent-plugin".to_string(),
plugin_namespace: Some("agent.tools".to_string()),
description: None,
has_skills,
mcp_server_names: vec!["portable".to_string()],
app_connector_ids: Vec::new(),
}),
}
);
}
}
#[tokio::test]
async fn installed_plugin_telemetry_metadata_resolves_persisted_remote_identity() {
let codex_home = TempDir::new().unwrap();
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
let plugin_id =
PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse");
PluginStore::new(codex_home.path().to_path_buf())
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_linear")
.expect("persist remote plugin id");
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let metadata = manager
.telemetry_metadata_for_installed_plugin(&plugin_id)
.await;
assert_eq!(
metadata,
PluginTelemetryMetadata {
plugin_id: Some(plugin_id),
remote_plugin_id: Some("plugins~Plugin_linear".to_string()),
capability_summary: Some(PluginCapabilitySummary {
config_name: "linear@openai-curated-remote".to_string(),
display_name: "linear".to_string(),
plugin_namespace: Some("linear".to_string()),
description: None,
has_skills: true,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
}),
}
);
}
#[test]
fn plugin_telemetry_ignores_local_marketplace_sidecars() {
let codex_home = TempDir::new().unwrap();
write_cached_plugin(codex_home.path(), "test", "sample");
let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse");
PluginStore::new(codex_home.path().to_path_buf())
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample")
.expect("persist remote plugin id");
let manager = test_plugins_manager(codex_home.path().to_path_buf());
assert_eq!(
manager.telemetry_metadata_for_plugin_id(&plugin_id),
PluginTelemetryMetadata {
plugin_id: Some(plugin_id),
remote_plugin_id: None,
capability_summary: None,
}
);
}
#[tokio::test]
async fn installed_plugin_telemetry_metadata_prefers_remote_snapshot_identity() {
let codex_home = TempDir::new().unwrap();
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
let plugin_id =
PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse");
PluginStore::new(codex_home.path().to_path_buf())
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_stale")
.expect("persist remote plugin id");
let manager = test_plugins_manager(codex_home.path().to_path_buf());
manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]);
let metadata = manager
.telemetry_metadata_for_installed_plugin(&plugin_id)
.await;
assert_eq!(
metadata,
PluginTelemetryMetadata {
plugin_id: Some(plugin_id),
remote_plugin_id: Some("plugins~Plugin_linear".to_string()),
capability_summary: Some(PluginCapabilitySummary {
config_name: "linear@openai-curated-remote".to_string(),
display_name: "linear".to_string(),
plugin_namespace: Some("linear".to_string()),
description: None,
has_skills: true,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
}),
}
);
}
#[tokio::test]
async fn installed_plugin_telemetry_metadata_accepts_authoritative_remote_identity() {
let codex_home = TempDir::new().unwrap();
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let plugin_id =
PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse");
let metadata = manager
.telemetry_metadata_for_installed_plugin_with_remote_id(&plugin_id, "plugins~Plugin_linear")
.await;
assert_eq!(
metadata,
PluginTelemetryMetadata {
plugin_id: Some(plugin_id),
remote_plugin_id: Some("plugins~Plugin_linear".to_string()),
capability_summary: None,
}
);
}
#[test]
fn capability_summary_telemetry_metadata_uses_local_identity() {
let codex_home = TempDir::new().unwrap();
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let summary = PluginCapabilitySummary {
config_name: "linear@openai-curated-remote".to_string(),
display_name: "Linear".to_string(),
plugin_namespace: Some("linear".to_string()),
description: Some("Track work".to_string()),
has_skills: true,
mcp_server_names: vec!["linear".to_string()],
app_connector_ids: vec![AppConnectorId("linear-app".to_string())],
};
let metadata = manager.telemetry_metadata_for_capability_summary(&summary);
assert_eq!(
metadata,
Some(PluginTelemetryMetadata {
plugin_id: Some(
PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"),
),
remote_plugin_id: None,
capability_summary: Some(summary),
})
);
}
#[test]
fn capability_summary_telemetry_metadata_resolves_persisted_remote_identity() {
let codex_home = TempDir::new().unwrap();
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
let plugin_id =
PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse");
PluginStore::new(codex_home.path().to_path_buf())
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_linear")
.expect("persist remote plugin id");
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let summary = PluginCapabilitySummary {
config_name: "linear@openai-curated-remote".to_string(),
display_name: "Linear".to_string(),
plugin_namespace: Some("linear".to_string()),
description: Some("Track work".to_string()),
has_skills: true,
mcp_server_names: vec!["linear".to_string()],
app_connector_ids: vec![AppConnectorId("linear-app".to_string())],
};
let metadata = manager.telemetry_metadata_for_capability_summary(&summary);
assert_eq!(
metadata,
Some(PluginTelemetryMetadata {
plugin_id: Some(plugin_id),
remote_plugin_id: Some("plugins~Plugin_linear".to_string()),
capability_summary: Some(summary),
})
);
}
#[tokio::test]
async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() {
let codex_home = TempDir::new().unwrap();
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
remote_plugin = false
[plugins."linear@openai-curated"]
enabled = true
[plugins."calendar@openai-curated"]
enabled = true
[plugins."linear@openai-api-curated"]
enabled = true
"#,
);
write_cached_plugin(codex_home.path(), "openai-curated", "linear");
write_cached_plugin(codex_home.path(), "openai-curated", "calendar");
write_cached_plugin(codex_home.path(), "openai-api-curated", "linear");
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only");
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
manager.write_remote_installed_plugins_cache(vec![
remote_installed_plugin("linear"),
remote_installed_plugin("remote-only"),
]);
assert_eq!(
loaded_plugin_names(&manager, &config).await,
vec![
"calendar@openai-curated".to_string(),
"linear@openai-curated".to_string(),
"remote-only@openai-curated-remote".to_string(),
]
);
}
#[tokio::test]
async fn api_curated_plugin_does_not_suppress_remote_curated_conflict_for_chatgpt() {
let codex_home = TempDir::new().unwrap();
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
remote_plugin = false
[plugins."linear@openai-api-curated"]
enabled = true
"#,
);
write_cached_plugin(codex_home.path(), "openai-api-curated", "linear");
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]);
assert_eq!(
loaded_plugin_names(&manager, &config).await,
vec!["linear@openai-curated-remote".to_string()]
);
}
#[tokio::test]
async fn remote_global_catalog_ignores_local_curated_plugins() {
let codex_home = TempDir::new().unwrap();
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = true
[plugins."linear@openai-api-curated"]
enabled = true
[plugins."calendar@openai-curated"]
enabled = true
"#,
);
write_cached_plugin(codex_home.path(), "openai-curated", "linear");
write_cached_plugin(codex_home.path(), "openai-api-curated", "linear");
write_cached_plugin(codex_home.path(), "openai-curated", "calendar");
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only");
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
manager.write_remote_installed_plugins_cache(vec![
remote_installed_plugin("linear"),
remote_installed_plugin("remote-only"),
]);
assert_eq!(
loaded_plugin_names(&manager, &config).await,
vec![
"linear@openai-curated-remote".to_string(),
"remote-only@openai-curated-remote".to_string(),
]
);
}
#[tokio::test]
async fn non_chatgpt_auth_rejects_cached_remote_curated_plugins_after_auth_switch() {
let codex_home = TempDir::new().unwrap();
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = true
[plugins."linear@openai-api-curated"]
enabled = true
"#,
);
write_cached_plugin(codex_home.path(), "openai-curated", "linear");
write_cached_plugin(codex_home.path(), "openai-api-curated", "linear");
write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only");
let mut config = load_config(codex_home.path(), codex_home.path()).await;
let auth_manager = test_auth_manager(/*auth_mode*/ None);
set_test_auth_mode(&auth_manager, Some(AuthMode::Chatgpt)).await;
let manager = test_plugins_manager_with_auth_manager(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Arc::clone(&auth_manager),
);
manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("remote-only")]);
assert_eq!(
loaded_plugin_names(&manager, &config).await,
vec!["remote-only@openai-curated-remote".to_string()]
);
set_test_auth_mode(&auth_manager, /*auth_mode*/ None).await;
assert_eq!(
loaded_plugin_names(&manager, &config).await,
vec!["linear@openai-api-curated".to_string()]
);
for auth_mode in [AuthMode::ApiKey, AuthMode::BedrockApiKey] {
set_test_auth_mode(&auth_manager, Some(auth_mode)).await;
assert_eq!(
loaded_plugin_names(&manager, &config).await,
vec!["linear@openai-api-curated".to_string()]
);
}
set_test_auth_mode(&auth_manager, /*auth_mode*/ None).await;
for model_provider_id in ["openai", AMAZON_BEDROCK_PROVIDER_ID, "ollama"] {
config.model_provider_id = model_provider_id.to_string();
assert_eq!(
loaded_plugin_names(&manager, &config).await,
vec!["linear@openai-api-curated".to_string()]
);
}
set_test_auth_mode(&auth_manager, Some(AuthMode::Chatgpt)).await;
assert_eq!(
loaded_plugin_names(&manager, &config).await,
vec!["remote-only@openai-curated-remote".to_string()]
);
}
#[tokio::test]
async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metadata() {
let codex_home = TempDir::new().unwrap();
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let mut plugin = remote_installed_linear_plugin();
plugin.install_policy = codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault;
plugin.auth_policy = codex_app_server_protocol::PluginAuthPolicy::OnInstall;
plugin.interface = Some(codex_app_server_protocol::PluginInterface {
display_name: Some("Linear".to_string()),
short_description: Some("Track remote work".to_string()),
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: Some("#111111".to_string()),
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(),
});
plugin.keywords = vec!["issues".to_string()];
manager.write_remote_installed_plugins_cache(vec![plugin]);
let marketplaces = manager
.build_remote_installed_plugin_marketplaces_from_cache(&[REMOTE_GLOBAL_MARKETPLACE_NAME])
.expect("remote installed cache should be present");
assert_eq!(marketplaces.len(), 1);
assert_eq!(marketplaces[0].name, "openai-curated-remote");
assert_eq!(marketplaces[0].display_name, "OpenAI Curated Remote");
assert_eq!(marketplaces[0].plugins.len(), 1);
let plugin = &marketplaces[0].plugins[0];
assert_eq!(plugin.id, "linear@openai-curated-remote");
assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear");
assert_eq!(plugin.name, "linear");
assert_eq!(plugin.installed, true);
assert_eq!(plugin.enabled, true);
assert_eq!(
plugin.install_policy,
codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault
);
assert_eq!(
plugin.auth_policy,
codex_app_server_protocol::PluginAuthPolicy::OnInstall
);
assert_eq!(plugin.keywords, vec!["issues".to_string()]);
assert_eq!(
plugin
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("Linear")
);
assert_eq!(
plugin
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref()),
Some("Track remote work")
);
assert_eq!(
manager
.build_remote_installed_plugin_marketplaces_from_cache(&[
REMOTE_WORKSPACE_MARKETPLACE_NAME
])
.expect("remote installed cache should be present"),
Vec::new()
);
}
#[tokio::test]
async fn build_remote_installed_plugin_marketplaces_from_cache_filters_by_marketplace_name() {
let codex_home = TempDir::new().unwrap();
let manager = test_plugins_manager(codex_home.path().to_path_buf());
manager.write_remote_installed_plugins_cache(vec![
remote_installed_plugin_in_marketplace(
"workspace-linear",
REMOTE_WORKSPACE_MARKETPLACE_NAME,
),
remote_installed_plugin_in_marketplace(
"shared-linear",
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
),
]);
let marketplaces = manager
.build_remote_installed_plugin_marketplaces_from_cache(&[REMOTE_WORKSPACE_MARKETPLACE_NAME])
.expect("remote installed cache should be present");
assert_eq!(marketplaces.len(), 1);
assert_eq!(marketplaces[0].name, REMOTE_WORKSPACE_MARKETPLACE_NAME);
assert_eq!(
marketplaces[0]
.plugins
.iter()
.map(|plugin| plugin.id.as_str())
.collect::<Vec<_>>(),
vec!["workspace-linear@workspace-directory"]
);
}
#[tokio::test]
async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
let skill_path = plugin_root.join("skills/sample-search/SKILL.md");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&skill_path,
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
let config_toml = r#"[features]
plugins = true
[[skills.config]]
name = "sample:sample-search"
enabled = false
[plugins."sample@test"]
enabled = true
"#;
let outcome =
load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await;
let skill_path = std::fs::canonicalize(skill_path)
.expect("skill path should canonicalize")
.abs();
assert_eq!(
outcome.plugins()[0].disabled_skill_paths,
HashSet::from([skill_path])
);
assert!(!outcome.plugins()[0].has_enabled_skills);
assert!(outcome.capability_summaries().is_empty());
}
#[tokio::test]
async fn load_plugins_ignores_unknown_disabled_skill_names() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join("skills/sample-search/SKILL.md"),
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
let config_toml = r#"[features]
plugins = true
[[skills.config]]
name = "sample:missing-skill"
enabled = false
[plugins."sample@test"]
enabled = true
"#;
let outcome =
load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await;
assert!(outcome.plugins()[0].disabled_skill_paths.is_empty());
assert!(outcome.plugins()[0].has_enabled_skills);
assert_eq!(
outcome.capability_summaries(),
&[PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
plugin_namespace: Some("sample".to_string()),
description: None,
has_skills: true,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
}]
);
}
#[tokio::test]
async fn plugin_telemetry_metadata_uses_default_mcp_config_path() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "sample"
}"#,
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
);
let summary = plugin_capability_summary_from_root(
&PluginId::parse("sample@test").expect("plugin id should parse"),
&plugin_root.abs(),
test_skill_root_loader().as_ref(),
)
.await;
assert_eq!(
summary,
Some(PluginCapabilitySummary {
config_name: "sample@test".to_string(),
display_name: "sample".to_string(),
plugin_namespace: Some("sample".to_string()),
description: None,
has_skills: false,
mcp_server_names: vec!["sample".to_string()],
app_connector_ids: Vec::new(),
})
);
}
#[tokio::test]
async fn plugin_capability_summary_uses_manifest_mcp_server_objects() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/counter-sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "counter-sample",
"version": "1.1.1",
"mcpServers": {
"counter": {
"type": "http",
"url": "https://sample.example/counter/mcp"
}
}
}"#,
);
let summary = plugin_capability_summary_from_root(
&PluginId::parse("counter-sample@test").expect("plugin id should parse"),
&plugin_root.abs(),
test_skill_root_loader().as_ref(),
)
.await;
assert_eq!(
summary,
Some(PluginCapabilitySummary {
config_name: "counter-sample@test".to_string(),
display_name: "counter-sample".to_string(),
plugin_namespace: Some("counter-sample".to_string()),
description: None,
has_skills: false,
mcp_server_names: vec!["counter".to_string()],
app_connector_ids: Vec::new(),
})
);
}
#[tokio::test]
async fn capability_summary_sanitizes_plugin_descriptions_to_one_line() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "sample",
"description": "Plugin that\n includes the sample\tserver"
}"#,
);
write_file(
&plugin_root.join("skills/sample-search/SKILL.md"),
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
assert_eq!(
outcome.plugins()[0].manifest_description.as_deref(),
Some("Plugin that\n includes the sample\tserver")
);
assert_eq!(
outcome.capability_summaries()[0].description.as_deref(),
Some("Plugin that includes the sample server")
);
}
#[tokio::test]
async fn capability_summary_truncates_overlong_plugin_descriptions() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
let too_long = "x".repeat(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN + 1);
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
&format!(
r#"{{
"name": "sample",
"description": "{too_long}"
}}"#
),
);
write_file(
&plugin_root.join("skills/sample-search/SKILL.md"),
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
assert_eq!(
outcome.plugins()[0].manifest_description.as_deref(),
Some(too_long.as_str())
);
assert_eq!(
outcome.capability_summaries()[0].description,
Some("x".repeat(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN))
);
}
#[tokio::test]
async fn load_plugins_uses_manifest_configured_component_paths() {
for (skills_json, expected_skill_dirs) in [
(r#""./custom-skills/""#, &["custom-skills"][..]),
(
r#"["./custom-skills/", "./extra-skills/"]"#,
&["custom-skills", "extra-skills"][..],
),
(
r#"["./custom-skills/", "./custom-skills/"]"#,
&["custom-skills"][..],
),
(r#""./skills/""#, &["skills"][..]),
(
r#"["./skills/abc/", "./skills/edk/"]"#,
&["skills/abc", "skills/edk"][..],
),
] {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
&format!(
r#"{{
"name": "sample",
"skills": {skills_json},
"mcpServers": "./config/custom.mcp.json",
"apps": "./config/custom.app.json"
}}"#
),
);
write_file(
&plugin_root.join("skills/default-skill/SKILL.md"),
"---\nname: default-skill\ndescription: default skill\n---\n",
);
write_file(
&plugin_root.join("skills/abc/SKILL.md"),
"---\nname: abc\ndescription: abc skill\n---\n",
);
write_file(
&plugin_root.join("skills/edk/SKILL.md"),
"---\nname: edk\ndescription: edk skill\n---\n",
);
write_file(
&plugin_root.join("custom-skills/custom-skill/SKILL.md"),
"---\nname: custom-skill\ndescription: custom skill\n---\n",
);
write_file(
&plugin_root.join("extra-skills/extra-skill/SKILL.md"),
"---\nname: extra-skill\ndescription: extra skill\n---\n",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"default": {
"type": "http",
"url": "https://default.example/mcp"
}
}
}"#,
);
write_file(
&plugin_root.join("config/custom.mcp.json"),
r#"{
"mcpServers": {
"custom": {
"type": "http",
"url": "https://custom.example/mcp"
}
}
}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"default-app": {
"id": "connector_default"
}
}
}"#,
);
write_file(
&plugin_root.join("config/custom.app.json"),
r#"{
"apps": {
"custom-app": {
"id": "connector_custom"
}
}
}"#,
);
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
Some(AuthMode::Chatgpt),
)
.await;
let mut expected_skill_roots = expected_skill_dirs
.iter()
.map(|dir| plugin_root.join(dir).abs())
.collect::<Vec<_>>();
expected_skill_roots.sort_unstable();
expected_skill_roots.dedup();
assert_eq!(outcome.plugins()[0].skill_roots, expected_skill_roots);
assert_eq!(
outcome.plugins()[0].mcp_servers,
HashMap::from([(
"custom".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::StreamableHttp {
url: "https://custom.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
http_headers_helper: None,
},
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
omit_tools_from: None,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)])
);
assert_eq!(
outcome.plugins()[0].apps,
vec![app_declaration("custom-app", "connector_custom")]
);
}
}
#[tokio::test]
async fn install_plugin_materializes_default_command_skills() {
let codex_home = TempDir::new().unwrap();
let source_root = codex_home.path().join("source/sample");
write_file(
&source_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "sample",
"skills": "./custom-skills/"
}"#,
);
fs::create_dir_all(source_root.join("custom-skills")).unwrap();
write_file(
&source_root.join("custom-skills/source-command-pr-review/SKILL.md"),
"---\nname: source-command-pr-review\ndescription: Native review skill\n---\n",
);
write_file(
&source_root.join("commands/pr/review.md"),
"---\ndescription: Review a pull request\n---\nInspect the proposed changes.\n",
);
write_file(
&source_root.join("commands/summarize.md"),
"---\ndescription: Summarize a change\n---\nSummarize the proposed changes.\n",
);
write_file(
&source_root.join("commands/oversized.md"),
&format!("---\ndescription: Oversized\n---\n{}", "x".repeat(4_000)),
);
write_file(
&source_root.join(".codex-plugin/migrated-command-skills/undeclared-command/SKILL.md"),
"---\nname: undeclared-command\ndescription: undeclared command\n---\n",
);
let result = PluginStore::new(codex_home.path().to_path_buf())
.install(
source_root.abs(),
PluginId::parse("sample@test").expect("plugin id should parse"),
)
.unwrap();
let migrated_skill = result
.installed_path
.join(".codex-plugin/migrated-command-skills/source-command-pr-review/SKILL.md");
let expected_migrated_skill = "---\nname: \"source-command-pr-review\"\ndescription: \"Review a pull request\"\n---\n\n# source-command-pr-review\n\nUse this skill when the user asks to run the migrated source command `pr-review`.\n\n## Command Template\n\nInspect the proposed changes.\n";
assert_eq!(
fs::read_to_string(&migrated_skill).unwrap(),
expected_migrated_skill
);
assert!(
!result
.installed_path
.join(".codex-plugin/migrated-command-skills/undeclared-command")
.exists()
);
assert!(
!result
.installed_path
.join(".codex-plugin/migrated-command-skills/source-command-oversized")
.exists()
);
let manifest = crate::manifest::load_plugin_manifest(&result.installed_path).unwrap();
let resolved = load_plugin_skill_inventory(
&result.installed_path,
&PluginIdentity {
plugin_id: result.plugin_id.as_key(),
remote_plugin_id: None,
},
&manifest,
PluginManifestFormat::Legacy,
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
test_skill_root_loader().as_ref(),
)
.await
.resolve(&SkillConfigRules::default());
assert_eq!(
resolved
.skills
.iter()
.map(|skill| skill.path_to_skills_md.clone())
.collect::<Vec<_>>(),
vec![
AbsolutePathBuf::from_absolute_path_checked(
fs::canonicalize(
result
.installed_path
.join("custom-skills/source-command-pr-review/SKILL.md")
)
.unwrap()
)
.unwrap(),
AbsolutePathBuf::from_absolute_path_checked(
fs::canonicalize(result.installed_path.join(
".codex-plugin/migrated-command-skills/source-command-summarize/SKILL.md"
))
.unwrap()
)
.unwrap()
]
);
}
#[test]
fn install_plugin_ignores_invalid_commands_manifest_field() {
let codex_home = TempDir::new().unwrap();
let source_root = codex_home.path().join("source/sample");
write_file(
&source_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample","commands":{}}"#,
);
write_file(
&source_root.join("commands/review.md"),
"---\ndescription: Review\n---\nReview the current change.\n",
);
let result = PluginStore::new(codex_home.path().to_path_buf())
.install(
source_root.abs(),
PluginId::parse("sample@test").expect("plugin id should parse"),
)
.unwrap();
assert!(
!result
.installed_path
.join(".codex-plugin/migrated-command-skills")
.exists()
);
}
#[test]
fn install_plugin_ignores_command_migration_errors() {
let codex_home = TempDir::new().unwrap();
let source_root = codex_home.path().join("source/sample");
write_file(
&source_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample","commands":"./commands/review.md"}"#,
);
fs::create_dir_all(source_root.join("commands")).unwrap();
fs::write(source_root.join("commands/review.md"), [0xff]).unwrap();
let result = PluginStore::new(codex_home.path().to_path_buf())
.install(
source_root.abs(),
PluginId::parse("sample@test").expect("plugin id should parse"),
)
.unwrap();
assert!(result.installed_path.join("commands/review.md").is_file());
}
#[tokio::test]
async fn load_plugin_skills_dedupes_overlapping_manifest_roots() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local")
.abs();
write_file(
&plugin_root.join("skills/abc/SKILL.md"),
"---\nname: abc\ndescription: abc skill\n---\n",
);
write_file(
&plugin_root.join("skills/edk/SKILL.md"),
"---\nname: edk\ndescription: edk skill\n---\n",
);
let manifest = crate::manifest::PluginManifest {
name: "sample".to_string(),
version: None,
description: None,
keywords: Vec::new(),
paths: crate::manifest::PluginManifestPaths {
skills: vec![
plugin_root.join("skills"),
plugin_root.join("skills/abc"),
plugin_root.join("skills/edk"),
plugin_root.join("skills/abc"),
],
mcp_servers: None,
apps: None,
hooks: None,
},
interface: None,
};
let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse");
let resolved = load_plugin_skill_inventory(
&plugin_root,
&PluginIdentity {
plugin_id: plugin_id.as_key(),
remote_plugin_id: None,
},
&manifest,
PluginManifestFormat::Legacy,
/*restriction_product*/ None,
/*plugin_skill_snapshots*/ None,
test_skill_root_loader().as_ref(),
)
.await
.resolve(&SkillConfigRules::default());
let skill_paths = resolved
.skills
.iter()
.map(|skill| skill.path_to_skills_md.clone())
.collect::<Vec<_>>();
let canonical_skill_path = |path| {
AbsolutePathBuf::from_absolute_path_checked(
fs::canonicalize(plugin_root.join(path)).expect("canonical skill path"),
)
.expect("absolute skill path")
};
assert_eq!(
skill_paths,
vec![
canonical_skill_path("skills/abc/SKILL.md"),
canonical_skill_path("skills/edk/SKILL.md")
]
);
}
#[tokio::test]
async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "sample",
"skills": "custom-skills",
"mcpServers": "config/custom.mcp.json",
"apps": "config/custom.app.json"
}"#,
);
write_file(
&plugin_root.join("skills/default-skill/SKILL.md"),
"---\nname: default-skill\ndescription: default skill\n---\n",
);
write_file(
&plugin_root.join("custom-skills/custom-skill/SKILL.md"),
"---\nname: custom-skill\ndescription: custom skill\n---\n",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"default": {
"type": "http",
"url": "https://default.example/mcp"
}
}
}"#,
);
write_file(
&plugin_root.join("config/custom.mcp.json"),
r#"{
"mcpServers": {
"custom": {
"type": "http",
"url": "https://custom.example/mcp"
}
}
}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"default-app": {
"id": "connector_default"
}
}
}"#,
);
write_file(
&plugin_root.join("config/custom.app.json"),
r#"{
"apps": {
"custom-app": {
"id": "connector_custom"
}
}
}"#,
);
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
Some(AuthMode::Chatgpt),
)
.await;
assert_eq!(
outcome.plugins()[0].skill_roots,
vec![plugin_root.join("skills").abs()]
);
assert_eq!(
outcome.plugins()[0].mcp_servers,
HashMap::from([(
"default".to_string(),
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::StreamableHttp {
url: "https://default.example/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
http_headers_helper: None,
},
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
omit_tools_from: None,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
},
)])
);
assert_eq!(
outcome.plugins()[0].apps,
vec![app_declaration("default-app", "connector_default")]
);
}
#[tokio::test]
async fn load_plugins_ignores_invalid_manifest_skills_shape() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "sample",
"skills": { "path": "./custom-skills/" }
}"#,
);
write_file(
&plugin_root.join("skills/default-skill/SKILL.md"),
"---\nname: default-skill\ndescription: default skill\n---\n",
);
write_file(
&plugin_root.join("custom-skills/custom-skill/SKILL.md"),
"---\nname: custom-skill\ndescription: custom skill\n---\n",
);
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
assert_eq!(outcome.plugins()[0].error, None);
assert_eq!(
outcome.plugins()[0].skill_roots,
vec![plugin_root.join("skills").abs()]
);
}
#[tokio::test]
async fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
);
let outcome = load_plugins_from_config(
&plugin_config_toml(
/*enabled*/ false, /*plugins_feature_enabled*/ true,
),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
assert_eq!(
outcome.plugins(),
vec![LoadedPlugin {
config_name: "sample@test".to_string(),
remote_plugin_id: None,
manifest_name: None,
plugin_namespace: None,
manifest_description: None,
root: AbsolutePathBuf::try_from(plugin_root).unwrap(),
enabled: false,
skill_roots: Vec::new(),
skill_discovery_mode: SkillDiscoveryMode::Recursive,
disabled_skill_paths: HashSet::new(),
has_enabled_skills: false,
mcp_servers: HashMap::new(),
apps: Vec::new(),
hook_sources: Vec::new(),
hook_load_warnings: Vec::new(),
error: None,
}]
);
assert!(outcome.effective_plugin_skill_roots().is_empty());
assert!(outcome.effective_mcp_servers().is_empty());
}
#[tokio::test]
async fn effective_apps_dedupes_connector_ids_across_plugins() {
let codex_home = TempDir::new().unwrap();
let plugin_a_root = codex_home
.path()
.join("plugins/cache")
.join("test/plugin-a/local");
let plugin_b_root = codex_home
.path()
.join("plugins/cache")
.join("test/plugin-b/local");
write_file(
&plugin_a_root.join(".codex-plugin/plugin.json"),
r#"{"name":"plugin-a"}"#,
);
write_file(
&plugin_a_root.join(".app.json"),
r#"{
"apps": {
"example": {
"id": "connector_example"
}
}
}"#,
);
write_file(
&plugin_b_root.join(".codex-plugin/plugin.json"),
r#"{"name":"plugin-b"}"#,
);
write_file(
&plugin_b_root.join(".app.json"),
r#"{
"apps": {
"chat": {
"id": "connector_example"
},
"gmail": {
"id": "connector_gmail"
}
}
}"#,
);
let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new();
features.insert("plugins".to_string(), Value::Boolean(true));
features.insert("apps".to_string(), Value::Boolean(true));
root.insert("features".to_string(), Value::Table(features));
let mut plugins = toml::map::Map::new();
let mut plugin_a = toml::map::Map::new();
plugin_a.insert("enabled".to_string(), Value::Boolean(true));
plugins.insert("plugin-a@test".to_string(), Value::Table(plugin_a));
let mut plugin_b = toml::map::Map::new();
plugin_b.insert("enabled".to_string(), Value::Boolean(true));
plugins.insert("plugin-b@test".to_string(), Value::Table(plugin_b));
root.insert("plugins".to_string(), Value::Table(plugins));
let config_toml =
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize");
let outcome =
load_plugins_from_config(&config_toml, codex_home.path(), Some(AuthMode::Chatgpt)).await;
assert_eq!(
outcome.effective_apps(),
vec![
AppConnectorId("connector_example".to_string()),
AppConnectorId("connector_gmail".to_string()),
]
);
}
#[tokio::test]
async fn effective_apps_preserves_app_config_order() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"slack": {
"id": "connector_slack"
},
"github": {
"id": "connector_github"
},
"slack-copy": {
"id": "connector_slack"
}
}
}"#,
);
let outcome = load_plugins_from_config(
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
codex_home.path(),
Some(AuthMode::Chatgpt),
)
.await;
assert_eq!(
outcome.effective_apps(),
vec![
AppConnectorId("connector_slack".to_string()),
AppConnectorId("connector_github".to_string()),
]
);
}
#[test]
fn capability_index_filters_inactive_and_zero_capability_plugins() {
let codex_home = TempDir::new().unwrap();
let connector = |id: &str| AppConnectorId(id.to_string());
let app = |name: &str, connector_id: &str| app_declaration(name, connector_id);
let http_server = |url: &str| McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::StreamableHttp {
url: url.to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
http_headers_helper: None,
},
environment_id: "local".to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
omit_tools_from: None,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
};
let plugin = |config_name: &str, dir_name: &str, manifest_name: &str| LoadedPlugin {
config_name: config_name.to_string(),
remote_plugin_id: None,
manifest_name: Some(manifest_name.to_string()),
plugin_namespace: Some(
config_name
.split_once('@')
.map_or(config_name, |(name, _)| name)
.to_string(),
),
manifest_description: None,
root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(),
enabled: true,
skill_roots: Vec::new(),
skill_discovery_mode: SkillDiscoveryMode::Recursive,
disabled_skill_paths: HashSet::new(),
has_enabled_skills: false,
mcp_servers: HashMap::new(),
apps: Vec::new(),
hook_sources: Vec::new(),
hook_load_warnings: Vec::new(),
error: None,
};
let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary {
config_name: config_name.to_string(),
display_name: display_name.to_string(),
plugin_namespace: Some(
config_name
.split_once('@')
.map_or(config_name, |(name, _)| name)
.to_string(),
),
description: None,
..PluginCapabilitySummary::default()
};
let outcome = PluginLoadOutcome::from_plugins(vec![
LoadedPlugin {
skill_roots: vec![codex_home.path().join("skills-plugin/skills").abs()],
has_enabled_skills: true,
..plugin("skills@test", "skills-plugin", "skills-plugin")
},
LoadedPlugin {
mcp_servers: HashMap::from([("alpha".to_string(), http_server("https://alpha"))]),
apps: vec![app("example", "connector_example")],
..plugin("alpha@test", "alpha-plugin", "alpha-plugin")
},
LoadedPlugin {
mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]),
apps: vec![
app("example", "connector_example"),
app("gmail", "connector_gmail"),
],
..plugin("beta@test", "beta-plugin", "beta-plugin")
},
plugin("empty@test", "empty-plugin", "empty-plugin"),
LoadedPlugin {
enabled: false,
skill_roots: vec![codex_home.path().join("disabled-plugin/skills").abs()],
apps: vec![app("hidden", "connector_hidden")],
..plugin("disabled@test", "disabled-plugin", "disabled-plugin")
},
LoadedPlugin {
apps: vec![app("broken", "connector_broken")],
error: Some("failed to load".to_string()),
..plugin("broken@test", "broken-plugin", "broken-plugin")
},
]);
assert_eq!(
outcome.capability_summaries(),
&[
PluginCapabilitySummary {
has_skills: true,
..summary("skills@test", "skills-plugin")
},
PluginCapabilitySummary {
mcp_server_names: vec!["alpha".to_string()],
app_connector_ids: vec![connector("connector_example")],
..summary("alpha@test", "alpha-plugin")
},
PluginCapabilitySummary {
mcp_server_names: vec!["beta".to_string()],
app_connector_ids: vec![
connector("connector_example"),
connector("connector_gmail"),
],
..summary("beta@test", "beta-plugin")
},
]
);
}
#[tokio::test]
async fn load_plugins_returns_empty_when_feature_disabled() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&plugin_root.join("skills/sample-search/SKILL.md"),
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&plugin_config_toml(
/*enabled*/ true, /*plugins_feature_enabled*/ false,
),
);
let config = load_config(codex_home.path(), codex_home.path()).await;
let outcome = test_plugins_manager(codex_home.path().to_path_buf())
.plugins_for_config(&config)
.await;
assert_eq!(outcome, PluginLoadOutcome::default());
}
#[tokio::test]
async fn plugin_cache_ignores_unrelated_session_overrides() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_plugin(
codex_home.path().join("plugins/cache/test").as_path(),
"sample/local",
"sample",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample": {
"url": "https://sample.example/mcp"
}
}
}"#,
);
let user_file = codex_home.path().join(CONFIG_TOML_FILE).abs();
let user_config: toml::Value = toml::from_str(&plugin_config_toml(
/*enabled*/ true, /*plugins_feature_enabled*/ true,
))
.expect("user config should parse");
let stack = |session_config: &str| {
ConfigLayerStack::new(
vec![
ConfigLayerEntry::new(
ConfigLayerSource::User {
file: user_file.clone(),
profile: None,
},
user_config.clone(),
),
ConfigLayerEntry::new(
ConfigLayerSource::SessionFlags,
toml::from_str(session_config).expect("session config should parse"),
),
],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack should build")
};
let config = |session_config| {
PluginsConfigInput::new(
stack(session_config),
String::new(),
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ false,
"https://chatgpt.com".to_string(),
test_http_client_factory(),
)
};
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let first = manager
.plugins_for_config(&config(r#"model = "first""#))
.await;
std::fs::remove_file(plugin_root.join(".mcp.json")).unwrap();
let second = manager
.plugins_for_config(&config(r#"model = "second""#))
.await;
assert_eq!(second, first);
assert_eq!(second.plugins()[0].mcp_servers.len(), 1);
}
#[tokio::test]
async fn skill_roots_resolve_remote_plugin_identity_from_authoritative_source() {
let mut duplicate_plugin = remote_installed_plugin("sample");
duplicate_plugin.id = "plugins~Plugin_duplicate".to_string();
for (installed_plugins, expected_remote_plugin_id) in [
(None, Some("plugins~Plugin_persisted")),
(Some(Vec::new()), None),
(
Some(vec![remote_installed_plugin("sample"), duplicate_plugin]),
Some("plugins~Plugin_sample"),
),
] {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache/openai-curated-remote/sample/local");
write_plugin(
codex_home
.path()
.join("plugins/cache/openai-curated-remote")
.as_path(),
"sample/local",
"sample",
);
write_file(
&plugin_root.join("skills/SKILL.md"),
"---\nname: search\ndescription: first\n---\n",
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
remote_plugin = true
[plugins."sample@openai-curated-remote"]
enabled = true
"#,
);
let plugin_id =
PluginId::parse("sample@openai-curated-remote").expect("remote plugin id should parse");
PluginStore::new(codex_home.path().to_path_buf())
.write_remote_plugin_id(&plugin_id, "plugins~Plugin_persisted")
.expect("persist remote plugin id");
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
if let Some(installed_plugins) = installed_plugins {
manager.write_remote_installed_plugins_cache(installed_plugins);
}
let plugin_outcome = manager.plugins_for_config(&config).await;
assert_eq!(
manager
.telemetry_metadata_for_plugin_id(&plugin_id)
.remote_plugin_id
.as_deref(),
expected_remote_plugin_id
);
assert_eq!(
plugin_outcome
.effective_plugin_skill_roots()
.into_iter()
.map(|root| {
(
root.plugin_identity.plugin_id,
root.plugin_identity.remote_plugin_id,
)
})
.collect::<Vec<_>>(),
vec![(
"sample@openai-curated-remote".to_string(),
expected_remote_plugin_id.map(str::to_string),
)],
);
}
}
#[test]
fn loaded_plugins_cache_invalidation_rejects_stale_load_completion() {
let codex_home = TempDir::new().unwrap();
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let cache_key = PluginLoadCacheKey {
configured_plugins: HashMap::new(),
skill_config_rules: SkillConfigRules::default(),
remote_global_catalog_active: false,
auth_identity: None,
};
let stale_generation = manager.loaded_plugins_cache_generation();
manager.clear_loaded_plugins_cache();
manager.cache_loaded_plugins_if_current(
stale_generation,
cache_key.clone(),
Vec::new(),
crate::skill_snapshots::new_plugin_skill_snapshots(),
);
assert_eq!(manager.cached_loaded_plugins(&cache_key), None);
}
#[tokio::test]
async fn plugins_for_config_discards_in_flight_load_after_account_change() {
let codex_home = TempDir::new().unwrap();
write_cached_plugin(
codex_home.path(),
REMOTE_GLOBAL_MARKETPLACE_NAME,
"remote-only",
);
let config = PluginsConfigInput::new(
unrestricted_config_layer_stack(),
String::new(),
/*plugins_enabled*/ true,
/*remote_plugin_enabled*/ true,
String::new(),
test_http_client_factory(),
);
let auth_manager = test_auth_manager(Some(AuthMode::ChatgptAuthTokens));
let manager = Arc::new(test_plugins_manager_with_auth_manager(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Arc::clone(&auth_manager),
));
manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("remote-only")]);
let account_a = auth_manager
.auth_cached()
.expect("capture first ChatGPT auth");
let load_permit = manager
.loaded_plugins_load_semaphore
.try_acquire()
.expect("hold plugin load semaphore");
let load = manager.plugins_for_config(&config);
tokio::pin!(load);
assert!(matches!(
futures::poll!(load.as_mut()),
std::task::Poll::Pending
));
let account_b = CodexAuth::from_external_chatgpt_tokens(
"header.e30.other",
"other-account",
/*chatgpt_plan_type*/ None,
)
.expect("build second ChatGPT auth");
set_test_auth(&auth_manager, Some(account_b)).await;
drop(load_permit);
assert_eq!(load.await, PluginLoadOutcome::default());
set_test_auth(&auth_manager, Some(account_a)).await;
assert_eq!(
loaded_plugin_names(&manager, &config).await,
vec!["remote-only@openai-curated-remote".to_string()]
);
}
#[tokio::test]
async fn load_plugins_rejects_invalid_plugin_keys() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
let mut root = toml::map::Map::new();
let mut features = toml::map::Map::new();
features.insert("plugins".to_string(), Value::Boolean(true));
root.insert("features".to_string(), Value::Table(features));
let mut plugin = toml::map::Map::new();
plugin.insert("enabled".to_string(), Value::Boolean(true));
let mut plugins = toml::map::Map::new();
plugins.insert("sample".to_string(), Value::Table(plugin));
root.insert("plugins".to_string(), Value::Table(plugins));
let outcome = load_plugins_from_config(
&toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"),
codex_home.path(),
/*auth_mode*/ None,
)
.await;
assert_eq!(outcome.plugins().len(), 1);
assert_eq!(
outcome.plugins()[0].error.as_deref(),
Some("invalid plugin key `sample`; expected <plugin>@<marketplace>")
);
assert!(outcome.effective_plugin_skill_roots().is_empty());
assert!(outcome.effective_mcp_servers().is_empty());
}
#[tokio::test]
async fn install_plugin_updates_config_with_relative_path_and_plugin_key() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin(&repo_root, "sample-plugin", "sample-plugin");
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
},
"policy": {
"authentication": "ON_USE"
}
}
]
}"#,
)
.unwrap();
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
PluginInstallRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
assert_eq!(
result,
PluginInstallOutcome {
plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(),
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnUse,
}
);
let config = fs::read_to_string(tmp.path().join("config.toml")).unwrap();
assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#));
assert!(config.contains("enabled = true"));
}
#[tokio::test]
async fn strict_install_requires_allowed_local_marketplace_to_be_added_first() {
let codex_home = TempDir::new().expect("create Codex home");
let marketplace_root = codex_home.path().join("company-marketplace");
write_plugin(&marketplace_root, "sample", "sample");
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "company",
"plugins": [
{
"name": "sample",
"source": {"source": "local", "path": "./sample"}
}
]
}"#,
);
let marketplace_root = marketplace_root
.canonicalize()
.expect("canonical marketplace root");
let requirements = format!(
r#"
[marketplaces]
restrict_to_allowed_sources = true
[marketplaces.allowed_sources.company]
source = "local"
path = {marketplace_root:?}
"#
);
let config = config_layer_stack_with_requirements(codex_home.path(), "", &requirements);
let marketplace_path =
AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json"))
.expect("absolute marketplace path");
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let err = manager
.install_plugin(
&config,
PluginInstallRequest {
plugin_name: "sample".to_string(),
marketplace_path: marketplace_path.clone(),
},
)
.await
.expect_err("unconfigured local marketplace should not be installable in strict mode");
assert!(matches!(
err,
PluginInstallError::Marketplace(MarketplaceError::InvalidMarketplaceFile { .. })
));
assert!(err.to_string().contains("must be added to config"));
assert!(!codex_home.path().join(CONFIG_TOML_FILE).exists());
let user_config = format!(
r#"
[marketplaces.company]
source_type = "local"
source = {marketplace_root:?}
"#
);
write_file(&codex_home.path().join(CONFIG_TOML_FILE), &user_config);
let config =
config_layer_stack_with_requirements(codex_home.path(), &user_config, &requirements);
let outcome = manager
.install_plugin(
&config,
PluginInstallRequest {
plugin_name: "sample".to_string(),
marketplace_path,
},
)
.await
.expect("configured allowlisted marketplace should be installable");
assert_eq!(
outcome.plugin_id,
PluginId::new("sample".to_string(), "company".to_string()).expect("plugin id")
);
}
#[tokio::test]
async fn install_openai_curated_plugin_uses_short_sha_cache_version() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
PluginInstallRequest {
plugin_name: "slack".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
curated_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
let installed_path = tmp.path().join(format!(
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
));
assert_eq!(
result,
PluginInstallOutcome {
plugin_id: PluginId::new(
"slack".to_string(),
OPENAI_CURATED_MARKETPLACE_NAME.to_string()
)
.unwrap(),
plugin_version: TEST_CURATED_PLUGIN_CACHE_VERSION.to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
}
);
}
#[tokio::test]
async fn install_plugin_uses_manifest_version_for_non_curated_plugins() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(
&repo_root,
"sample-plugin",
"sample-plugin",
Some("1.2.3-beta+7"),
);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
)
.unwrap();
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
PluginInstallRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
let installed_path = tmp
.path()
.join("plugins/cache/debug/sample-plugin/1.2.3-beta+7");
assert_eq!(
result,
PluginInstallOutcome {
plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(),
plugin_version: "1.2.3-beta+7".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
}
);
}
#[tokio::test]
async fn install_plugin_writes_marketplace_manifest_fallback_when_missing_plugin_json() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("plugins/quality-review");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join("skills/thermo-nuclear-code-quality-review")).unwrap();
fs::write(
plugin_root.join("skills/thermo-nuclear-code-quality-review/SKILL.md"),
"review skill",
)
.unwrap();
write_file(
&plugin_root.join("commands/review.md"),
"---\ndescription: Review code\n---\nReview the current change.\n",
);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "quality-review",
"description": "Strict code quality review focused on maintainability.",
"source": "./plugins/quality-review",
"author": {
"name": "Byron Grogan"
},
"skills": [
"./skills/thermo-nuclear-code-quality-review"
],
"commands": ["./commands/review.md"],
"category": "code-review"
}
]
}"#,
)
.unwrap();
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
PluginInstallRequest {
plugin_name: "quality-review".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/quality-review/local");
assert_eq!(
result,
PluginInstallOutcome {
plugin_id: PluginId::new("quality-review".to_string(), "debug".to_string()).unwrap(),
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
}
);
assert!(!plugin_root.join(".codex-plugin/plugin.json").exists());
assert!(
!tmp.path()
.join("plugins/.marketplace-plugin-source-staging")
.exists()
);
let manifest = crate::manifest::load_plugin_manifest(&installed_path).unwrap();
assert_eq!(manifest.name, "quality-review");
assert_eq!(
manifest.description.as_deref(),
Some("Strict code quality review focused on maintainability.")
);
assert_eq!(
manifest.paths.skills,
vec![
AbsolutePathBuf::try_from(
installed_path.join("skills/thermo-nuclear-code-quality-review")
)
.unwrap()
]
);
let interface = manifest.interface.expect("fallback interface");
assert_eq!(interface.developer_name.as_deref(), Some("Byron Grogan"));
assert_eq!(interface.category.as_deref(), Some("code-review"));
let fallback_json: serde_json::Value = serde_json::from_str(
&fs::read_to_string(installed_path.join(".codex-plugin/plugin.json")).unwrap(),
)
.unwrap();
assert_eq!(
fallback_json["author"],
serde_json::json!({ "name": "Byron Grogan" })
);
assert_eq!(fallback_json["category"], "code-review");
assert_eq!(
fs::read_to_string(
installed_path
.join(".codex-plugin/migrated-command-skills/source-command-review/SKILL.md")
)
.unwrap(),
"---\nname: \"source-command-review\"\ndescription: \"Review code\"\n---\n\n# source-command-review\n\nUse this skill when the user asks to run the migrated source command `review`.\n\n## Command Template\n\nReview the current change.\n"
);
}
#[tokio::test]
async fn install_plugin_supports_git_subdir_marketplace_sources() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("marketplace");
let remote_repo = tmp.path().join("remote-plugin-repo");
let remote_repo_url = url::Url::from_directory_path(&remote_repo)
.unwrap()
.to_string();
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin(&remote_repo, "plugins/toolkit", "toolkit");
init_git_repo(&remote_repo);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
format!(
r#"{{
"name": "debug",
"plugins": [
{{
"name": "toolkit",
"source": {{
"source": "git-subdir",
"url": "{remote_repo_url}",
"path": "plugins/toolkit"
}}
}}
]
}}"#
),
)
.unwrap();
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
PluginInstallRequest {
plugin_name: "toolkit".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local");
assert_eq!(
result,
PluginInstallOutcome {
plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(),
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[tokio::test]
async fn install_plugin_supports_relative_git_subdir_marketplace_sources() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("marketplace");
let remote_repo = repo_root.join("remote-plugin-repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin(&remote_repo, "plugins/toolkit", "toolkit");
init_git_repo(&remote_repo);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "toolkit",
"source": {
"source": "git-subdir",
"url": "./remote-plugin-repo",
"path": "plugins/toolkit"
}
}
]
}"#,
)
.unwrap();
let result = test_plugins_manager(tmp.path().to_path_buf())
.install_plugin(
&unrestricted_config_layer_stack(),
PluginInstallRequest {
plugin_name: "toolkit".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local");
assert_eq!(
result,
PluginInstallOutcome {
plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(),
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[tokio::test]
async fn uninstall_plugin_removes_cache_and_config_entry() {
let tmp = tempfile::tempdir().unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/local",
"sample-plugin",
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
let manager = test_plugins_manager(tmp.path().to_path_buf());
manager
.uninstall_plugin("sample-plugin@debug".to_string())
.await
.unwrap();
manager
.uninstall_plugin("sample-plugin@debug".to_string())
.await
.unwrap();
assert!(
!tmp.path()
.join("plugins/cache/debug/sample-plugin")
.exists()
);
let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#));
}
#[tokio::test]
async fn list_marketplaces_includes_enabled_state() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"enabled-plugin/local",
"enabled-plugin",
);
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"disabled-plugin/local",
"disabled-plugin",
);
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "enabled-plugin",
"source": {
"source": "local",
"path": "./enabled-plugin"
}
},
{
"name": "disabled-plugin",
"source": {
"source": "local",
"path": "./disabled-plugin"
}
}
]
}"#,
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."enabled-plugin@debug"]
enabled = true
[plugins."disabled-plugin@debug"]
enabled = false
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = test_plugins_manager(tmp.path().to_path_buf())
.list_marketplaces_for_config(
&config,
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
/*include_openai_curated*/ true,
)
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(
tmp.path().join("repo/.agents/plugins/marketplace.json"),
)
.unwrap()
})
.expect("expected repo marketplace entry");
assert_eq!(
marketplace,
ConfiguredMarketplace {
name: "debug".to_string(),
path: AbsolutePathBuf::try_from(
tmp.path().join("repo/.agents/plugins/marketplace.json"),
)
.unwrap(),
interface: None,
plugins: vec![
ConfiguredMarketplacePlugin {
id: "enabled-plugin@debug".to_string(),
name: "enabled-plugin".to_string(),
local_version: None,
installed_version: Some("local".to_string()),
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
.unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: true,
enabled: true,
},
ConfiguredMarketplacePlugin {
id: "disabled-plugin@debug".to_string(),
name: "disabled-plugin".to_string(),
local_version: None,
installed_version: Some("local".to_string()),
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),)
.unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: true,
enabled: false,
},
],
}
);
}
#[tokio::test]
async fn list_marketplaces_returns_empty_when_feature_disabled() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "enabled-plugin",
"source": {
"source": "local",
"path": "./enabled-plugin"
}
}
]
}"#,
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = false
[plugins."enabled-plugin@debug"]
enabled = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = test_plugins_manager(tmp.path().to_path_buf())
.list_marketplaces_for_config(
&config,
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
/*include_openai_curated*/ true,
)
.unwrap()
.marketplaces;
assert_eq!(marketplaces, Vec::new());
}
#[tokio::test]
async fn list_marketplaces_excludes_plugins_with_explicit_empty_products() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "disabled-plugin",
"source": {
"source": "local",
"path": "./disabled-plugin"
},
"policy": {
"products": []
}
},
{
"name": "default-plugin",
"source": {
"source": "local",
"path": "./default-plugin"
}
}
]
}"#,
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = test_plugins_manager(tmp.path().to_path_buf())
.list_marketplaces_for_config(
&config,
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
/*include_openai_curated*/ true,
)
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(
tmp.path().join("repo/.agents/plugins/marketplace.json"),
)
.unwrap()
})
.expect("expected repo marketplace entry");
assert_eq!(
marketplace.plugins,
vec![ConfiguredMarketplacePlugin {
id: "default-plugin@debug".to_string(),
name: "default-plugin".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: false,
}]
);
}
#[tokio::test]
async fn read_plugin_for_config_returns_plugins_disabled_when_feature_disabled() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap();
fs::write(
marketplace_path.as_path(),
r#"{
"name": "debug",
"plugins": [
{
"name": "enabled-plugin",
"source": {
"source": "local",
"path": "./enabled-plugin"
}
}
]
}"#,
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = false
[plugins."enabled-plugin@debug"]
enabled = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let err = test_plugins_manager(tmp.path().to_path_buf())
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "enabled-plugin".to_string(),
marketplace_path,
},
)
.await
.unwrap_err();
assert!(matches!(err, MarketplaceError::PluginsDisabled));
}
#[tokio::test]
async fn read_plugin_for_config_filters_mcp_servers_for_codex_backend_auth() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
);
write_file(
&repo_root.join("sample-plugin/.codex-plugin/plugin.json"),
r#"{"name":"sample-plugin"}"#,
);
write_file(
&repo_root.join("sample-plugin/.app.json"),
r#"{"apps":{"sample-mcp":{"id":"connector_sample"}}}"#,
);
write_file(
&repo_root.join("sample-plugin/.mcp.json"),
r#"{"mcpServers":{"other-mcp":{"command":"other-mcp"},"sample-mcp":{"command":"sample-mcp"}}}"#,
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let request = PluginReadRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
};
let chatgpt_outcome = test_plugins_manager_with_options(
tmp.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
)
.read_plugin_for_config(&config, &request)
.await
.unwrap();
assert_eq!(
chatgpt_outcome.plugin.mcp_server_names,
vec!["other-mcp".to_string()]
);
assert_eq!(
chatgpt_outcome.plugin.apps,
vec![AppConnectorId("connector_sample".to_string())]
);
let api_key_outcome = test_plugins_manager_with_options(
tmp.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::ApiKey),
)
.read_plugin_for_config(&config, &request)
.await
.unwrap();
assert_eq!(
api_key_outcome.plugin.mcp_server_names,
vec!["other-mcp".to_string(), "sample-mcp".to_string()]
);
assert!(api_key_outcome.plugin.apps.is_empty());
}
#[tokio::test]
async fn read_plugin_for_config_uses_marketplace_manifest_fallback_paths_for_local_source() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("sample-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": "./sample-plugin",
"apps": "./config/custom.app.json",
"mcpServers": {
"sample-mcp": {
"command": "sample-mcp"
}
}
}
]
}"#,
);
write_file(
&plugin_root.join("config/custom.app.json"),
r#"{"apps":{"sample-app":{"id":"connector_sample"}}}"#,
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let manager = test_plugins_manager(tmp.path().to_path_buf());
let outcome = manager
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
assert_eq!(
outcome.plugin.apps,
vec![AppConnectorId("connector_sample".to_string())]
);
assert_eq!(
outcome.plugin.mcp_server_names,
vec!["sample-mcp".to_string()]
);
let listed_plugin = manager
.list_marketplaces_for_config(
&config,
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
/*include_openai_curated*/ false,
)
.unwrap()
.marketplaces
.into_iter()
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
.unwrap()
})
.unwrap()
.plugins
.into_iter()
.find(|plugin| plugin.name == "sample-plugin")
.unwrap();
let listed_detail = manager
.read_plugin_detail_for_marketplace_plugin(&config, "debug", listed_plugin)
.await
.unwrap();
assert_eq!(
listed_detail.apps,
vec![AppConnectorId("connector_sample".to_string())]
);
assert_eq!(
listed_detail.mcp_server_names,
vec!["sample-mcp".to_string()]
);
}
#[tokio::test]
async fn agent_plugin_read_and_tool_suggestions_use_portable_capabilities_only() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("agent-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{"name":"debug","plugins":[{"name":"agent-plugin","source":"./agent-plugin"}]}"#,
);
write_file(
&plugin_root.join("plugin.json"),
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"agent.tools"}"#,
);
write_file(
&plugin_root.join("skills/direct/SKILL.md"),
"---\nname: direct\ndescription: Direct skill\n---\n",
);
write_file(
&plugin_root.join("skills/group/nested/SKILL.md"),
"---\nname: nested\ndescription: Nested skill\n---\n",
);
write_file(
&plugin_root.join("mcp.json"),
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"portable":{"type":"stdio","command":"echo"}}}"#,
);
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"apps":"./.app.json","hooks":"./hooks/hooks.json"}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{"apps":{"legacy":{"id":"connector_legacy"}}}"#,
);
write_file(
&plugin_root.join("hooks/hooks.json"),
r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo legacy"}]}]}}"#,
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
"[features]\nplugins = true\n",
);
let config = load_config(tmp.path(), &repo_root).await;
let manager = test_plugins_manager(tmp.path().to_path_buf());
let plugin = manager
.list_marketplaces_for_config(
&config,
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
/*include_openai_curated*/ false,
)
.unwrap()
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "debug")
.unwrap()
.plugins
.into_iter()
.find(|plugin| plugin.name == "agent-plugin")
.unwrap();
let detail = manager
.read_plugin_detail_for_marketplace_plugin(&config, "debug", plugin.clone())
.await
.unwrap();
let suggestion = manager
.tool_suggest_metadata_for_marketplace_plugin(
"debug",
&plugin,
&SkillConfigRules::default(),
)
.await
.unwrap();
assert_eq!(
detail
.skills
.iter()
.map(|skill| skill.name.as_str())
.collect::<Vec<_>>(),
vec!["agent.tools:direct"]
);
assert_eq!(detail.mcp_server_names, vec!["portable"]);
assert!(detail.apps.is_empty());
assert!(detail.hooks.is_empty());
assert!(suggestion.has_skills);
assert_eq!(suggestion.mcp_server_names, vec!["portable"]);
assert!(suggestion.app_connector_ids.is_empty());
}
#[tokio::test]
async fn read_plugin_for_config_does_not_fallback_from_invalid_plugin_manifest() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("sample-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": "./sample-plugin",
"description": "Fallback metadata"
}
]
}"#,
);
write_file(&plugin_root.join(".codex-plugin/plugin.json"), "{");
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let err = test_plugins_manager(tmp.path().to_path_buf())
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "sample-plugin".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap_err();
assert_eq!(err.to_string(), "missing or invalid plugin.json");
}
#[tokio::test]
async fn read_plugin_for_config_uses_user_layer_skill_settings_only() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let plugin_root = repo_root.join("enabled-plugin");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "enabled-plugin",
"source": {
"source": "local",
"path": "./enabled-plugin"
}
}
]
}"#,
);
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"enabled-plugin"}"#,
);
write_file(
&plugin_root.join("skills/sample-search/SKILL.md"),
"---\nname: sample-search\ndescription: search sample data\n---\n",
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."enabled-plugin@debug"]
enabled = true
"#,
);
write_file(
&repo_root.join(".codex/config.toml"),
r#"[[skills.config]]
name = "enabled-plugin:sample-search"
enabled = false
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let outcome = test_plugins_manager(tmp.path().to_path_buf())
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "enabled-plugin".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
assert!(outcome.plugin.disabled_skill_paths.is_empty());
}
#[tokio::test]
async fn read_plugin_for_config_uninstalled_git_source_requires_install_without_cloning() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
.unwrap()
.to_string();
fs::create_dir_all(repo_root.join(".git")).unwrap();
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "debug",
"plugins": [
{{
"name": "toolkit",
"source": {{
"source": "git-subdir",
"url": "{missing_remote_repo_url}",
"path": "plugins/toolkit"
}},
"policy": {{
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
}}
}}
]
}}"#
),
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let outcome = test_plugins_manager(tmp.path().to_path_buf())
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "toolkit".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
assert_eq!(
outcome.plugin.details_unavailable_reason,
Some(PluginDetailsUnavailableReason::InstallRequiredForRemoteSource)
);
assert!(!outcome.plugin.installed);
let expected_description = format!(
"This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {missing_remote_repo_url}, path `plugins/toolkit`."
);
assert_eq!(
outcome.plugin.description.as_deref(),
Some(expected_description.as_str())
);
assert!(outcome.plugin.skills.is_empty());
assert!(outcome.plugin.apps.is_empty());
assert!(outcome.plugin.mcp_server_names.is_empty());
assert!(
!tmp.path()
.join("plugins/.marketplace-plugin-source-staging")
.exists()
);
}
#[tokio::test]
async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cloning() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
.unwrap()
.to_string();
fs::create_dir_all(repo_root.join(".git")).unwrap();
write_file(
&repo_root.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 = tmp.path().join("plugins/cache/debug/toolkit/local");
write_file(
&cached_plugin_root.join(".codex-plugin/plugin.json"),
r#"{
"name": "toolkit",
"description": "Cached toolkit plugin",
"interface": {
"displayName": "Toolkit"
}
}"#,
);
write_file(
&cached_plugin_root.join("skills/search/SKILL.md"),
"---\nname: search\ndescription: search cached data\n---\n",
);
write_file(
&cached_plugin_root.join(".app.json"),
r#"{
"apps": {
"calendar": {
"id": "connector_calendar",
"category": "First Category"
},
"calendar_duplicate": {
"id": "connector_calendar",
"category": "Second Category"
}
}
}"#,
);
write_file(
&cached_plugin_root.join(".mcp.json"),
r#"{"mcpServers":{"toolkit":{"command":"toolkit-mcp"}}}"#,
);
write_file(
&cached_plugin_root.join("hooks/hooks.json"),
r#"{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo startup"
}
]
}
],
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "echo first"
},
{
"type": "command",
"command": "echo second"
}
]
}
]
}
}"#,
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."toolkit@debug"]
enabled = true
[hooks.state."toolkit@debug:hooks/hooks.json:pre_tool_use:0:0"]
enabled = false
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let outcome = test_plugins_manager(tmp.path().to_path_buf())
.read_plugin_for_config(
&config,
&PluginReadRequest {
plugin_name: "toolkit".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
repo_root.join(".agents/plugins/marketplace.json"),
)
.unwrap(),
},
)
.await
.unwrap();
assert_eq!(outcome.plugin.details_unavailable_reason, None);
assert_eq!(
outcome.plugin.description.as_deref(),
Some("Cached toolkit plugin")
);
assert_eq!(
outcome.plugin.interface,
Some(PluginManifestInterface {
display_name: Some("Toolkit".to_string()),
category: Some("Developer Tools".to_string()),
..Default::default()
})
);
assert!(outcome.plugin.installed);
assert_eq!(outcome.plugin.skills.len(), 1);
assert_eq!(outcome.plugin.skills[0].name, "toolkit:search");
assert_eq!(
outcome.plugin.apps,
vec![AppConnectorId("connector_calendar".to_string())]
);
assert_eq!(
outcome.plugin.app_category_by_id,
HashMap::from([(
"connector_calendar".to_string(),
"First Category".to_string()
)])
);
assert_eq!(
outcome.plugin.hooks,
vec![
PluginHookSummary {
key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:0".to_string(),
event_name: HookEventName::PreToolUse,
},
PluginHookSummary {
key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:1".to_string(),
event_name: HookEventName::PreToolUse,
},
PluginHookSummary {
key: "toolkit@debug:hooks/hooks.json:session_start:0:0".to_string(),
event_name: HookEventName::SessionStart,
},
]
);
assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]);
assert!(
!tmp.path()
.join("plugins/.marketplace-plugin-source-staging")
.exists()
);
}
#[tokio::test]
async fn list_marketplaces_installed_git_source_reads_metadata_from_cache_without_cloning() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
.unwrap()
.to_string();
fs::create_dir_all(repo_root.join(".git")).unwrap();
write_file(
&repo_root.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 = tmp.path().join("plugins/cache/debug/toolkit/local");
write_file(
&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",
"screenshots": ["./assets/screenshot.png"]
}
}"##,
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."toolkit@debug"]
enabled = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = test_plugins_manager(tmp.path().to_path_buf())
.list_marketplaces_for_config(
&config,
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
/*include_openai_curated*/ true,
)
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
.unwrap()
})
.expect("debug marketplace should be listed");
let mut plugins = marketplace.plugins;
assert!(plugins[0].manifest_fallback.is_some());
plugins[0].manifest_fallback = None;
assert_eq!(
plugins,
vec![ConfiguredMarketplacePlugin {
id: "toolkit@debug".to_string(),
name: "toolkit".to_string(),
local_version: None,
installed_version: Some("local".to_string()),
source: MarketplacePluginSource::Git {
url: missing_remote_repo_url,
path: Some("plugins/toolkit".to_string()),
ref_name: None,
sha: None,
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: Some(PluginManifestInterface {
display_name: Some("Toolkit".to_string()),
short_description: Some("Search cached data".to_string()),
category: Some("Developer Tools".to_string()),
brand_color: Some("#3B82F6".to_string()),
composer_icon: Some(
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/icon.png")).unwrap(),
),
logo: Some(
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/logo.png")).unwrap(),
),
screenshots: vec![
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/screenshot.png"))
.unwrap(),
],
..Default::default()
}),
keywords: Vec::new(),
manifest_fallback: None,
installed: true,
enabled: true,
}]
);
assert!(
!tmp.path()
.join("plugins/.marketplace-plugin-source-staging")
.exists()
);
}
#[tokio::test]
async fn list_marketplaces_includes_curated_repo_marketplace() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
let plugin_root = curated_root.join("plugins/linear");
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
curated_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "openai-curated",
"plugins": [
{
"name": "linear",
"source": {
"source": "local",
"path": "./plugins/linear"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"linear"}"#,
)
.unwrap();
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = test_plugins_manager_with_options(
tmp.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
)
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
.unwrap()
.marketplaces;
let curated_marketplace = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "openai-curated")
.expect("curated marketplace should be listed");
assert_eq!(
curated_marketplace,
ConfiguredMarketplace {
name: "openai-curated".to_string(),
path: AbsolutePathBuf::try_from(curated_root.join(".agents/plugins/marketplace.json"))
.unwrap(),
interface: None,
plugins: vec![ConfiguredMarketplacePlugin {
id: "linear@openai-curated".to_string(),
name: "linear".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: false,
}],
}
);
}
#[tokio::test]
async fn list_marketplaces_can_skip_openai_curated_before_loading() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
write_file(
&curated_root.join(".agents/plugins/marketplace.json"),
"{not valid json",
);
let config = load_config(tmp.path(), tmp.path()).await;
let outcome = test_plugins_manager(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ false)
.unwrap();
assert_eq!(outcome.errors, Vec::new());
assert_eq!(
outcome
.marketplaces
.iter()
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME),
false
);
}
#[tokio::test]
async fn list_marketplaces_uses_api_curated_manifest_when_selected() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
write_file(
&curated_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "openai-curated",
"plugins": [
{
"name": "siwc-plugin",
"source": {
"source": "local",
"path": "./plugins/siwc-plugin"
}
}
]
}"#,
);
write_file(
&curated_root.join(".agents/plugins/api_marketplace.json"),
r#"{
"name": "openai-api-curated",
"interface": {
"displayName": "OpenAI Curated"
},
"plugins": [
{
"name": "api-plugin",
"source": {
"source": "local",
"path": "./plugins/api-plugin"
}
}
]
}"#,
);
let config = load_config(tmp.path(), tmp.path()).await;
let manager = test_plugins_manager_with_options(
tmp.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::ApiKey),
);
let marketplaces = manager
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
.unwrap()
.marketplaces;
let curated_marketplace = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == OPENAI_API_CURATED_MARKETPLACE_NAME)
.expect("API curated marketplace should be listed");
assert_eq!(
curated_marketplace,
ConfiguredMarketplace {
name: "openai-api-curated".to_string(),
path: AbsolutePathBuf::try_from(
curated_root.join(".agents/plugins/api_marketplace.json")
)
.unwrap(),
interface: Some(MarketplaceInterface {
display_name: Some("OpenAI Curated".to_string()),
}),
plugins: vec![ConfiguredMarketplacePlugin {
id: "api-plugin@openai-api-curated".to_string(),
name: "api-plugin".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(curated_root.join("plugins/api-plugin"))
.unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: false,
}],
}
);
}
#[tokio::test]
async fn list_marketplaces_selects_curated_catalog_only_from_authentication() {
for (configured_provider, resolved_provider, auth_mode, expected_marketplace) in [
(
"openai",
AMAZON_BEDROCK_PROVIDER_ID,
None,
OPENAI_API_CURATED_MARKETPLACE_NAME,
),
(
AMAZON_BEDROCK_PROVIDER_ID,
"openai",
None,
OPENAI_API_CURATED_MARKETPLACE_NAME,
),
(
"openai",
"ollama",
None,
OPENAI_API_CURATED_MARKETPLACE_NAME,
),
(
"openai",
"ollama",
Some(AuthMode::ApiKey),
OPENAI_API_CURATED_MARKETPLACE_NAME,
),
(
"openai",
"ollama",
Some(AuthMode::Chatgpt),
OPENAI_CURATED_MARKETPLACE_NAME,
),
] {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
&format!(
r#"model_provider = "{configured_provider}"
[features]
plugins = true
"#
),
);
write_openai_curated_marketplace(&curated_root, &["chatgpt-plugin"]);
write_openai_api_curated_marketplace(&curated_root, &["api-plugin"]);
let mut config = load_config(tmp.path(), tmp.path()).await;
config.model_provider_id = resolved_provider.to_string();
let marketplaces = test_plugins_manager_with_options(
tmp.path().to_path_buf(),
Some(Product::Codex),
auth_mode,
)
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
.unwrap()
.marketplaces;
assert_eq!(
marketplaces
.iter()
.map(|marketplace| marketplace.name.as_str())
.collect::<Vec<_>>(),
vec![expected_marketplace],
"unexpected curated catalog for provider `{resolved_provider}` with auth {auth_mode:?}"
);
}
}
#[tokio::test]
async fn list_marketplaces_uses_chatgpt_curated_manifest_for_bedrock_with_chatgpt_auth() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"model_provider = "amazon-bedrock"
[features]
plugins = true
"#,
);
write_openai_curated_marketplace(&curated_root, &["chatgpt-plugin"]);
write_openai_api_curated_marketplace(&curated_root, &["api-plugin"]);
let config = load_config(tmp.path(), tmp.path()).await;
let manager = test_plugins_manager_with_options(
tmp.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
);
let marketplaces = manager
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
.unwrap()
.marketplaces;
assert!(
marketplaces
.iter()
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
);
assert!(
marketplaces
.iter()
.all(|marketplace| marketplace.name != OPENAI_API_CURATED_MARKETPLACE_NAME)
);
}
#[tokio::test]
async fn list_marketplaces_skips_missing_api_curated_manifest() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
write_file(
&curated_root.join(".agents/plugins/marketplace.json"),
"{not valid json",
);
let config = load_config(tmp.path(), tmp.path()).await;
let manager = test_plugins_manager_with_options(
tmp.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::BedrockApiKey),
);
let outcome = manager
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
.unwrap();
assert_eq!(outcome.errors, Vec::new());
assert_eq!(
outcome
.marketplaces
.iter()
.any(|marketplace| marketplace.name == OPENAI_API_CURATED_MARKETPLACE_NAME),
false
);
}
#[tokio::test]
async fn list_marketplaces_includes_installed_marketplace_roots() {
let tmp = tempfile::tempdir().unwrap();
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
let plugin_root = marketplace_root.join("plugins/sample");
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces.debug]
last_updated = "2026-04-10T12:34:56Z"
source_type = "git"
source = "/tmp/debug"
"#,
);
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.unwrap();
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = test_plugins_manager(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(
marketplace_root.join(".agents/plugins/marketplace.json"),
)
.unwrap()
})
.expect("installed marketplace should be listed");
assert_eq!(
marketplace.path,
AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json"))
.unwrap()
);
assert_eq!(marketplace.plugins.len(), 1);
assert_eq!(marketplace.plugins[0].id, "sample@debug");
assert_eq!(
marketplace.plugins[0].source,
MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(plugin_root).unwrap(),
}
);
}
#[tokio::test]
async fn configured_marketplace_upgrade_invalidates_cached_tool_suggest_metadata() {
let tmp = tempfile::tempdir().unwrap();
let remote_repo = tmp.path().join("remote-marketplace");
let remote_repo_url = url::Url::from_directory_path(&remote_repo)
.unwrap()
.to_string();
write_file(
&remote_repo.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
);
write_curated_plugin(&remote_repo, "sample");
write_file(
&remote_repo.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample","description":"Before upgrade"}"#,
);
init_git_repo(&remote_repo);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
&format!(
r#"[features]
plugins = true
[marketplaces.debug]
source_type = "git"
source = "{remote_repo_url}"
"#
),
);
let manager = test_plugins_manager(tmp.path().to_path_buf());
let config = load_config(tmp.path(), tmp.path()).await;
let initial_upgrade = manager
.upgrade_configured_marketplaces_for_config(&config, /*marketplace_name*/ None)
.expect("initial marketplace install should succeed");
assert_eq!(initial_upgrade.errors, Vec::new());
assert_eq!(initial_upgrade.upgraded_roots.len(), 1);
let config = load_config(tmp.path(), tmp.path()).await;
let input = ToolSuggestPluginDiscoveryInput {
plugins: config.clone(),
configured_plugin_ids: HashSet::from(["sample@debug".to_string()]),
disabled_plugin_ids: HashSet::new(),
loaded_plugin_app_connector_ids: HashSet::new(),
};
let expected = ToolSuggestDiscoverablePlugin {
id: "sample@debug".to_string(),
remote_plugin_id: None,
name: "sample".to_string(),
description: Some("Before upgrade".to_string()),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
};
assert_eq!(
manager
.list_tool_suggest_discoverable_plugins(&input, /*auth*/ None)
.await
.expect("initial tool-suggest metadata should load"),
vec![expected.clone()]
);
write_file(
&remote_repo.join("plugins/sample/.codex-plugin/plugin.json"),
r#"{"name":"sample","description":"After upgrade"}"#,
);
run_git(&remote_repo, &["add", "."]);
run_git(&remote_repo, &["commit", "-m", "update plugin"]);
let upgrade = manager
.upgrade_configured_marketplaces_for_config(&config, Some("debug"))
.expect("marketplace upgrade should succeed");
assert_eq!(upgrade.errors, Vec::new());
assert_eq!(upgrade.upgraded_roots.len(), 1);
assert_eq!(
manager
.list_tool_suggest_discoverable_plugins(&input, /*auth*/ None)
.await
.expect("refreshed tool-suggest metadata should load"),
vec![ToolSuggestDiscoverablePlugin {
description: Some("After upgrade".to_string()),
..expected
}]
);
}
#[tokio::test]
async fn list_marketplaces_uses_config_when_known_registry_is_malformed() {
let tmp = tempfile::tempdir().unwrap();
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
let plugin_root = marketplace_root.join("plugins/sample");
let registry_path = tmp.path().join(".tmp/known_marketplaces.json");
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[marketplaces.debug]
last_updated = "2026-04-10T12:34:56Z"
source_type = "git"
source = "/tmp/debug"
"#,
);
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.unwrap();
fs::create_dir_all(registry_path.parent().unwrap()).unwrap();
fs::write(registry_path, "{not valid json").unwrap();
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = test_plugins_manager(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(
marketplace_root.join(".agents/plugins/marketplace.json"),
)
.unwrap()
})
.expect("configured marketplace should be discovered");
assert_eq!(marketplace.plugins[0].id, "sample@debug");
}
#[tokio::test]
async fn list_marketplaces_ignores_installed_roots_missing_from_config() {
let tmp = tempfile::tempdir().unwrap();
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
let plugin_root = marketplace_root.join("plugins/sample");
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::write(
marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample",
"source": {
"source": "local",
"path": "./plugins/sample"
}
}
]
}"#,
)
.unwrap();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
)
.unwrap();
let config = load_config(tmp.path(), tmp.path()).await;
let marketplaces = test_plugins_manager(tmp.path().to_path_buf())
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
.unwrap()
.marketplaces;
assert!(
marketplaces.iter().all(|marketplace| {
marketplace.path
!= AbsolutePathBuf::try_from(
marketplace_root.join(".agents/plugins/marketplace.json"),
)
.unwrap()
}),
"installed marketplace root missing from config should not be listed"
);
}
#[tokio::test]
async fn list_marketplaces_uses_first_duplicate_plugin_entry() {
let tmp = tempfile::tempdir().unwrap();
let repo_a_root = tmp.path().join("repo-a");
let repo_b_root = tmp.path().join("repo-b");
fs::create_dir_all(repo_a_root.join(".git")).unwrap();
fs::create_dir_all(repo_b_root.join(".git")).unwrap();
fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap();
fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap();
fs::write(
repo_a_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "dup-plugin",
"source": {
"source": "local",
"path": "./from-a"
}
}
]
}"#,
)
.unwrap();
fs::write(
repo_b_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "dup-plugin",
"source": {
"source": "local",
"path": "./from-b"
}
},
{
"name": "b-only-plugin",
"source": {
"source": "local",
"path": "./from-b-only"
}
}
]
}"#,
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."dup-plugin@debug"]
enabled = true
[plugins."b-only-plugin@debug"]
enabled = false
"#,
);
let config = load_config(tmp.path(), &repo_a_root).await;
let marketplaces = test_plugins_manager(tmp.path().to_path_buf())
.list_marketplaces_for_config(
&config,
&[
AbsolutePathBuf::try_from(repo_a_root).unwrap(),
AbsolutePathBuf::try_from(repo_b_root).unwrap(),
],
/*include_openai_curated*/ true,
)
.unwrap()
.marketplaces;
let repo_a_marketplace = marketplaces
.iter()
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(
tmp.path().join("repo-a/.agents/plugins/marketplace.json"),
)
.unwrap()
})
.expect("repo-a marketplace should be listed");
assert_eq!(
repo_a_marketplace.plugins,
vec![ConfiguredMarketplacePlugin {
id: "dup-plugin@debug".to_string(),
name: "dup-plugin".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: true,
}]
);
let repo_b_marketplace = marketplaces
.iter()
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(
tmp.path().join("repo-b/.agents/plugins/marketplace.json"),
)
.unwrap()
})
.expect("repo-b marketplace should be listed");
assert_eq!(
repo_b_marketplace.plugins,
vec![ConfiguredMarketplacePlugin {
id: "b-only-plugin@debug".to_string(),
name: "b-only-plugin".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: false,
}]
);
let duplicate_plugin_count = marketplaces
.iter()
.flat_map(|marketplace| marketplace.plugins.iter())
.filter(|plugin| plugin.name == "dup-plugin")
.count();
assert_eq!(duplicate_plugin_count, 1);
}
#[tokio::test]
async fn list_marketplaces_marks_configured_plugin_uninstalled_when_cache_is_missing() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
let config = load_config(tmp.path(), &repo_root).await;
let marketplaces = test_plugins_manager(tmp.path().to_path_buf())
.list_marketplaces_for_config(
&config,
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
/*include_openai_curated*/ true,
)
.unwrap()
.marketplaces;
let marketplace = marketplaces
.into_iter()
.find(|marketplace| {
marketplace.path
== AbsolutePathBuf::try_from(
tmp.path().join("repo/.agents/plugins/marketplace.json"),
)
.unwrap()
})
.expect("expected repo marketplace entry");
assert_eq!(
marketplace,
ConfiguredMarketplace {
name: "debug".to_string(),
path: AbsolutePathBuf::try_from(
tmp.path().join("repo/.agents/plugins/marketplace.json"),
)
.unwrap(),
interface: None,
plugins: vec![ConfiguredMarketplacePlugin {
id: "sample-plugin@debug".to_string(),
name: "sample-plugin".to_string(),
local_version: None,
installed_version: None,
source: MarketplacePluginSource::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(),
},
policy: MarketplacePluginPolicy {
installation: MarketplacePluginInstallPolicy::Available,
authentication: MarketplacePluginAuthPolicy::OnInstall,
products: None,
},
interface: None,
keywords: Vec::new(),
manifest_fallback: None,
installed: false,
enabled: true,
}],
}
);
}
#[tokio::test]
async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "chat"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["chat-plugin"]"#))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = test_plugins_manager_with_options(
tmp.path().to_path_buf(),
Some(Product::Chatgpt),
/*auth_mode*/ None,
);
let featured_plugin_ids = manager
.featured_plugin_ids_for_config(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
)
.await
.unwrap();
assert_eq!(featured_plugin_ids, vec!["chat-plugin".to_string()]);
}
#[tokio::test]
async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().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#"["codex-plugin"]"#))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = test_plugins_manager_with_options(
tmp.path().to_path_buf(),
/*restriction_product*/ None,
/*auth_mode*/ None,
);
let featured_plugin_ids = manager
.featured_plugin_ids_for_config(&config, /*auth*/ None)
.await
.unwrap();
assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]);
}
#[tokio::test]
async fn remote_plugin_caches_refresh_warms_recommended_plugins_cache() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ps/plugins/suggested/codex"))
.and(query_param("scope", "GLOBAL"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"enabled": true,
"plugins": []
})))
.expect(1)
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = server.uri();
let manager = std::sync::Arc::new(test_plugins_manager(tmp.path().to_path_buf()));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let cache_key = recommended_plugins_cache_key(&config);
manager.maybe_start_remote_plugin_caches_refresh(
&config,
Some(auth.clone()),
/*on_effective_plugins_changed*/ None,
);
let mode = tokio::time::timeout(Duration::from_secs(2), async {
loop {
if let Some(mode) = manager.cached_recommended_plugins_mode(&cache_key) {
break mode;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("recommended plugins cache should be warmed");
assert_eq!(
mode,
RecommendedPluginsMode::Endpoint {
plugins: Vec::new()
}
);
assert_eq!(
manager
.recommended_plugins_mode_for_config(&config, Some(&auth))
.await,
mode
);
manager.clear_recommended_plugins_cache();
assert_eq!(manager.cached_recommended_plugins_mode(&cache_key), None);
}
#[tokio::test]
async fn recommended_plugins_mode_deduplicates_concurrent_cache_misses() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ps/plugins/suggested/codex"))
.and(query_param("scope", "GLOBAL"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(header("OAI-Product-Sku", "codex"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({
"enabled": true,
"plugins": [
{
"id": "plugin_slack",
"name": "slack",
"display_name": "Slack"
},
{
"id": "plugin_github",
"name": "github",
"display_name": "GitHub"
}
]
}))
.set_delay(Duration::from_millis(100)),
)
.expect(1)
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = server.uri();
let manager = test_plugins_manager(tmp.path().to_path_buf());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let expected = RecommendedPluginsMode::Endpoint {
plugins: vec![
RecommendedPlugin {
config_id: "github@openai-curated-remote".to_string(),
remote_plugin_id: "plugin_github".to_string(),
display_name: "GitHub".to_string(),
},
RecommendedPlugin {
config_id: "slack@openai-curated-remote".to_string(),
remote_plugin_id: "plugin_slack".to_string(),
display_name: "Slack".to_string(),
},
],
};
let (left, right) = tokio::join!(
manager.recommended_plugins_mode_for_config(&config, Some(&auth)),
manager.recommended_plugins_mode_for_config(&config, Some(&auth)),
);
assert_eq!((left, right), (expected.clone(), expected.clone()));
assert_eq!(
manager
.recommended_plugins_mode_for_config(&config, Some(&auth))
.await,
expected
);
}
#[tokio::test]
async fn recommended_plugin_candidates_filter_installed_and_disabled_plugins() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ps/plugins/suggested/codex"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"enabled": true,
"plugins": [
{
"id": "plugin_linear",
"name": "linear",
"display_name": "Linear"
},
{
"id": "plugin_github",
"name": "github",
"display_name": "GitHub"
},
{
"id": "plugin_slack",
"name": "slack",
"display_name": "Slack"
}
]
})))
.expect(1)
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = server.uri();
let manager = test_plugins_manager(tmp.path().to_path_buf());
let mut installed_linear = remote_installed_plugin("linear");
installed_linear.id = "plugin_linear".to_string();
manager.write_remote_installed_plugins_cache(vec![installed_linear]);
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let disabled_tools = [ToolSuggestDisabledTool::plugin(
"github@openai-curated-remote",
)];
let loaded_plugins = manager.plugins_for_config(&config).await;
let candidates = manager
.recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput {
plugins_config: &config,
loaded_plugins: &loaded_plugins,
auth: Some(&auth),
disabled_tools: &disabled_tools,
app_server_client_name: None,
})
.await;
assert_eq!(
candidates,
Some(vec![DiscoverableTool::from(DiscoverablePluginInfo {
id: "slack@openai-curated-remote".to_string(),
remote_plugin_id: Some("plugin_slack".to_string()),
name: "Slack".to_string(),
description: None,
has_skills: false,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
})])
);
}
#[tokio::test]
async fn recommended_plugins_mode_caches_explicit_false() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ps/plugins/suggested/codex"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"enabled": false,
"plugins": []
})))
.expect(1)
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = server.uri();
let manager = test_plugins_manager(tmp.path().to_path_buf());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
assert_eq!(
manager
.recommended_plugins_mode_for_config(&config, Some(&auth))
.await,
RecommendedPluginsMode::Legacy
);
assert_eq!(
manager
.recommended_plugins_mode_for_config(&config, Some(&auth))
.await,
RecommendedPluginsMode::Legacy
);
}
#[tokio::test]
async fn recommended_plugins_mode_retries_after_fetch_failure() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ps/plugins/suggested/codex"))
.respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
.expect(1)
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = server.uri();
let manager = test_plugins_manager(tmp.path().to_path_buf());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
assert_eq!(
manager
.recommended_plugins_mode_for_config(&config, Some(&auth))
.await,
RecommendedPluginsMode::Legacy
);
server.reset().await;
Mock::given(method("GET"))
.and(path("/ps/plugins/suggested/codex"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"enabled": true,
"plugins": []
})))
.expect(1)
.mount(&server)
.await;
assert_eq!(
manager
.recommended_plugins_mode_for_config(&config, Some(&auth))
.await,
RecommendedPluginsMode::Endpoint {
plugins: Vec::new()
}
);
}
#[test]
fn refresh_curated_plugin_cache_replaces_existing_local_version_with_short_sha_version() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
let plugin_id = PluginId::new(
"slack".to_string(),
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
)
.unwrap();
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"slack/local",
"slack",
);
assert!(
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
.expect("cache refresh should succeed")
);
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/slack/local")
.exists()
);
assert!(
tmp.path()
.join(format!(
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
))
.is_dir()
);
}
#[test]
fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_current_short_version() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
let plugin_id = PluginId::new(
"slack".to_string(),
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
)
.unwrap();
assert!(
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
.expect("cache refresh should recreate missing configured plugin")
);
assert!(
tmp.path()
.join(format!(
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
))
.is_dir()
);
}
#[test]
fn refresh_curated_plugin_cache_reinstalls_missing_api_curated_plugin() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &[]);
write_openai_api_curated_marketplace(&curated_root, &["api-only"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
let plugin_id = PluginId::new(
"api-only".to_string(),
OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(),
)
.unwrap();
assert!(
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
.expect("cache refresh should recreate missing configured API curated plugin")
);
assert!(
tmp.path()
.join(format!(
"plugins/cache/openai-api-curated/api-only/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
))
.is_dir()
);
}
#[test]
fn refresh_curated_plugin_cache_leaves_api_curated_plugin_when_api_manifest_missing() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &[]);
write_cached_plugin(tmp.path(), OPENAI_API_CURATED_MARKETPLACE_NAME, "api-only");
let plugin_id = PluginId::new(
"api-only".to_string(),
OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(),
)
.unwrap();
assert!(
!refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
.expect("cache refresh should skip missing API curated manifest")
);
assert!(
tmp.path()
.join("plugins/cache/openai-api-curated/api-only/local")
.is_dir()
);
}
#[test]
fn refresh_curated_plugin_cache_removes_cache_for_plugin_removed_from_marketplace() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &[]);
let plugin_id = PluginId::new(
"google-sheets".to_string(),
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
)
.unwrap();
let plugin_cache_root = tmp
.path()
.join("plugins/cache/openai-curated/google-sheets");
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
&format!("google-sheets/{TEST_CURATED_PLUGIN_CACHE_VERSION}"),
"google-sheets",
);
assert!(
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
.expect("cache refresh should remove stale configured plugin")
);
assert!(!plugin_cache_root.exists());
}
#[test]
fn curated_plugin_ids_from_config_keys_reads_latest_codex_home_user_config() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."slack@openai-curated"]
enabled = true
[plugins."api-only@openai-api-curated"]
enabled = true
[plugins."sample@debug"]
enabled = true
"#,
);
assert_eq!(
configured_curated_plugin_ids_from_codex_home(tmp.path())
.into_iter()
.map(|plugin_id| plugin_id.as_key())
.collect::<Vec<_>>(),
vec![
"api-only@openai-api-curated".to_string(),
"slack@openai-curated".to_string(),
]
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
assert_eq!(
configured_curated_plugin_ids_from_codex_home(tmp.path()),
Vec::<PluginId>::new()
);
}
#[test]
fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
let plugin_id = PluginId::new(
"slack".to_string(),
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
)
.unwrap();
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
&format!("slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"),
"slack",
);
assert!(
!refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
.expect("cache refresh should be a no-op when configured plugins are current")
);
}
#[test]
fn refresh_curated_plugin_cache_migrates_full_sha_cache_version_to_short_version() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
let plugin_id = PluginId::new(
"slack".to_string(),
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
)
.unwrap();
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
&format!("slack/{TEST_CURATED_PLUGIN_SHA}"),
"slack",
);
assert!(
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
.expect("cache refresh should migrate the full sha cache version")
);
assert!(
!tmp.path()
.join(format!(
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_SHA}"
))
.exists()
);
assert!(
tmp.path()
.join(format!(
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
))
.is_dir()
);
}
#[test]
fn refresh_non_curated_plugin_cache_replaces_existing_local_version_with_manifest_version() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
);
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/local",
"sample-plugin",
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
assert!(
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should succeed")
);
assert!(
!tmp.path()
.join("plugins/cache/debug/sample-plugin/local")
.exists()
);
assert!(
tmp.path()
.join("plugins/cache/debug/sample-plugin/1.2.3")
.is_dir()
);
}
#[tokio::test]
async fn non_curated_plugin_cache_refresh_preserves_manual_priority() {
let codex_home = TempDir::new().unwrap();
let marketplace_root = codex_home.path().join("marketplace");
fs::create_dir_all(marketplace_root.join(".git")).unwrap();
write_plugin_with_version(
&marketplace_root,
"sample-plugin",
"sample-plugin",
Some("1.0.0"),
);
write_file(
&marketplace_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [{
"name": "sample-plugin",
"source": { "source": "local", "path": "./sample-plugin" }
}]
}"#,
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
let config = load_plugins_config_input(codex_home.path(), &marketplace_root).await;
let roots = [AbsolutePathBuf::try_from(marketplace_root.clone()).unwrap()];
let manager = Arc::new(test_plugins_manager(codex_home.path().to_path_buf()));
manager
.non_curated_cache_refresh_state
.write()
.expect("refresh state lock")
.in_flight = true;
for git_mode in [
PluginGitMode::Automatic,
PluginGitMode::Manual,
PluginGitMode::Automatic,
] {
manager.schedule_non_curated_plugin_cache_refresh(
&config,
&roots,
NonCuratedCacheRefreshMode::IfVersionChanged,
git_mode,
);
}
{
let mut state = manager
.non_curated_cache_refresh_state
.write()
.expect("refresh state lock");
assert!(matches!(
state.requested.as_ref().map(|request| request.git_mode),
Some(PluginGitMode::Manual)
));
state.last_refreshed = state.requested.take();
state.in_flight = false;
}
manager.maybe_start_non_curated_plugin_cache_refresh(&config, &roots);
{
let mut state = manager
.non_curated_cache_refresh_state
.write()
.expect("refresh state lock");
assert!(!state.in_flight);
assert!(state.requested.is_none());
state.requested = state.last_refreshed.clone();
state.in_flight = true;
}
write_plugin_with_version(
&marketplace_root,
"sample-plugin",
"sample-plugin",
Some("2.0.0"),
);
manager.maybe_start_non_curated_plugin_cache_refresh(&config, &roots);
let state = manager
.non_curated_cache_refresh_state
.read()
.expect("refresh state lock");
let request = state.requested.as_ref().expect("pending refresh");
assert!(matches!(request.git_mode, PluginGitMode::Automatic));
assert_eq!(
request.configured_plugin_sources[0]
.local_version
.as_deref(),
Some("2.0.0")
);
}
#[test]
fn refresh_non_curated_plugin_cache_reinstalls_missing_configured_plugin_with_manifest_version() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
assert!(
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should reinstall missing configured plugin")
);
assert!(
tmp.path()
.join("plugins/cache/debug/sample-plugin/1.2.3")
.is_dir()
);
}
#[test]
fn refresh_non_curated_plugin_cache_refreshes_configured_git_source() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
let remote_repo = tmp.path().join("remote-plugin-repo");
let remote_repo_url = url::Url::from_directory_path(&remote_repo)
.unwrap()
.to_string();
fs::create_dir_all(repo_root.join(".git")).unwrap();
write_plugin_with_version(
&remote_repo,
"plugins/sample-plugin",
"sample-plugin",
Some("1.2.3"),
);
init_git_repo(&remote_repo);
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "debug",
"plugins": [
{{
"name": "sample-plugin",
"source": {{
"source": "git-subdir",
"url": "{remote_repo_url}",
"path": "plugins/sample-plugin"
}}
}}
]
}}"#
),
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
assert!(
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should materialize configured Git plugin")
);
assert!(
tmp.path()
.join("plugins/cache/debug/sample-plugin/1.2.3")
.is_dir()
);
}
#[test]
fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
);
write_plugin_with_version(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/1.2.3",
"sample-plugin",
Some("1.2.3"),
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
assert!(
!refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should be a no-op when configured plugins are current")
);
}
#[test]
fn refresh_non_curated_plugin_cache_force_reinstalls_current_local_version() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin(&repo_root, "sample-plugin", "sample-plugin");
fs::write(repo_root.join("sample-plugin/skills/SKILL.md"), "new skill").unwrap();
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
}
]
}"#,
);
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/local",
"sample-plugin",
);
fs::write(
tmp.path()
.join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md"),
"old skill",
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
assert!(
refresh_non_curated_plugin_cache_force_reinstall(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should reinstall unchanged local version")
);
assert_eq!(
fs::read_to_string(
tmp.path()
.join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md")
)
.unwrap(),
"new skill"
);
}
#[test]
fn refresh_non_curated_plugin_cache_ignores_invalid_unconfigured_plugin_versions() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
write_plugin_with_version(&repo_root, "broken-plugin", "broken-plugin", Some(" "));
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "sample-plugin",
"source": {
"source": "local",
"path": "./sample-plugin"
}
},
{
"name": "broken-plugin",
"source": {
"source": "local",
"path": "./broken-plugin"
}
}
]
}"#,
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."sample-plugin@debug"]
enabled = true
"#,
);
assert!(
refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["sample-plugin@debug".to_string()],
)
.expect("cache refresh should ignore unrelated invalid plugin manifests")
);
assert!(
tmp.path()
.join("plugins/cache/debug/sample-plugin/1.2.3")
.is_dir()
);
}
#[test]
fn refresh_non_curated_plugin_cache_continues_after_plugin_error() {
let tmp = tempfile::tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
write_plugin_with_version(&repo_root, "z-good", "z-good", Some("1.2.3"));
write_file(
&repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "debug",
"plugins": [
{
"name": "a-broken",
"source": {
"source": "local",
"path": "./missing"
}
},
{
"name": "z-good",
"source": {
"source": "local",
"path": "./z-good"
}
}
]
}"#,
);
let err = refresh_non_curated_plugin_cache(
tmp.path(),
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
&["a-broken@debug".to_string(), "z-good@debug".to_string()],
)
.expect_err("broken plugin should be reported after refreshing the remaining plugins");
assert!(err.contains("a-broken@debug"));
assert!(tmp.path().join("plugins/cache/debug/z-good/1.2.3").is_dir());
}
#[tokio::test]
async fn load_plugins_ignores_project_config_files() {
let codex_home = TempDir::new().unwrap();
let project_root = codex_home.path().join("project");
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
r#"{"name":"sample"}"#,
);
write_file(
&project_root.join(".codex/config.toml"),
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
);
let stack = ConfigLayerStack::new(
vec![ConfigLayerEntry::new(
ConfigLayerSource::Project {
dot_codex_folder: AbsolutePathBuf::try_from(project_root.join(".codex")).unwrap(),
},
toml::from_str(&plugin_config_toml(
/*enabled*/ true, /*plugins_feature_enabled*/ true,
))
.expect("project config should parse"),
)],
ConfigRequirements::default(),
ConfigRequirementsToml::default(),
)
.expect("config layer stack should build");
let plugins = load_plugins_from_layer_stack(
&stack,
crate::remote_plugin_id_resolver::RemoteInstalledPluginsSnapshot::default(),
&PluginStore::new(codex_home.path().to_path_buf()),
/*plugin_skill_snapshots*/ None,
Some(Product::Codex),
/*remote_global_catalog_active*/ false,
test_skill_root_loader().as_ref(),
)
.await;
assert_eq!(plugins, Vec::new());
}
#[tokio::test]
async fn plugin_hooks_for_layer_stack_loads_configured_plugin_hooks() {
let codex_home = TempDir::new().unwrap();
let plugin_root = codex_home
.path()
.join("plugins/cache")
.join("test/sample/local");
write_plugin(
codex_home.path().join("plugins/cache/test").as_path(),
"sample/local",
"sample",
);
write_file(
&plugin_root.join("hooks/hooks.json"),
r#"{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo startup"
}
]
}
]
}
}"#,
);
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
);
let config = load_config(codex_home.path(), codex_home.path()).await;
let outcome = test_plugins_manager(codex_home.path().to_path_buf())
.plugin_hooks_for_layer_stack(&config.config_layer_stack, &config)
.await;
assert_eq!(outcome.hook_sources.len(), 1);
assert_eq!(
outcome.hook_sources[0].source_relative_path,
"hooks/hooks.json"
);
assert_eq!(outcome.hook_load_warnings, Vec::<String>::new());
}
#[tokio::test]
async fn plugin_hooks_for_layer_stack_follow_auth_mode_and_provider() {
let codex_home = TempDir::new().unwrap();
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"model_provider = "amazon-bedrock"
[features]
plugins = true
remote_plugin = false
[plugins."linear@openai-curated"]
enabled = true
[plugins."linear@openai-api-curated"]
enabled = true
"#,
);
for marketplace_name in [
OPENAI_CURATED_MARKETPLACE_NAME,
OPENAI_API_CURATED_MARKETPLACE_NAME,
] {
write_cached_plugin(codex_home.path(), marketplace_name, "linear");
write_file(
&codex_home
.path()
.join("plugins/cache")
.join(marketplace_name)
.join("linear/local/hooks/hooks.json"),
r#"{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "echo startup"
}
]
}
]
}
}"#,
);
}
let config = load_config(codex_home.path(), codex_home.path()).await;
let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt));
let manager = test_plugins_manager_with_auth_manager(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Arc::clone(&auth_manager),
);
let chatgpt_hooks = manager
.plugin_hooks_for_layer_stack(&config.config_layer_stack, &config)
.await;
assert_eq!(
chatgpt_hooks
.hook_sources
.iter()
.map(|source| source.plugin_id.as_key())
.collect::<Vec<_>>(),
vec!["linear@openai-curated"]
);
set_test_auth_mode(&auth_manager, Some(AuthMode::ApiKey)).await;
let api_hooks = manager
.plugin_hooks_for_layer_stack(&config.config_layer_stack, &config)
.await;
assert_eq!(
api_hooks
.hook_sources
.iter()
.map(|source| source.plugin_id.as_key())
.collect::<Vec<_>>(),
vec!["linear@openai-api-curated"]
);
set_test_auth_mode(&auth_manager, /*auth_mode*/ None).await;
let bedrock_hooks = manager
.plugin_hooks_for_layer_stack(&config.config_layer_stack, &config)
.await;
assert_eq!(
bedrock_hooks
.hook_sources
.iter()
.map(|source| source.plugin_id.as_key())
.collect::<Vec<_>>(),
vec!["linear@openai-api-curated"]
);
}
#[test]
fn remote_installed_plugins_cache_refresh_coalesces_materializations() {
let tmp = TempDir::new().unwrap();
let manager = std::sync::Arc::new(test_plugins_manager(tmp.path().to_path_buf()));
let materialization_callback_count =
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let unrelated_callback_count = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
manager
.remote_installed_plugins_cache_refresh_state
.write()
.expect("refresh state lock")
.in_flight = true;
let materialization = |name: &str| RemotePluginMaterialization {
plugin_id: PluginId::new(
name.to_string(),
REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(),
)
.expect("valid plugin id"),
scope: crate::remote::RemotePluginScope::Workspace,
discoverability: Some(crate::remote::RemotePluginShareDiscoverability::Listed),
authenticated_account_id: Some("account-123".to_string()),
};
let change = |name: &str| EffectivePluginsChange {
materialized_remote_plugins: vec![materialization(name)],
};
let callback = |count: std::sync::Arc<std::sync::atomic::AtomicUsize>| {
let callback: EffectivePluginsChangedCallback = std::sync::Arc::new(move |_change| {
count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
});
callback
};
let generation = manager
.prepare_remote_installed_plugins_cache_generation(/*auth*/ None)
.expect("prepare remote installed cache generation");
let request =
|change, on_effective_plugins_changed| RemoteInstalledPluginsCacheRefreshRequest {
generation,
service_config: RemotePluginServiceConfig::new(
"https://example.com".to_string(),
test_http_client_factory(),
),
auth: None,
notify: RemoteInstalledPluginsCacheRefreshNotify::IfCacheChanged,
on_effective_plugins_changed: Some(on_effective_plugins_changed),
change,
};
manager.schedule_remote_installed_plugins_cache_refresh(request(
change("beta"),
callback(std::sync::Arc::clone(&materialization_callback_count)),
));
manager.schedule_remote_installed_plugins_cache_refresh(request(
change("alpha"),
callback(std::sync::Arc::clone(&unrelated_callback_count)),
));
let state = manager
.remote_installed_plugins_cache_refresh_state
.read()
.expect("refresh state lock");
let request = state.requested.as_ref().expect("pending refresh");
assert_eq!(
request.change,
EffectivePluginsChange {
materialized_remote_plugins: vec![materialization("alpha"), materialization("beta"),],
}
);
request
.on_effective_plugins_changed
.as_ref()
.expect("pending callback")(request.change.clone());
assert_eq!(
materialization_callback_count.load(std::sync::atomic::Ordering::Relaxed),
1
);
assert_eq!(
unrelated_callback_count.load(std::sync::atomic::Ordering::Relaxed),
0
);
}
#[tokio::test]
async fn background_remote_installed_bundle_sync_stays_deduped() {
let codex_home = TempDir::new().unwrap();
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
remote_plugin = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("includeDownloadUrls", "true"))
.respond_with(
ResponseTemplate::new(200)
.set_delay(Duration::from_millis(200))
.set_body_json(serde_json::json!({
"plugins": [],
"pagination": {"next_page_token": null},
})),
)
.expect(1)
.mount(&server)
.await;
let mut config = load_config(codex_home.path(), codex_home.path()).await;
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let first_manager = std::sync::Arc::new(test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
));
let second_manager = std::sync::Arc::new(test_plugins_manager_with_options(
codex_home.path().to_path_buf(),
Some(Product::Codex),
Some(AuthMode::Chatgpt),
));
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
first_manager.maybe_start_remote_installed_plugin_bundle_sync(
&config,
Some(auth.clone()),
/*on_effective_plugins_changed*/ None,
);
second_manager.maybe_start_remote_installed_plugin_bundle_sync(
&config,
Some(auth),
/*on_effective_plugins_changed*/ None,
);
tokio::time::sleep(Duration::from_millis(400)).await;
server.verify().await;
}
#[tokio::test]
async fn reconcile_remote_installed_plugins_rejects_incomplete_snapshot_without_cleanup() {
let codex_home = TempDir::new().unwrap();
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
remote_plugin = true
"#,
);
write_cached_plugin(codex_home.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear");
write_cached_plugin(
codex_home.path(),
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
"malformed",
);
write_cached_plugin(
codex_home.path(),
REMOTE_WORKSPACE_MARKETPLACE_NAME,
"renamed-malformed",
);
let malformed_remote_plugin_id = "plugins~Plugin_malformed";
let malformed_plugin_id = PluginId::new(
"malformed".to_string(),
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(),
)
.expect("valid malformed plugin id");
PluginStore::new(codex_home.path().to_path_buf())
.write_remote_plugin_id(&malformed_plugin_id, malformed_remote_plugin_id)
.expect("persist malformed plugin identity");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("includeDownloadUrls", "true"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"plugins": [
{
"id": "plugins~Plugin_linear",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "ENABLED",
"release": {
"version": "local",
"display_name": "",
"description": "Track work",
"interface": {},
},
"enabled": true,
},
{
"id": malformed_remote_plugin_id,
"name": "renamed-malformed",
"scope": "WORKSPACE",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "ENABLED",
"release": {
"version": "local",
"display_name": "Malformed",
"description": "Missing discoverability",
"interface": {},
},
"enabled": true,
},
],
"pagination": {"next_page_token": null},
})))
.expect(1)
.mount(&server)
.await;
let mut config = load_config(codex_home.path(), codex_home.path()).await;
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let auth_manager = test_auth_manager(Some(AuthMode::Chatgpt));
let auth = auth_manager.auth_cached().expect("test ChatGPT auth");
let manager = std::sync::Arc::new(test_plugins_manager_with_auth_manager(
codex_home.path().to_path_buf(),
Some(Product::Codex),
auth_manager,
));
let mut previous_malformed = remote_installed_plugin_in_marketplace(
"malformed",
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
);
previous_malformed.id = malformed_remote_plugin_id.to_string();
previous_malformed.version = Some("local".to_string());
previous_malformed.enabled = false;
manager.write_remote_installed_plugins_cache(vec![previous_malformed.clone()]);
let result = manager
.reconcile_remote_installed_plugins(&config, Some(&auth))
.await;
assert!(matches!(
result,
Err(RemoteInstalledPluginBundleSyncError::Catalog(
RemotePluginCatalogError::UnexpectedResponse(_)
))
));
server.verify().await;
assert_eq!(
manager
.remote_installed_plugins_cache
.read()
.expect("installed plugin cache lock")
.plugins
.clone(),
Some(vec![previous_malformed])
);
let plugin_cache_root = codex_home.path().join("plugins/cache");
let renamed_malformed_cache = plugin_cache_root
.join(REMOTE_WORKSPACE_MARKETPLACE_NAME)
.join("renamed-malformed");
let previous_malformed_cache = plugin_cache_root
.join(REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME)
.join("malformed");
let linear_metadata = plugin_cache_root
.join(REMOTE_GLOBAL_MARKETPLACE_NAME)
.join("linear")
.join(".codex-remote-plugin-install.json");
assert!(renamed_malformed_cache.exists());
assert!(previous_malformed_cache.exists());
assert!(!linear_metadata.exists());
}
#[test]
fn reconciliation_fences_refresh_publication_and_recovers_after_cancellation() {
let codex_home = TempDir::new().unwrap();
let manager = test_plugins_manager(codex_home.path().to_path_buf());
let reconcile_generation = manager
.begin_remote_installed_plugins_reconcile(/*auth*/ None)
.expect("begin reconciliation");
let stale_refresh_generation = manager
.remote_installed_plugins_cache_generation_if_current(/*auth*/ None)
.expect("capture concurrent refresh generation");
assert_eq!(
manager.write_remote_installed_plugins_cache_snapshot(
stale_refresh_generation,
Vec::new(),
/*auth*/ None,
RemoteInstalledPluginsCachePublication::Refresh,
),
None
);
assert_eq!(
manager.write_remote_installed_plugins_cache_snapshot(
reconcile_generation,
vec![remote_installed_linear_plugin()],
/*auth*/ None,
RemoteInstalledPluginsCachePublication::Reconcile,
),
Some(true)
);
let cancelled_generation = manager
.begin_remote_installed_plugins_reconcile(/*auth*/ None)
.expect("begin cancelled reconciliation");
let reconciliation = RemoteInstalledPluginsReconciliationGuard {
manager: &manager,
generation: cancelled_generation,
committed: false,
};
drop(reconciliation);
let refresh_generation = manager
.remote_installed_plugins_cache_generation_if_current(/*auth*/ None)
.expect("capture refresh generation after cancellation");
assert_eq!(
manager.write_remote_installed_plugins_cache_snapshot(
refresh_generation,
vec![remote_installed_plugin("beta")],
/*auth*/ None,
RemoteInstalledPluginsCachePublication::Refresh,
),
Some(true)
);
let retry_generation = manager
.begin_remote_installed_plugins_reconcile(/*auth*/ None)
.expect("begin retry after cancellation");
assert_eq!(
manager.write_remote_installed_plugins_cache_snapshot(
retry_generation,
vec![remote_installed_plugin("beta")],
/*auth*/ None,
RemoteInstalledPluginsCachePublication::Reconcile,
),
Some(true)
);
}
#[test]
fn plugin_install_error_preserves_store_io_sub_error_type() {
let error = PluginInstallError::Store(PluginStoreError::Io {
context: "failed to copy plugin file",
source: std::io::Error::other("copy failed"),
});
assert_eq!(
error.sub_error_type(),
Some("failed_to_copy_plugin_file".to_string())
);
}