mirror of
https://github.com/openai/codex.git
synced 2026-05-14 00:02:33 +00:00
- Adds localVersion to plugin summaries and remoteVersion to share
context, including generated API schemas.
- Hydrates local and remote plugin versions from manifests and remote
release metadata.
- Adds default-on plugin_sharing gate for shared-with-me listing and
plugin/share/save, with disabled-path errors
and focused coverage.
3685 lines
108 KiB
Rust
3685 lines
108 KiB
Rust
use super::*;
|
|
use crate::LoadedPlugin;
|
|
use crate::PluginLoadOutcome;
|
|
use crate::installed_marketplaces::marketplace_install_root;
|
|
use crate::loader::load_plugins_from_layer_stack;
|
|
use crate::loader::refresh_non_curated_plugin_cache;
|
|
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall;
|
|
use crate::marketplace::MarketplacePluginInstallPolicy;
|
|
use crate::remote::RemoteInstalledPlugin;
|
|
use crate::startup_sync::curated_plugins_repo_path;
|
|
use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
|
|
use crate::test_support::TEST_CURATED_PLUGIN_SHA;
|
|
use crate::test_support::load_plugins_config as load_plugins_config_input;
|
|
use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
|
use crate::test_support::write_file;
|
|
use crate::test_support::write_openai_curated_marketplace;
|
|
use codex_app_server_protocol::ConfigLayerSource;
|
|
use codex_config::AppToolApproval;
|
|
use codex_config::CONFIG_TOML_FILE;
|
|
use codex_config::ConfigLayerEntry;
|
|
use codex_config::ConfigLayerStack;
|
|
use codex_config::ConfigRequirements;
|
|
use codex_config::ConfigRequirementsToml;
|
|
use codex_config::McpServerConfig;
|
|
use codex_config::McpServerToolConfig;
|
|
use codex_config::types::McpServerTransportConfig;
|
|
use codex_login::CodexAuth;
|
|
use codex_protocol::protocol::HookEventName;
|
|
use codex_protocol::protocol::Product;
|
|
use codex_utils_absolute_path::test_support::PathBufExt;
|
|
use pretty_assertions::assert_eq;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
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_with_version(
|
|
root: &Path,
|
|
dir_name: &str,
|
|
manifest_name: &str,
|
|
manifest_version: Option<&str>,
|
|
) {
|
|
let plugin_root = root.join(dir_name);
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
|
fs::create_dir_all(plugin_root.join("skills")).unwrap();
|
|
let version = manifest_version
|
|
.map(|manifest_version| format!(r#","version":"{manifest_version}""#))
|
|
.unwrap_or_default();
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
format!(r#"{{"name":"{manifest_name}"{version}}}"#),
|
|
)
|
|
.unwrap();
|
|
fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap();
|
|
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
|
|
}
|
|
|
|
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
|
|
write_plugin_with_version(
|
|
root,
|
|
dir_name,
|
|
manifest_name,
|
|
/*manifest_version*/ None,
|
|
);
|
|
}
|
|
|
|
fn init_git_repo(repo: &Path) {
|
|
run_git(repo, &["init"]);
|
|
run_git(repo, &["config", "user.email", "codex-test@example.com"]);
|
|
run_git(repo, &["config", "user.name", "Codex Test"]);
|
|
run_git(repo, &["add", "."]);
|
|
run_git(repo, &["commit", "-m", "initial"]);
|
|
}
|
|
|
|
fn run_git(repo: &Path, args: &[&str]) {
|
|
let output = std::process::Command::new("git")
|
|
.arg("-C")
|
|
.arg(repo)
|
|
.args(args)
|
|
.output()
|
|
.unwrap_or_else(|err| panic!("git should run: {err}"));
|
|
assert!(
|
|
output.status.success(),
|
|
"git -C {} {} failed\nstdout:\n{}\nstderr:\n{}",
|
|
repo.display(),
|
|
args.join(" "),
|
|
String::from_utf8_lossy(&output.stdout),
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
}
|
|
|
|
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
|
|
plugin_config_toml_with_plugin_hooks(
|
|
enabled,
|
|
plugins_feature_enabled,
|
|
/*plugin_hooks_feature_enabled*/ false,
|
|
)
|
|
}
|
|
|
|
fn plugin_config_toml_with_plugin_hooks(
|
|
enabled: bool,
|
|
plugins_feature_enabled: bool,
|
|
plugin_hooks_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),
|
|
);
|
|
features.insert(
|
|
"plugin_hooks".to_string(),
|
|
Value::Boolean(plugin_hooks_feature_enabled),
|
|
);
|
|
root.insert("features".to_string(), Value::Table(features));
|
|
|
|
let mut plugin = toml::map::Map::new();
|
|
plugin.insert("enabled".to_string(), Value::Boolean(enabled));
|
|
|
|
let mut plugins = toml::map::Map::new();
|
|
plugins.insert("sample@test".to_string(), Value::Table(plugin));
|
|
root.insert("plugins".to_string(), Value::Table(plugins));
|
|
|
|
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize")
|
|
}
|
|
|
|
async fn load_plugins_from_config(config_toml: &str, codex_home: &Path) -> PluginLoadOutcome {
|
|
write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml);
|
|
let config = load_config(codex_home, codex_home).await;
|
|
PluginsManager::new(codex_home.to_path_buf())
|
|
.plugins_for_config(&config)
|
|
.await
|
|
}
|
|
|
|
async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput {
|
|
load_plugins_config_input(codex_home, cwd).await
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_loads_default_skills_and_mcp_servers() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "sample",
|
|
"description": "Plugin that includes the sample MCP server and Skills"
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/sample-search/SKILL.md"),
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"sample": {
|
|
"type": "http",
|
|
"url": "https://sample.example/mcp",
|
|
"oauth": {
|
|
"clientId": "client-id",
|
|
"callbackPort": 3118
|
|
}
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"example": {
|
|
"id": "connector_example"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
codex_home.path(),
|
|
)
|
|
.await;
|
|
|
|
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").abs()],
|
|
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,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
)]),
|
|
apps: vec![AppConnectorId("connector_example".to_string())],
|
|
hook_sources: Vec::new(),
|
|
hook_load_warnings: Vec::new(),
|
|
error: None,
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
outcome.capability_summaries(),
|
|
&[PluginCapabilitySummary {
|
|
config_name: "sample@test".to_string(),
|
|
display_name: "sample".to_string(),
|
|
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").abs()]
|
|
);
|
|
assert_eq!(outcome.effective_mcp_servers().len(), 1);
|
|
assert_eq!(
|
|
outcome.effective_apps(),
|
|
vec![AppConnectorId("connector_example".to_string())]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_applies_plugin_mcp_server_policy() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "sample"
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"sample": {
|
|
"type": "http",
|
|
"url": "https://sample.example/mcp",
|
|
"default_tools_approval_mode": "prompt",
|
|
"enabled_tools": ["read", "search"],
|
|
"tools": {
|
|
"search": { "approval_mode": "prompt" }
|
|
}
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
let config_toml = r#"
|
|
[features]
|
|
plugins = true
|
|
|
|
[plugins."sample@test"]
|
|
enabled = true
|
|
|
|
[plugins."sample@test".mcp_servers.sample]
|
|
enabled = false
|
|
default_tools_approval_mode = "approve"
|
|
enabled_tools = ["search"]
|
|
disabled_tools = ["delete"]
|
|
|
|
[plugins."sample@test".mcp_servers.sample.tools.search]
|
|
approval_mode = "approve"
|
|
"#;
|
|
|
|
let outcome = load_plugins_from_config(config_toml, codex_home.path()).await;
|
|
let server = outcome.plugins()[0]
|
|
.mcp_servers
|
|
.get("sample")
|
|
.expect("sample server");
|
|
|
|
assert!(!server.enabled);
|
|
assert_eq!(
|
|
server.default_tools_approval_mode,
|
|
Some(AppToolApproval::Approve)
|
|
);
|
|
assert_eq!(server.enabled_tools, Some(vec!["search".to_string()]));
|
|
assert_eq!(server.disabled_tools, Some(vec!["delete".to_string()]));
|
|
assert_eq!(
|
|
server.tools.get("search"),
|
|
Some(&McpServerToolConfig {
|
|
approval_mode: Some(AppToolApproval::Approve),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_installed_cache_adds_plugin_skill_roots_without_marketplace_config() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_base = codex_home
|
|
.path()
|
|
.join("plugins/cache/chatgpt-global/linear");
|
|
write_plugin(&plugin_base, "local", "linear");
|
|
write_file(
|
|
&codex_home.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
"#,
|
|
);
|
|
|
|
let config = load_config(codex_home.path(), codex_home.path()).await;
|
|
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
|
manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin {
|
|
marketplace_name: "chatgpt-global".to_string(),
|
|
id: "plugins~Plugin_linear".to_string(),
|
|
name: "linear".to_string(),
|
|
enabled: true,
|
|
}]);
|
|
|
|
let outcome = manager.plugins_for_config(&config).await;
|
|
assert_eq!(
|
|
outcome.effective_skill_roots(),
|
|
vec![AbsolutePathBuf::try_from(plugin_base.join("local/skills")).unwrap()]
|
|
);
|
|
assert_eq!(outcome.plugins().len(), 1);
|
|
assert_eq!(outcome.plugins()[0].config_name, "linear@chatgpt-global");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_installed_cache_ignores_plugins_missing_local_cache() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
write_file(
|
|
&codex_home.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
"#,
|
|
);
|
|
|
|
let config = load_config(codex_home.path(), codex_home.path()).await;
|
|
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
|
manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin {
|
|
marketplace_name: "chatgpt-global".to_string(),
|
|
id: "plugins~Plugin_linear".to_string(),
|
|
name: "linear".to_string(),
|
|
enabled: true,
|
|
}]);
|
|
|
|
let outcome = manager.plugins_for_config(&config).await;
|
|
assert_eq!(outcome, PluginLoadOutcome::default());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
let skill_path = plugin_root.join("skills/sample-search/SKILL.md");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&skill_path,
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
|
|
let config_toml = r#"[features]
|
|
plugins = true
|
|
|
|
[[skills.config]]
|
|
name = "sample:sample-search"
|
|
enabled = false
|
|
|
|
[plugins."sample@test"]
|
|
enabled = true
|
|
"#;
|
|
let outcome = load_plugins_from_config(config_toml, codex_home.path()).await;
|
|
let skill_path = std::fs::canonicalize(skill_path)
|
|
.expect("skill path should canonicalize")
|
|
.abs();
|
|
|
|
assert_eq!(
|
|
outcome.plugins()[0].disabled_skill_paths,
|
|
HashSet::from([skill_path])
|
|
);
|
|
assert!(!outcome.plugins()[0].has_enabled_skills);
|
|
assert!(outcome.capability_summaries().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_ignores_unknown_disabled_skill_names() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/sample-search/SKILL.md"),
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
|
|
let config_toml = r#"[features]
|
|
plugins = true
|
|
|
|
[[skills.config]]
|
|
name = "sample:missing-skill"
|
|
enabled = false
|
|
|
|
[plugins."sample@test"]
|
|
enabled = true
|
|
"#;
|
|
let outcome = load_plugins_from_config(config_toml, codex_home.path()).await;
|
|
|
|
assert!(outcome.plugins()[0].disabled_skill_paths.is_empty());
|
|
assert!(outcome.plugins()[0].has_enabled_skills);
|
|
assert_eq!(
|
|
outcome.capability_summaries(),
|
|
&[PluginCapabilitySummary {
|
|
config_name: "sample@test".to_string(),
|
|
display_name: "sample".to_string(),
|
|
description: None,
|
|
has_skills: true,
|
|
mcp_server_names: Vec::new(),
|
|
app_connector_ids: Vec::new(),
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_telemetry_metadata_uses_default_mcp_config_path() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "sample"
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"sample": {
|
|
"type": "http",
|
|
"url": "https://sample.example/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let metadata = plugin_telemetry_metadata_from_root(
|
|
&PluginId::parse("sample@test").expect("plugin id should parse"),
|
|
&plugin_root.abs(),
|
|
)
|
|
.await;
|
|
|
|
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(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn capability_summary_sanitizes_plugin_descriptions_to_one_line() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "sample",
|
|
"description": "Plugin that\n includes the sample\tserver"
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/sample-search/SKILL.md"),
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
codex_home.path(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.plugins()[0].manifest_description.as_deref(),
|
|
Some("Plugin that\n includes the sample\tserver")
|
|
);
|
|
assert_eq!(
|
|
outcome.capability_summaries()[0].description.as_deref(),
|
|
Some("Plugin that includes the sample server")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn capability_summary_truncates_overlong_plugin_descriptions() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
let too_long = "x".repeat(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN + 1);
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
&format!(
|
|
r#"{{
|
|
"name": "sample",
|
|
"description": "{too_long}"
|
|
}}"#
|
|
),
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/sample-search/SKILL.md"),
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
codex_home.path(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.plugins()[0].manifest_description.as_deref(),
|
|
Some(too_long.as_str())
|
|
);
|
|
assert_eq!(
|
|
outcome.capability_summaries()[0].description,
|
|
Some("x".repeat(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_uses_manifest_configured_component_paths() {
|
|
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(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.plugins()[0].skill_roots,
|
|
vec![
|
|
plugin_root.join("custom-skills").abs(),
|
|
plugin_root.join("skills").abs()
|
|
]
|
|
);
|
|
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,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
)])
|
|
);
|
|
assert_eq!(
|
|
outcome.plugins()[0].apps,
|
|
vec![AppConnectorId("connector_custom".to_string())]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "sample",
|
|
"skills": "custom-skills",
|
|
"mcpServers": "config/custom.mcp.json",
|
|
"apps": "config/custom.app.json"
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/default-skill/SKILL.md"),
|
|
"---\nname: default-skill\ndescription: default skill\n---\n",
|
|
);
|
|
write_file(
|
|
&plugin_root.join("custom-skills/custom-skill/SKILL.md"),
|
|
"---\nname: custom-skill\ndescription: custom skill\n---\n",
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"default": {
|
|
"type": "http",
|
|
"url": "https://default.example/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("config/custom.mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"custom": {
|
|
"type": "http",
|
|
"url": "https://custom.example/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"default": {
|
|
"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(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.plugins()[0].skill_roots,
|
|
vec![plugin_root.join("skills").abs()]
|
|
);
|
|
assert_eq!(
|
|
outcome.plugins()[0].mcp_servers,
|
|
HashMap::from([(
|
|
"default".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url: "https://default.example/mcp".to_string(),
|
|
bearer_token_env_var: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
)])
|
|
);
|
|
assert_eq!(
|
|
outcome.plugins()[0].apps,
|
|
vec![AppConnectorId("connector_default".to_string())]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"sample": {
|
|
"type": "http",
|
|
"url": "https://sample.example/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(
|
|
/*enabled*/ false, /*plugins_feature_enabled*/ true,
|
|
),
|
|
codex_home.path(),
|
|
)
|
|
.await;
|
|
|
|
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(),
|
|
hook_sources: Vec::new(),
|
|
hook_load_warnings: Vec::new(),
|
|
error: None,
|
|
}]
|
|
);
|
|
assert!(outcome.effective_skill_roots().is_empty());
|
|
assert!(outcome.effective_mcp_servers().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn effective_apps_dedupes_connector_ids_across_plugins() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_a_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/plugin-a/local");
|
|
let plugin_b_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/plugin-b/local");
|
|
|
|
write_file(
|
|
&plugin_a_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"plugin-a"}"#,
|
|
);
|
|
write_file(
|
|
&plugin_a_root.join(".app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"example": {
|
|
"id": "connector_example"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_b_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"plugin-b"}"#,
|
|
);
|
|
write_file(
|
|
&plugin_b_root.join(".app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"chat": {
|
|
"id": "connector_example"
|
|
},
|
|
"gmail": {
|
|
"id": "connector_gmail"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let mut root = toml::map::Map::new();
|
|
let mut features = toml::map::Map::new();
|
|
features.insert("plugins".to_string(), Value::Boolean(true));
|
|
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()).await;
|
|
|
|
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,
|
|
},
|
|
experimental_environment: None,
|
|
enabled: true,
|
|
required: false,
|
|
supports_parallel_tool_calls: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: None,
|
|
tool_timeout_sec: None,
|
|
default_tools_approval_mode: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
oauth_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(),
|
|
hook_sources: Vec::new(),
|
|
hook_load_warnings: Vec::new(),
|
|
error: None,
|
|
};
|
|
let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary {
|
|
config_name: config_name.to_string(),
|
|
display_name: display_name.to_string(),
|
|
description: None,
|
|
..PluginCapabilitySummary::default()
|
|
};
|
|
let outcome = PluginLoadOutcome::from_plugins(vec![
|
|
LoadedPlugin {
|
|
skill_roots: vec![codex_home.path().join("skills-plugin/skills").abs()],
|
|
has_enabled_skills: true,
|
|
..plugin("skills@test", "skills-plugin", "skills-plugin")
|
|
},
|
|
LoadedPlugin {
|
|
mcp_servers: HashMap::from([("alpha".to_string(), http_server("https://alpha"))]),
|
|
apps: vec![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").abs()],
|
|
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")
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_returns_empty_when_feature_disabled() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/sample-search/SKILL.md"),
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
write_file(
|
|
&codex_home.path().join(CONFIG_TOML_FILE),
|
|
&plugin_config_toml(
|
|
/*enabled*/ true, /*plugins_feature_enabled*/ false,
|
|
),
|
|
);
|
|
|
|
let config = load_config(codex_home.path(), codex_home.path()).await;
|
|
let outcome = PluginsManager::new(codex_home.path().to_path_buf())
|
|
.plugins_for_config(&config)
|
|
.await;
|
|
|
|
assert_eq!(outcome, PluginLoadOutcome::default());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugins_for_config_reloads_when_plugin_hooks_enablement_changes() {
|
|
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("hooks/hooks.json"),
|
|
r#"{
|
|
"hooks": {
|
|
"PreToolUse": [
|
|
{
|
|
"hooks": [{ "type": "command", "command": "echo plugin hook" }]
|
|
}
|
|
]
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
|
write_file(
|
|
&codex_home.path().join(CONFIG_TOML_FILE),
|
|
&plugin_config_toml_with_plugin_hooks(
|
|
/*enabled*/ true, /*plugins_feature_enabled*/ true,
|
|
/*plugin_hooks_feature_enabled*/ false,
|
|
),
|
|
);
|
|
let config_without_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await;
|
|
let without_plugin_hooks = manager
|
|
.plugins_for_config(&config_without_plugin_hooks)
|
|
.await;
|
|
assert!(
|
|
without_plugin_hooks
|
|
.effective_plugin_hook_sources()
|
|
.is_empty()
|
|
);
|
|
|
|
write_file(
|
|
&codex_home.path().join(CONFIG_TOML_FILE),
|
|
&plugin_config_toml_with_plugin_hooks(
|
|
/*enabled*/ true, /*plugins_feature_enabled*/ true,
|
|
/*plugin_hooks_feature_enabled*/ true,
|
|
),
|
|
);
|
|
let config_with_plugin_hooks = load_config(codex_home.path(), codex_home.path()).await;
|
|
let with_plugin_hooks = manager.plugins_for_config(&config_with_plugin_hooks).await;
|
|
assert_eq!(with_plugin_hooks.effective_plugin_hook_sources().len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_rejects_invalid_plugin_keys() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
|
|
let mut root = toml::map::Map::new();
|
|
let mut features = toml::map::Map::new();
|
|
features.insert("plugins".to_string(), Value::Boolean(true));
|
|
root.insert("features".to_string(), Value::Table(features));
|
|
|
|
let mut plugin = toml::map::Map::new();
|
|
plugin.insert("enabled".to_string(), Value::Boolean(true));
|
|
|
|
let mut plugins = toml::map::Map::new();
|
|
plugins.insert("sample".to_string(), Value::Table(plugin));
|
|
root.insert("plugins".to_string(), Value::Table(plugins));
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"),
|
|
codex_home.path(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(outcome.plugins().len(), 1);
|
|
assert_eq!(
|
|
outcome.plugins()[0].error.as_deref(),
|
|
Some("invalid plugin key `sample`; expected <plugin>@<marketplace>")
|
|
);
|
|
assert!(outcome.effective_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 install_openai_curated_plugin_uses_short_sha_cache_version() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
|
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
|
|
|
let result = PluginsManager::new(tmp.path().to_path_buf())
|
|
.install_plugin(PluginInstallRequest {
|
|
plugin_name: "slack".to_string(),
|
|
marketplace_path: AbsolutePathBuf::try_from(
|
|
curated_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let installed_path = tmp.path().join(format!(
|
|
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
|
|
));
|
|
assert_eq!(
|
|
result,
|
|
PluginInstallOutcome {
|
|
plugin_id: PluginId::new(
|
|
"slack".to_string(),
|
|
OPENAI_CURATED_MARKETPLACE_NAME.to_string()
|
|
)
|
|
.unwrap(),
|
|
plugin_version: TEST_CURATED_PLUGIN_CACHE_VERSION.to_string(),
|
|
installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(),
|
|
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_plugin_uses_manifest_version_for_non_curated_plugins() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
|
write_plugin_with_version(
|
|
&repo_root,
|
|
"sample-plugin",
|
|
"sample-plugin",
|
|
Some("1.2.3-beta+7"),
|
|
);
|
|
fs::write(
|
|
repo_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./sample-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let result = 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/1.2.3-beta+7");
|
|
assert_eq!(
|
|
result,
|
|
PluginInstallOutcome {
|
|
plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(),
|
|
plugin_version: "1.2.3-beta+7".to_string(),
|
|
installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(),
|
|
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_plugin_supports_git_subdir_marketplace_sources() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("marketplace");
|
|
let remote_repo = tmp.path().join("remote-plugin-repo");
|
|
let remote_repo_url = url::Url::from_directory_path(&remote_repo)
|
|
.unwrap()
|
|
.to_string();
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
|
write_plugin(&remote_repo, "plugins/toolkit", "toolkit");
|
|
init_git_repo(&remote_repo);
|
|
fs::write(
|
|
repo_root.join(".agents/plugins/marketplace.json"),
|
|
format!(
|
|
r#"{{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{{
|
|
"name": "toolkit",
|
|
"source": {{
|
|
"source": "git-subdir",
|
|
"url": "{remote_repo_url}",
|
|
"path": "plugins/toolkit"
|
|
}}
|
|
}}
|
|
]
|
|
}}"#
|
|
),
|
|
)
|
|
.unwrap();
|
|
|
|
let result = PluginsManager::new(tmp.path().to_path_buf())
|
|
.install_plugin(PluginInstallRequest {
|
|
plugin_name: "toolkit".to_string(),
|
|
marketplace_path: AbsolutePathBuf::try_from(
|
|
repo_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local");
|
|
assert_eq!(
|
|
result,
|
|
PluginInstallOutcome {
|
|
plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(),
|
|
plugin_version: "local".to_string(),
|
|
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
|
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
|
}
|
|
);
|
|
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn install_plugin_supports_relative_git_subdir_marketplace_sources() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("marketplace");
|
|
let remote_repo = repo_root.join("remote-plugin-repo");
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
|
write_plugin(&remote_repo, "plugins/toolkit", "toolkit");
|
|
init_git_repo(&remote_repo);
|
|
fs::write(
|
|
repo_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "toolkit",
|
|
"source": {
|
|
"source": "git-subdir",
|
|
"url": "./remote-plugin-repo",
|
|
"path": "plugins/toolkit"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let result = PluginsManager::new(tmp.path().to_path_buf())
|
|
.install_plugin(PluginInstallRequest {
|
|
plugin_name: "toolkit".to_string(),
|
|
marketplace_path: AbsolutePathBuf::try_from(
|
|
repo_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let installed_path = tmp.path().join("plugins/cache/debug/toolkit/local");
|
|
assert_eq!(
|
|
result,
|
|
PluginInstallOutcome {
|
|
plugin_id: PluginId::new("toolkit".to_string(), "debug".to_string()).unwrap(),
|
|
plugin_version: "local".to_string(),
|
|
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
|
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
|
}
|
|
);
|
|
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn uninstall_plugin_removes_cache_and_config_entry() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/local",
|
|
"sample-plugin",
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."sample-plugin@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
let manager = 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(),
|
|
local_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
|
|
.unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
installed: true,
|
|
enabled: true,
|
|
},
|
|
ConfiguredMarketplacePlugin {
|
|
id: "disabled-plugin@debug".to_string(),
|
|
name: "disabled-plugin".to_string(),
|
|
local_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),)
|
|
.unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
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(),
|
|
local_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
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,
|
|
},
|
|
)
|
|
.await
|
|
.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(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(outcome.plugin.disabled_skill_paths.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_plugin_for_config_uninstalled_git_source_requires_install_without_cloning() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
|
|
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
|
|
.unwrap()
|
|
.to_string();
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
&format!(
|
|
r#"{{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{{
|
|
"name": "toolkit",
|
|
"source": {{
|
|
"source": "git-subdir",
|
|
"url": "{missing_remote_repo_url}",
|
|
"path": "plugins/toolkit"
|
|
}},
|
|
"policy": {{
|
|
"installation": "AVAILABLE",
|
|
"authentication": "ON_INSTALL"
|
|
}}
|
|
}}
|
|
]
|
|
}}"#
|
|
),
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
|
|
let config = load_config(tmp.path(), &repo_root).await;
|
|
let outcome = PluginsManager::new(tmp.path().to_path_buf())
|
|
.read_plugin_for_config(
|
|
&config,
|
|
&PluginReadRequest {
|
|
plugin_name: "toolkit".to_string(),
|
|
marketplace_path: AbsolutePathBuf::try_from(
|
|
repo_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(
|
|
outcome.plugin.details_unavailable_reason,
|
|
Some(PluginDetailsUnavailableReason::InstallRequiredForRemoteSource)
|
|
);
|
|
assert!(!outcome.plugin.installed);
|
|
let expected_description = format!(
|
|
"This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {missing_remote_repo_url}, path `plugins/toolkit`."
|
|
);
|
|
assert_eq!(
|
|
outcome.plugin.description.as_deref(),
|
|
Some(expected_description.as_str())
|
|
);
|
|
assert!(outcome.plugin.skills.is_empty());
|
|
assert!(outcome.plugin.apps.is_empty());
|
|
assert!(outcome.plugin.mcp_server_names.is_empty());
|
|
assert!(
|
|
!tmp.path()
|
|
.join("plugins/.marketplace-plugin-source-staging")
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cloning() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
|
|
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
|
|
.unwrap()
|
|
.to_string();
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
&format!(
|
|
r#"{{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{{
|
|
"name": "toolkit",
|
|
"source": {{
|
|
"source": "git-subdir",
|
|
"url": "{missing_remote_repo_url}",
|
|
"path": "plugins/toolkit"
|
|
}},
|
|
"category": "Developer Tools"
|
|
}}
|
|
]
|
|
}}"#
|
|
),
|
|
);
|
|
let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local");
|
|
write_file(
|
|
&cached_plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "toolkit",
|
|
"description": "Cached toolkit plugin",
|
|
"interface": {
|
|
"displayName": "Toolkit"
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&cached_plugin_root.join("skills/search/SKILL.md"),
|
|
"---\nname: search\ndescription: search cached data\n---\n",
|
|
);
|
|
write_file(
|
|
&cached_plugin_root.join(".app.json"),
|
|
r#"{"apps":{"calendar":{"id":"connector_calendar"}}}"#,
|
|
);
|
|
write_file(
|
|
&cached_plugin_root.join(".mcp.json"),
|
|
r#"{"mcpServers":{"toolkit":{"command":"toolkit-mcp"}}}"#,
|
|
);
|
|
write_file(
|
|
&cached_plugin_root.join("hooks/hooks.json"),
|
|
r#"{
|
|
"hooks": {
|
|
"SessionStart": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "echo startup"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"PreToolUse": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "echo first"
|
|
},
|
|
{
|
|
"type": "command",
|
|
"command": "echo second"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
plugin_hooks = true
|
|
|
|
[plugins."toolkit@debug"]
|
|
enabled = true
|
|
|
|
[hooks.state."toolkit@debug:hooks/hooks.json:pre_tool_use:0:0"]
|
|
enabled = false
|
|
"#,
|
|
);
|
|
|
|
let config = load_config(tmp.path(), &repo_root).await;
|
|
let outcome = PluginsManager::new(tmp.path().to_path_buf())
|
|
.read_plugin_for_config(
|
|
&config,
|
|
&PluginReadRequest {
|
|
plugin_name: "toolkit".to_string(),
|
|
marketplace_path: AbsolutePathBuf::try_from(
|
|
repo_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap(),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(outcome.plugin.details_unavailable_reason, None);
|
|
assert_eq!(
|
|
outcome.plugin.description.as_deref(),
|
|
Some("Cached toolkit plugin")
|
|
);
|
|
assert_eq!(
|
|
outcome.plugin.interface,
|
|
Some(PluginManifestInterface {
|
|
display_name: Some("Toolkit".to_string()),
|
|
category: Some("Developer Tools".to_string()),
|
|
..Default::default()
|
|
})
|
|
);
|
|
assert!(outcome.plugin.installed);
|
|
assert_eq!(outcome.plugin.skills.len(), 1);
|
|
assert_eq!(outcome.plugin.skills[0].name, "toolkit:search");
|
|
assert_eq!(
|
|
outcome.plugin.apps,
|
|
vec![AppConnectorId("connector_calendar".to_string())]
|
|
);
|
|
assert_eq!(
|
|
outcome.plugin.hooks,
|
|
vec![
|
|
PluginHookSummary {
|
|
key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:0".to_string(),
|
|
event_name: HookEventName::PreToolUse,
|
|
},
|
|
PluginHookSummary {
|
|
key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:1".to_string(),
|
|
event_name: HookEventName::PreToolUse,
|
|
},
|
|
PluginHookSummary {
|
|
key: "toolkit@debug:hooks/hooks.json:session_start:0:0".to_string(),
|
|
event_name: HookEventName::SessionStart,
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]);
|
|
assert!(
|
|
!tmp.path()
|
|
.join("plugins/.marketplace-plugin-source-staging")
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_installed_git_source_reads_metadata_from_cache_without_cloning() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
|
|
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
|
|
.unwrap()
|
|
.to_string();
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
&format!(
|
|
r#"{{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{{
|
|
"name": "toolkit",
|
|
"source": {{
|
|
"source": "git-subdir",
|
|
"url": "{missing_remote_repo_url}",
|
|
"path": "plugins/toolkit"
|
|
}},
|
|
"category": "Developer Tools"
|
|
}}
|
|
]
|
|
}}"#
|
|
),
|
|
);
|
|
let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local");
|
|
write_file(
|
|
&cached_plugin_root.join(".codex-plugin/plugin.json"),
|
|
r##"{
|
|
"name": "toolkit",
|
|
"interface": {
|
|
"displayName": "Toolkit",
|
|
"shortDescription": "Search cached data",
|
|
"category": "Cached Category",
|
|
"brandColor": "#3B82F6",
|
|
"composerIcon": "./assets/icon.png",
|
|
"logo": "./assets/logo.png",
|
|
"screenshots": ["./assets/screenshot.png"]
|
|
}
|
|
}"##,
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."toolkit@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
let config = load_config(tmp.path(), &repo_root).await;
|
|
let marketplaces = 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.name == "debug")
|
|
.expect("debug marketplace should be listed");
|
|
|
|
assert_eq!(
|
|
marketplace.plugins,
|
|
vec![ConfiguredMarketplacePlugin {
|
|
id: "toolkit@debug".to_string(),
|
|
name: "toolkit".to_string(),
|
|
local_version: None,
|
|
source: MarketplacePluginSource::Git {
|
|
url: missing_remote_repo_url,
|
|
path: Some("plugins/toolkit".to_string()),
|
|
ref_name: None,
|
|
sha: None,
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: Some(PluginManifestInterface {
|
|
display_name: Some("Toolkit".to_string()),
|
|
short_description: Some("Search cached data".to_string()),
|
|
category: Some("Developer Tools".to_string()),
|
|
brand_color: Some("#3B82F6".to_string()),
|
|
composer_icon: Some(
|
|
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/icon.png")).unwrap(),
|
|
),
|
|
logo: Some(
|
|
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/logo.png")).unwrap(),
|
|
),
|
|
screenshots: vec![
|
|
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/screenshot.png"))
|
|
.unwrap(),
|
|
],
|
|
..Default::default()
|
|
}),
|
|
keywords: Vec::new(),
|
|
installed: true,
|
|
enabled: true,
|
|
}]
|
|
);
|
|
assert!(
|
|
!tmp.path()
|
|
.join("plugins/.marketplace-plugin-source-staging")
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[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",
|
|
"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: None,
|
|
plugins: vec![ConfiguredMarketplacePlugin {
|
|
id: "linear@openai-curated".to_string(),
|
|
name: "linear".to_string(),
|
|
local_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
installed: false,
|
|
enabled: false,
|
|
}],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_includes_installed_marketplace_roots() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
|
|
let plugin_root = marketplace_root.join("plugins/sample");
|
|
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[marketplaces.debug]
|
|
last_updated = "2026-04-10T12:34:56Z"
|
|
source_type = "git"
|
|
source = "/tmp/debug"
|
|
"#,
|
|
);
|
|
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
|
fs::write(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/sample"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
)
|
|
.unwrap();
|
|
let config = load_config(tmp.path(), tmp.path()).await;
|
|
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
|
.list_marketplaces_for_config(&config, &[])
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
let marketplace = marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| {
|
|
marketplace.path
|
|
== AbsolutePathBuf::try_from(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
})
|
|
.expect("installed marketplace should be listed");
|
|
|
|
assert_eq!(
|
|
marketplace.path,
|
|
AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json"))
|
|
.unwrap()
|
|
);
|
|
assert_eq!(marketplace.plugins.len(), 1);
|
|
assert_eq!(marketplace.plugins[0].id, "sample@debug");
|
|
assert_eq!(
|
|
marketplace.plugins[0].source,
|
|
MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(plugin_root).unwrap(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_uses_config_when_known_registry_is_malformed() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
|
|
let plugin_root = marketplace_root.join("plugins/sample");
|
|
let registry_path = tmp.path().join(".tmp/known_marketplaces.json");
|
|
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[marketplaces.debug]
|
|
last_updated = "2026-04-10T12:34:56Z"
|
|
source_type = "git"
|
|
source = "/tmp/debug"
|
|
"#,
|
|
);
|
|
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
|
fs::write(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/sample"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
)
|
|
.unwrap();
|
|
fs::create_dir_all(registry_path.parent().unwrap()).unwrap();
|
|
fs::write(registry_path, "{not valid json").unwrap();
|
|
|
|
let config = load_config(tmp.path(), tmp.path()).await;
|
|
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
|
.list_marketplaces_for_config(&config, &[])
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
let marketplace = marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| {
|
|
marketplace.path
|
|
== AbsolutePathBuf::try_from(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
})
|
|
.expect("configured marketplace should be discovered");
|
|
|
|
assert_eq!(marketplace.plugins[0].id, "sample@debug");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_ignores_installed_roots_missing_from_config() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
|
|
let plugin_root = marketplace_root.join("plugins/sample");
|
|
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
|
fs::write(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/sample"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
)
|
|
.unwrap();
|
|
let config = load_config(tmp.path(), tmp.path()).await;
|
|
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
|
.list_marketplaces_for_config(&config, &[])
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
assert!(
|
|
marketplaces.iter().all(|marketplace| {
|
|
marketplace.path
|
|
!= AbsolutePathBuf::try_from(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
}),
|
|
"installed marketplace root missing from config should not be listed"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_uses_first_duplicate_plugin_entry() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_a_root = tmp.path().join("repo-a");
|
|
let repo_b_root = tmp.path().join("repo-b");
|
|
fs::create_dir_all(repo_a_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_b_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap();
|
|
fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap();
|
|
fs::write(
|
|
repo_a_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "dup-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./from-a"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
repo_b_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "dup-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./from-b"
|
|
}
|
|
},
|
|
{
|
|
"name": "b-only-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./from-b-only"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."dup-plugin@debug"]
|
|
enabled = true
|
|
|
|
[plugins."b-only-plugin@debug"]
|
|
enabled = false
|
|
"#,
|
|
);
|
|
|
|
let config = load_config(tmp.path(), &repo_a_root).await;
|
|
let marketplaces = 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(),
|
|
local_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
installed: false,
|
|
enabled: true,
|
|
}]
|
|
);
|
|
|
|
let repo_b_marketplace = marketplaces
|
|
.iter()
|
|
.find(|marketplace| {
|
|
marketplace.path
|
|
== AbsolutePathBuf::try_from(
|
|
tmp.path().join("repo-b/.agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
})
|
|
.expect("repo-b marketplace should be listed");
|
|
assert_eq!(
|
|
repo_b_marketplace.plugins,
|
|
vec![ConfiguredMarketplacePlugin {
|
|
id: "b-only-plugin@debug".to_string(),
|
|
name: "b-only-plugin".to_string(),
|
|
local_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
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(),
|
|
local_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
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<_>>(),
|
|
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_CACHE_VERSION}/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_short_sha_version() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
|
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
|
let plugin_id = PluginId::new(
|
|
"slack".to_string(),
|
|
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
|
)
|
|
.unwrap();
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/openai-curated"),
|
|
"slack/local",
|
|
"slack",
|
|
);
|
|
|
|
assert!(
|
|
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
|
.expect("cache refresh should succeed")
|
|
);
|
|
|
|
assert!(
|
|
!tmp.path()
|
|
.join("plugins/cache/openai-curated/slack/local")
|
|
.exists()
|
|
);
|
|
assert!(
|
|
tmp.path()
|
|
.join(format!(
|
|
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
|
|
))
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_current_short_version() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
|
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
|
let plugin_id = PluginId::new(
|
|
"slack".to_string(),
|
|
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(
|
|
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
|
.expect("cache refresh should recreate missing configured plugin")
|
|
);
|
|
|
|
assert!(
|
|
tmp.path()
|
|
.join(format!(
|
|
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
|
|
))
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn curated_plugin_ids_from_config_keys_reads_latest_codex_home_user_config() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."slack@openai-curated"]
|
|
enabled = true
|
|
|
|
[plugins."sample@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
assert_eq!(
|
|
configured_curated_plugin_ids_from_codex_home(tmp.path())
|
|
.into_iter()
|
|
.map(|plugin_id| plugin_id.as_key())
|
|
.collect::<Vec<_>>(),
|
|
vec!["slack@openai-curated".to_string()]
|
|
);
|
|
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
|
|
assert_eq!(
|
|
configured_curated_plugin_ids_from_codex_home(tmp.path()),
|
|
Vec::<PluginId>::new()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
|
let plugin_id = PluginId::new(
|
|
"slack".to_string(),
|
|
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
|
)
|
|
.unwrap();
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/openai-curated"),
|
|
&format!("slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"),
|
|
"slack",
|
|
);
|
|
|
|
assert!(
|
|
!refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
|
.expect("cache refresh should be a no-op when configured plugins are current")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_curated_plugin_cache_migrates_full_sha_cache_version_to_short_version() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
|
let plugin_id = PluginId::new(
|
|
"slack".to_string(),
|
|
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
|
)
|
|
.unwrap();
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/openai-curated"),
|
|
&format!("slack/{TEST_CURATED_PLUGIN_SHA}"),
|
|
"slack",
|
|
);
|
|
|
|
assert!(
|
|
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
|
.expect("cache refresh should migrate the full sha cache version")
|
|
);
|
|
assert!(
|
|
!tmp.path()
|
|
.join(format!(
|
|
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_SHA}"
|
|
))
|
|
.exists()
|
|
);
|
|
assert!(
|
|
tmp.path()
|
|
.join(format!(
|
|
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
|
|
))
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_non_curated_plugin_cache_replaces_existing_local_version_with_manifest_version() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
|
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./sample-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
);
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/local",
|
|
"sample-plugin",
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."sample-plugin@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
assert!(
|
|
refresh_non_curated_plugin_cache(
|
|
tmp.path(),
|
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
|
)
|
|
.expect("cache refresh should succeed")
|
|
);
|
|
|
|
assert!(
|
|
!tmp.path()
|
|
.join("plugins/cache/debug/sample-plugin/local")
|
|
.exists()
|
|
);
|
|
assert!(
|
|
tmp.path()
|
|
.join("plugins/cache/debug/sample-plugin/1.2.3")
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_non_curated_plugin_cache_reinstalls_missing_configured_plugin_with_manifest_version() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
|
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./sample-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."sample-plugin@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
assert!(
|
|
refresh_non_curated_plugin_cache(
|
|
tmp.path(),
|
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
|
)
|
|
.expect("cache refresh should reinstall missing configured plugin")
|
|
);
|
|
|
|
assert!(
|
|
tmp.path()
|
|
.join("plugins/cache/debug/sample-plugin/1.2.3")
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_non_curated_plugin_cache_refreshes_configured_git_source() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
let remote_repo = tmp.path().join("remote-plugin-repo");
|
|
let remote_repo_url = url::Url::from_directory_path(&remote_repo)
|
|
.unwrap()
|
|
.to_string();
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
write_plugin_with_version(
|
|
&remote_repo,
|
|
"plugins/sample-plugin",
|
|
"sample-plugin",
|
|
Some("1.2.3"),
|
|
);
|
|
init_git_repo(&remote_repo);
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
&format!(
|
|
r#"{{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{{
|
|
"name": "sample-plugin",
|
|
"source": {{
|
|
"source": "git-subdir",
|
|
"url": "{remote_repo_url}",
|
|
"path": "plugins/sample-plugin"
|
|
}}
|
|
}}
|
|
]
|
|
}}"#
|
|
),
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."sample-plugin@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
assert!(
|
|
refresh_non_curated_plugin_cache(
|
|
tmp.path(),
|
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
|
)
|
|
.expect("cache refresh should materialize configured Git plugin")
|
|
);
|
|
|
|
assert!(
|
|
tmp.path()
|
|
.join("plugins/cache/debug/sample-plugin/1.2.3")
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_non_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
|
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./sample-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
);
|
|
write_plugin_with_version(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/1.2.3",
|
|
"sample-plugin",
|
|
Some("1.2.3"),
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."sample-plugin@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
assert!(
|
|
!refresh_non_curated_plugin_cache(
|
|
tmp.path(),
|
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
|
)
|
|
.expect("cache refresh should be a no-op when configured plugins are current")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_non_curated_plugin_cache_force_reinstalls_current_local_version() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
|
write_plugin(&repo_root, "sample-plugin", "sample-plugin");
|
|
fs::write(repo_root.join("sample-plugin/skills/SKILL.md"), "new skill").unwrap();
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./sample-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
);
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/local",
|
|
"sample-plugin",
|
|
);
|
|
fs::write(
|
|
tmp.path()
|
|
.join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md"),
|
|
"old skill",
|
|
)
|
|
.unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."sample-plugin@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
assert!(
|
|
refresh_non_curated_plugin_cache_force_reinstall(
|
|
tmp.path(),
|
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
|
)
|
|
.expect("cache refresh should reinstall unchanged local version")
|
|
);
|
|
|
|
assert_eq!(
|
|
fs::read_to_string(
|
|
tmp.path()
|
|
.join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md")
|
|
)
|
|
.unwrap(),
|
|
"new skill"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_non_curated_plugin_cache_ignores_invalid_unconfigured_plugin_versions() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
|
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
|
|
write_plugin_with_version(&repo_root, "broken-plugin", "broken-plugin", Some(" "));
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./sample-plugin"
|
|
}
|
|
},
|
|
{
|
|
"name": "broken-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./broken-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."sample-plugin@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
assert!(
|
|
refresh_non_curated_plugin_cache(
|
|
tmp.path(),
|
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
|
)
|
|
.expect("cache refresh should ignore unrelated invalid plugin manifests")
|
|
);
|
|
|
|
assert!(
|
|
tmp.path()
|
|
.join("plugins/cache/debug/sample-plugin/1.2.3")
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_ignores_project_config_files() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let project_root = codex_home.path().join("project");
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&project_root.join(".codex/config.toml"),
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
);
|
|
|
|
let stack = ConfigLayerStack::new(
|
|
vec![ConfigLayerEntry::new(
|
|
ConfigLayerSource::Project {
|
|
dot_codex_folder: AbsolutePathBuf::try_from(project_root.join(".codex")).unwrap(),
|
|
},
|
|
toml::from_str(&plugin_config_toml(
|
|
/*enabled*/ true, /*plugins_feature_enabled*/ true,
|
|
))
|
|
.expect("project config should parse"),
|
|
)],
|
|
ConfigRequirements::default(),
|
|
ConfigRequirementsToml::default(),
|
|
)
|
|
.expect("config layer stack should build");
|
|
|
|
let outcome = load_plugins_from_layer_stack(
|
|
&stack,
|
|
std::collections::HashMap::new(),
|
|
&PluginStore::new(codex_home.path().to_path_buf()),
|
|
Some(Product::Codex),
|
|
/*plugin_hooks_enabled*/ false,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(outcome, PluginLoadOutcome::default());
|
|
}
|