use super::*; use crate::auth::CodexAuth; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config::types::McpServerTransportConfig; use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; use crate::plugins::LoadedPlugin; use crate::plugins::MarketplacePluginInstallPolicy; use crate::plugins::PluginLoadOutcome; use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA; use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; use crate::plugins::test_support::write_file; use crate::plugins::test_support::write_openai_curated_marketplace; use codex_app_server_protocol::ConfigLayerSource; use codex_protocol::protocol::Product; use pretty_assertions::assert_eq; use std::fs; 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 write_plugin(root: &Path, dir_name: &str, manifest_name: &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(); fs::write( plugin_root.join(".codex-plugin/plugin.json"), format!(r#"{{"name":"{manifest_name}"}}"#), ) .unwrap(); fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap(); fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap(); } 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") } fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> PluginLoadOutcome { write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml); let config = load_config_blocking(codex_home, codex_home); PluginsManager::new(codex_home.to_path_buf()).plugins_for_config(&config) } async fn load_config(codex_home: &Path, cwd: &Path) -> crate::config::Config { ConfigBuilder::default() .codex_home(codex_home.to_path_buf()) .fallback_cwd(Some(cwd.to_path_buf())) .build() .await .expect("config should load") } fn load_config_blocking(codex_home: &Path, cwd: &Path) -> crate::config::Config { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .expect("tokio runtime should build") .block_on(load_config(codex_home, cwd)) } #[test] 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(), ); assert_eq!( outcome.plugins(), vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: 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")], disabled_skill_paths: HashSet::new(), has_enabled_skills: true, mcp_servers: HashMap::from([( "sample".to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://sample.example/mcp".to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, }, enabled: true, required: false, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth_resource: None, tools: HashMap::new(), }, )]), apps: vec![AppConnectorId("connector_example".to_string())], error: None, }] ); assert_eq!( outcome.capability_summaries(), &[PluginCapabilitySummary { config_name: "sample@test".to_string(), display_name: "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_skill_roots(), vec![plugin_root.join("skills")] ); assert_eq!(outcome.effective_mcp_servers().len(), 1); assert_eq!( outcome.effective_apps(), vec![AppConnectorId("connector_example".to_string())] ); } #[test] 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()); let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize"); 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()); } #[test] 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()); 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(), description: None, has_skills: true, mcp_server_names: Vec::new(), app_connector_ids: Vec::new(), }] ); } #[test] 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 metadata = plugin_telemetry_metadata_from_root( &PluginId::parse("sample@test").expect("plugin id should parse"), &plugin_root, ); assert_eq!( metadata.capability_summary, Some(PluginCapabilitySummary { config_name: "sample@test".to_string(), display_name: "sample".to_string(), description: None, has_skills: false, mcp_server_names: vec!["sample".to_string()], app_connector_ids: Vec::new(), }) ); } #[test] 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(), ); 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") ); } #[test] 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(), ); 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)) ); } #[test] fn load_plugins_uses_manifest_configured_component_paths() { 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": { "id": "connector_default" } } }"#, ); write_file( &plugin_root.join("config/custom.app.json"), r#"{ "apps": { "custom": { "id": "connector_custom" } } }"#, ); let outcome = load_plugins_from_config( &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), codex_home.path(), ); assert_eq!( outcome.plugins()[0].skill_roots, vec![ plugin_root.join("custom-skills"), plugin_root.join("skills") ] ); assert_eq!( outcome.plugins()[0].mcp_servers, HashMap::from([( "custom".to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://custom.example/mcp".to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, }, enabled: true, required: false, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth_resource: None, tools: HashMap::new(), }, )]) ); assert_eq!( outcome.plugins()[0].apps, vec![AppConnectorId("connector_custom".to_string())] ); } #[test] 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": { "id": "connector_default" } } }"#, ); write_file( &plugin_root.join("config/custom.app.json"), r#"{ "apps": { "custom": { "id": "connector_custom" } } }"#, ); let outcome = load_plugins_from_config( &plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true), codex_home.path(), ); assert_eq!( outcome.plugins()[0].skill_roots, vec![plugin_root.join("skills")] ); assert_eq!( outcome.plugins()[0].mcp_servers, HashMap::from([( "default".to_string(), McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://default.example/mcp".to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, }, enabled: true, required: false, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, scopes: None, oauth_resource: None, tools: HashMap::new(), }, )]) ); assert_eq!( outcome.plugins()[0].apps, vec![AppConnectorId("connector_default".to_string())] ); } #[test] 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(), ); assert_eq!( outcome.plugins(), vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: None, manifest_description: None, root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, skill_roots: Vec::new(), disabled_skill_paths: HashSet::new(), has_enabled_skills: false, mcp_servers: HashMap::new(), apps: Vec::new(), error: None, }] ); assert!(outcome.effective_skill_roots().is_empty()); assert!(outcome.effective_mcp_servers().is_empty()); } #[test] 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)); 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()); assert_eq!( outcome.effective_apps(), vec![ AppConnectorId("connector_example".to_string()), AppConnectorId("connector_gmail".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 http_server = |url: &str| McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: url.to_string(), bearer_token_env_var: None, http_headers: None, env_http_headers: None, }, enabled: true, required: false, disabled_reason: None, startup_timeout_sec: None, tool_timeout_sec: None, enabled_tools: None, disabled_tools: None, scopes: 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(), manifest_name: Some(manifest_name.to_string()), manifest_description: None, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, skill_roots: Vec::new(), disabled_skill_paths: HashSet::new(), has_enabled_skills: false, mcp_servers: HashMap::new(), apps: 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(), description: None, ..PluginCapabilitySummary::default() }; let outcome = PluginLoadOutcome::from_plugins(vec![ LoadedPlugin { skill_roots: vec![codex_home.path().join("skills-plugin/skills")], 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![connector("connector_example")], ..plugin("alpha@test", "alpha-plugin", "alpha-plugin") }, LoadedPlugin { mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]), apps: vec![connector("connector_example"), connector("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")], apps: vec![connector("connector_hidden")], ..plugin("disabled@test", "disabled-plugin", "disabled-plugin") }, LoadedPlugin { apps: vec![connector("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") }, ] ); } #[test] 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_blocking(codex_home.path(), codex_home.path()); let outcome = PluginsManager::new(codex_home.path().to_path_buf()).plugins_for_config(&config); assert_eq!(outcome, PluginLoadOutcome::default()); } #[test] 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(), ); assert_eq!(outcome.plugins().len(), 1); assert_eq!( outcome.plugins()[0].error.as_deref(), Some("invalid plugin key `sample`; expected @") ); assert!(outcome.effective_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 = PluginsManager::new(tmp.path().to_path_buf()) .install_plugin(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 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 = PluginsManager::new(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 = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) .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(), 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, installed: true, enabled: true, }, ConfiguredMarketplacePlugin { id: "disabled-plugin@debug".to_string(), name: "disabled-plugin".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, 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 = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) .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 = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) .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(), 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, 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 = PluginsManager::new(tmp.path().to_path_buf()) .read_plugin_for_config( &config, &PluginReadRequest { plugin_name: "enabled-plugin".to_string(), marketplace_path, }, ) .unwrap_err(); assert!(matches!(err, MarketplaceError::PluginsDisabled)); } #[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 = PluginsManager::new(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(), }, ) .unwrap(); assert!(outcome.plugin.disabled_skill_paths.is_empty()); } #[tokio::test] async fn sync_plugins_from_remote_returns_default_when_feature_disabled() { let tmp = tempfile::tempdir().unwrap(); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = false "#, ); let config = load_config(tmp.path(), tmp.path()).await; let outcome = PluginsManager::new(tmp.path().to_path_buf()) .sync_plugins_from_remote(&config, /*auth*/ None, /*additive_only*/ false) .await .unwrap(); assert_eq!(outcome, RemotePluginSyncResult::default()); } #[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", "interface": { "displayName": "ChatGPT Official" }, "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 = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[]) .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: Some(MarketplaceInterface { display_name: Some(OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string()), }), plugins: vec![ConfiguredMarketplacePlugin { id: "linear@openai-curated".to_string(), name: "linear".to_string(), 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, installed: false, enabled: false, }], } ); } #[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 = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config( &config, &[ AbsolutePathBuf::try_from(repo_a_root).unwrap(), AbsolutePathBuf::try_from(repo_b_root).unwrap(), ], ) .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(), 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, 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(), 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, 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 = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) .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(), 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, installed: false, enabled: true, }], } ); } #[tokio::test] async fn sync_plugins_from_remote_reconciles_cache_and_config() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), "linear/local", "linear", ); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), "gmail/local", "gmail", ); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), "calendar/local", "calendar", ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."linear@openai-curated"] enabled = false [plugins."gmail@openai-curated"] enabled = false [plugins."calendar@openai-curated"] enabled = true "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/plugins/list")) .and(header("authorization", "Bearer Access Token")) .and(header("chatgpt-account-id", "account_id")) .respond_with(ResponseTemplate::new(200).set_body_string( r#"[ {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} ]"#, )) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); let manager = PluginsManager::new(tmp.path().to_path_buf()); let result = manager .sync_plugins_from_remote( &config, Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), /*additive_only*/ false, ) .await .unwrap(); assert_eq!( result, RemotePluginSyncResult { installed_plugin_ids: Vec::new(), enabled_plugin_ids: vec!["linear@openai-curated".to_string()], disabled_plugin_ids: Vec::new(), uninstalled_plugin_ids: vec![ "gmail@openai-curated".to_string(), "calendar@openai-curated".to_string(), ], } ); assert!( tmp.path() .join("plugins/cache/openai-curated/linear/local") .is_dir() ); assert!( !tmp.path() .join("plugins/cache/openai-curated/gmail") .exists() ); assert!( !tmp.path() .join("plugins/cache/openai-curated/calendar") .exists() ); let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); assert!(config.contains("enabled = true")); assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#)); let synced_config = load_config(tmp.path(), tmp.path()).await; let curated_marketplace = manager .list_marketplaces_for_config(&synced_config, &[]) .unwrap() .marketplaces .into_iter() .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) .unwrap(); assert_eq!( curated_marketplace .plugins .into_iter() .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) .collect::>(), vec![ ("linear@openai-curated".to_string(), true, true), ("gmail@openai-curated".to_string(), false, false), ("calendar@openai-curated".to_string(), false, false), ] ); } #[tokio::test] async fn sync_plugins_from_remote_additive_only_keeps_existing_plugins() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]); write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), "linear/local", "linear", ); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), "gmail/local", "gmail", ); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), "calendar/local", "calendar", ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."linear@openai-curated"] enabled = false [plugins."gmail@openai-curated"] enabled = false [plugins."calendar@openai-curated"] enabled = true "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/plugins/list")) .and(header("authorization", "Bearer Access Token")) .and(header("chatgpt-account-id", "account_id")) .respond_with(ResponseTemplate::new(200).set_body_string( r#"[ {"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}, {"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false} ]"#, )) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); let manager = PluginsManager::new(tmp.path().to_path_buf()); let result = manager .sync_plugins_from_remote( &config, Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), /*additive_only*/ true, ) .await .unwrap(); assert_eq!( result, RemotePluginSyncResult { installed_plugin_ids: Vec::new(), enabled_plugin_ids: vec!["linear@openai-curated".to_string()], disabled_plugin_ids: Vec::new(), uninstalled_plugin_ids: Vec::new(), } ); assert!( tmp.path() .join("plugins/cache/openai-curated/linear/local") .is_dir() ); assert!( tmp.path() .join("plugins/cache/openai-curated/gmail/local") .is_dir() ); assert!( tmp.path() .join("plugins/cache/openai-curated/calendar/local") .is_dir() ); let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#)); assert!(config.contains(r#"[plugins."calendar@openai-curated"]"#)); assert!(config.contains("enabled = true")); } #[tokio::test] async fn sync_plugins_from_remote_ignores_unknown_remote_plugins() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &["linear"]); write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."linear@openai-curated"] enabled = false "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/plugins/list")) .respond_with(ResponseTemplate::new(200).set_body_string( r#"[ {"id":"1","name":"plugin-one","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} ]"#, )) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); let manager = PluginsManager::new(tmp.path().to_path_buf()); let result = manager .sync_plugins_from_remote( &config, Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), /*additive_only*/ false, ) .await .unwrap(); assert_eq!( result, RemotePluginSyncResult { installed_plugin_ids: Vec::new(), enabled_plugin_ids: Vec::new(), disabled_plugin_ids: Vec::new(), uninstalled_plugin_ids: vec!["linear@openai-curated".to_string()], } ); let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); assert!(!config.contains(r#"[plugins."linear@openai-curated"]"#)); assert!( !tmp.path() .join("plugins/cache/openai-curated/linear") .exists() ); } #[tokio::test] async fn sync_plugins_from_remote_keeps_existing_plugins_when_install_fails() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]); write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap(); write_plugin( &tmp.path().join("plugins/cache/openai-curated"), "linear/local", "linear", ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true [plugins."linear@openai-curated"] enabled = false "#, ); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/backend-api/plugins/list")) .respond_with(ResponseTemplate::new(200).set_body_string( r#"[ {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} ]"#, )) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); let manager = PluginsManager::new(tmp.path().to_path_buf()); let err = manager .sync_plugins_from_remote( &config, Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), /*additive_only*/ false, ) .await .unwrap_err(); assert!(matches!( err, PluginRemoteSyncError::Store(PluginStoreError::Invalid(ref message)) if message.contains("plugin source path is not a directory") )); assert!( tmp.path() .join("plugins/cache/openai-curated/linear/local") .is_dir() ); assert!( !tmp.path() .join("plugins/cache/openai-curated/gmail") .exists() ); let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap(); assert!(config.contains(r#"[plugins."linear@openai-curated"]"#)); assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#)); assert!(config.contains("enabled = false")); } #[tokio::test] async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() { let tmp = tempfile::tempdir().unwrap(); let curated_root = curated_plugins_repo_path(tmp.path()); write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA); fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap(); fs::write( curated_root.join(".agents/plugins/marketplace.json"), r#"{ "name": "openai-curated", "plugins": [ { "name": "gmail", "source": { "source": "local", "path": "./plugins/gmail-first" } }, { "name": "gmail", "source": { "source": "local", "path": "./plugins/gmail-second" } } ] }"#, ) .unwrap(); write_plugin(&curated_root, "plugins/gmail-first", "gmail"); write_plugin(&curated_root, "plugins/gmail-second", "gmail"); fs::write(curated_root.join("plugins/gmail-first/marker.txt"), "first").unwrap(); fs::write( curated_root.join("plugins/gmail-second/marker.txt"), "second", ) .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/list")) .respond_with(ResponseTemplate::new(200).set_body_string( r#"[ {"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true} ]"#, )) .mount(&server) .await; let mut config = load_config(tmp.path(), tmp.path()).await; config.chatgpt_base_url = format!("{}/backend-api/", server.uri()); let manager = PluginsManager::new(tmp.path().to_path_buf()); let result = manager .sync_plugins_from_remote( &config, Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()), /*additive_only*/ false, ) .await .unwrap(); assert_eq!( result, RemotePluginSyncResult { installed_plugin_ids: vec!["gmail@openai-curated".to_string()], enabled_plugin_ids: vec!["gmail@openai-curated".to_string()], disabled_plugin_ids: Vec::new(), uninstalled_plugin_ids: Vec::new(), } ); assert_eq!( fs::read_to_string(tmp.path().join(format!( "plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}/marker.txt" ))) .unwrap(), "first" ); } #[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 = PluginsManager::new_with_restriction_product( tmp.path().to_path_buf(), Some(Product::Chatgpt), ); 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 = PluginsManager::new_with_restriction_product( tmp.path().to_path_buf(), /*restriction_product*/ 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()]); } #[test] fn refresh_curated_plugin_cache_replaces_existing_local_version_with_sha() { 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_SHA}" )) .is_dir() ); } #[test] fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_current_sha() { 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_SHA}" )) .is_dir() ); } #[test] fn configured_curated_plugin_ids_from_codex_home_reads_latest_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."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!["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::::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_SHA}"), "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 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 outcome = load_plugins_from_layer_stack( &stack, &PluginStore::new(codex_home.path().to_path_buf()), Some(Product::Codex), ); assert_eq!(outcome, PluginLoadOutcome::default()); }