mirror of
https://github.com/openai/codex.git
synced 2026-04-29 17:06:51 +00:00
It now supports: - Connectors that are from installed and enabled plugins that are not installed yet - Plugins that are on the allowlist that are not installed yet.
1713 lines
52 KiB
Rust
1713 lines
52 KiB
Rust
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::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 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;
|
|
|
|
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 stack = ConfigLayerStack::new(
|
|
vec![ConfigLayerEntry::new(
|
|
ConfigLayerSource::User {
|
|
file: AbsolutePathBuf::try_from(codex_home.join(CONFIG_TOML_FILE)).unwrap(),
|
|
},
|
|
toml::from_str(config_toml).expect("plugin test config should parse"),
|
|
)],
|
|
ConfigRequirements::default(),
|
|
ConfigRequirementsToml::default(),
|
|
)
|
|
.expect("config layer stack should build");
|
|
PluginsManager::new(codex_home.to_path_buf()).plugins_for_layer_stack(codex_home, &stack, false)
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
#[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(true, 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")],
|
|
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,
|
|
},
|
|
)]),
|
|
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 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(true, 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(true, 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(true, 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,
|
|
},
|
|
)])
|
|
);
|
|
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(true, 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,
|
|
},
|
|
)])
|
|
);
|
|
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(false, 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(),
|
|
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,
|
|
};
|
|
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(),
|
|
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")],
|
|
..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 plugin_namespace_for_skill_path_uses_manifest_name() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home.path().join("plugins/sample");
|
|
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(&skill_path, "---\ndescription: search\n---\n");
|
|
|
|
assert_eq!(
|
|
plugin_namespace_for_skill_path(&skill_path),
|
|
Some("sample".to_string())
|
|
);
|
|
}
|
|
|
|
#[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",
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(&plugin_config_toml(true, false), codex_home.path());
|
|
|
|
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 <plugin>@<marketplace>")
|
|
);
|
|
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"
|
|
},
|
|
"authPolicy": "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();
|
|
|
|
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,
|
|
ConfiguredMarketplaceSummary {
|
|
name: "debug".to_string(),
|
|
path: AbsolutePathBuf::try_from(
|
|
tmp.path().join("repo/.agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap(),
|
|
interface: None,
|
|
plugins: vec![
|
|
ConfiguredMarketplacePluginSummary {
|
|
id: "enabled-plugin@debug".to_string(),
|
|
name: "enabled-plugin".to_string(),
|
|
source: MarketplacePluginSourceSummary::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
|
|
.unwrap(),
|
|
},
|
|
install_policy: MarketplacePluginInstallPolicy::Available,
|
|
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
|
interface: None,
|
|
installed: true,
|
|
enabled: true,
|
|
},
|
|
ConfiguredMarketplacePluginSummary {
|
|
id: "disabled-plugin@debug".to_string(),
|
|
name: "disabled-plugin".to_string(),
|
|
source: MarketplacePluginSourceSummary::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),)
|
|
.unwrap(),
|
|
},
|
|
install_policy: MarketplacePluginInstallPolicy::Available,
|
|
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
|
interface: None,
|
|
installed: true,
|
|
enabled: false,
|
|
},
|
|
],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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");
|
|
|
|
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();
|
|
|
|
let curated_marketplace = marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated")
|
|
.expect("curated marketplace should be listed");
|
|
|
|
assert_eq!(
|
|
curated_marketplace,
|
|
ConfiguredMarketplaceSummary {
|
|
name: "openai-curated".to_string(),
|
|
path: AbsolutePathBuf::try_from(curated_root.join(".agents/plugins/marketplace.json"))
|
|
.unwrap(),
|
|
interface: Some(MarketplaceInterfaceSummary {
|
|
display_name: Some("ChatGPT Official".to_string()),
|
|
}),
|
|
plugins: vec![ConfiguredMarketplacePluginSummary {
|
|
id: "linear@openai-curated".to_string(),
|
|
name: "linear".to_string(),
|
|
source: MarketplacePluginSourceSummary::Local {
|
|
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(),
|
|
},
|
|
install_policy: MarketplacePluginInstallPolicy::Available,
|
|
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
|
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();
|
|
|
|
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![ConfiguredMarketplacePluginSummary {
|
|
id: "dup-plugin@debug".to_string(),
|
|
name: "dup-plugin".to_string(),
|
|
source: MarketplacePluginSourceSummary::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
|
|
},
|
|
install_policy: MarketplacePluginInstallPolicy::Available,
|
|
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
|
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![ConfiguredMarketplacePluginSummary {
|
|
id: "b-only-plugin@debug".to_string(),
|
|
name: "b-only-plugin".to_string(),
|
|
source: MarketplacePluginSourceSummary::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
|
|
},
|
|
install_policy: MarketplacePluginInstallPolicy::Available,
|
|
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
|
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();
|
|
|
|
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,
|
|
ConfiguredMarketplaceSummary {
|
|
name: "debug".to_string(),
|
|
path: AbsolutePathBuf::try_from(
|
|
tmp.path().join("repo/.agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap(),
|
|
interface: None,
|
|
plugins: vec![ConfiguredMarketplacePluginSummary {
|
|
id: "sample-plugin@debug".to_string(),
|
|
name: "sample-plugin".to_string(),
|
|
source: MarketplacePluginSourceSummary::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(),
|
|
},
|
|
install_policy: MarketplacePluginInstallPolicy::Available,
|
|
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
|
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()),
|
|
)
|
|
.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()
|
|
.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<_>>(),
|
|
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_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()),
|
|
)
|
|
.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()),
|
|
)
|
|
.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()),
|
|
)
|
|
.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"
|
|
);
|
|
}
|
|
|
|
#[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 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(true, 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(true, true)).expect("project config should parse"),
|
|
)],
|
|
ConfigRequirements::default(),
|
|
ConfigRequirementsToml::default(),
|
|
)
|
|
.expect("config layer stack should build");
|
|
|
|
let outcome = PluginsManager::new(codex_home.path().to_path_buf()).plugins_for_layer_stack(
|
|
&project_root,
|
|
&stack,
|
|
false,
|
|
);
|
|
|
|
assert_eq!(outcome, PluginLoadOutcome::default());
|
|
}
|