mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
1883dedc0e
## Summary
This fixes plugin manifest parsing for MCP servers declared as an object
directly in `plugin.json`.
Before this change, Codex modeled `mcpServers` as only a string path,
for example:
```json
{
"name": "counter-sample",
"version": "1.1.1",
"mcpServers": "./.mcp.json"
}
```
Some migrated plugins instead provide the server map directly in the
manifest:
```json
{
"name": "counter-sample",
"version": "1.1.1",
"description": "Plugin that declares MCP servers in the manifest",
"mcpServers": {
"counter": {
"type": "http",
"url": "https://sample.example/counter/mcp"
}
}
}
```
That object form previously failed during install/load with an error
like:
```text
failed to parse plugin manifest: invalid type: map, expected a string
```
## What changed
- Add a manifest representation for `mcpServers` as either
`Path(Resource)` or `Object(map)`.
- Parse `plugin.json` `mcpServers` as either a string path or an object.
- Route object-valued MCP server maps through the existing plugin MCP
config parser instead of adding a second parser.
- Apply existing per-plugin MCP server policy to object-valued MCP
servers the same way as file-backed MCP servers.
- Include object-valued MCP server names in plugin telemetry/capability
metadata.
- Support object-valued MCP config for executor plugins without
requiring a `.mcp.json` filesystem read.
- Update the bundled plugin-creator validator and `plugin-json-spec.md`
so generated-plugin validation accepts the same object-valued shape.
## Compatibility
Existing plugin manifests that use `"mcpServers": "./.mcp.json"`
continue to work. Plugins can now also use the object shape shown above.
## Tests
Added coverage for the new manifest attribute shape at the install,
normal load, telemetry, and executor-provider layers:
- `install_accepts_manifest_mcp_server_objects`
- `load_plugins_loads_manifest_mcp_server_objects`
- `plugin_telemetry_metadata_uses_manifest_mcp_server_objects`
- `reads_manifest_object_config_without_executor_file_system_access`
Also smoke-tested the plugin-creator validator against both supported
forms:
- `mcpServers` as a direct object in `plugin.json`
- `mcpServers` as `"./.mcp.json"` with a companion `.mcp.json`
## Validation
- `just test -p codex-plugin`
- `just test -p codex-core-plugins`
- `just test -p codex-mcp-extension`
- `just bazel-lock-update`
- `just bazel-lock-check`
- `just fmt`
- `git diff --check`
- Focused rename/object-form rerun: `just test -p codex-core-plugins
manager::tests::load_plugins_loads_manifest_mcp_server_objects
manager::tests::plugin_telemetry_metadata_uses_manifest_mcp_server_objects
store::tests::install_accepts_manifest_mcp_server_objects`
- Focused executor rerun: `just test -p codex-mcp-extension
executor_plugin::provider::tests::reads_manifest_object_config_without_executor_file_system_access`
- `python3
codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py
/private/tmp/codex-validator-object`
- `python3
codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py
/private/tmp/codex-validator-path`
4774 lines
141 KiB
Rust
4774 lines
141 KiB
Rust
use super::*;
|
|
use crate::LoadedPlugin;
|
|
use crate::OPENAI_API_CURATED_MARKETPLACE_NAME;
|
|
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
|
use crate::PluginLoadOutcome;
|
|
use crate::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::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
|
use crate::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME;
|
|
use crate::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME;
|
|
use crate::remote::RecommendedPlugin;
|
|
use crate::remote::RemoteInstalledPlugin;
|
|
use crate::startup_sync::curated_plugins_repo_path;
|
|
use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
|
|
use crate::test_support::TEST_CURATED_PLUGIN_SHA;
|
|
use crate::test_support::load_plugins_config as load_plugins_config_input;
|
|
use crate::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
|
use crate::test_support::write_file;
|
|
use crate::test_support::write_openai_api_curated_marketplace;
|
|
use crate::test_support::write_openai_curated_marketplace;
|
|
use codex_app_server_protocol::AuthMode;
|
|
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::McpServerOAuthConfig;
|
|
use codex_config::McpServerToolConfig;
|
|
use codex_config::types::McpServerTransportConfig;
|
|
use codex_login::CodexAuth;
|
|
use codex_plugin::AppDeclaration;
|
|
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 std::time::Duration;
|
|
use tempfile::TempDir;
|
|
use toml::Value;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::header;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
use wiremock::matchers::query_param;
|
|
|
|
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
|
|
|
#[test]
|
|
fn plugins_manager_tracks_auth_mode() {
|
|
let tmp = TempDir::new().unwrap();
|
|
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
|
|
|
assert_eq!(manager.auth_mode(), None);
|
|
assert!(manager.set_auth_mode(Some(AuthMode::ApiKey)));
|
|
assert_eq!(manager.auth_mode(), Some(AuthMode::ApiKey));
|
|
assert!(!manager.set_auth_mode(Some(AuthMode::ApiKey)));
|
|
assert!(manager.set_auth_mode(Some(AuthMode::ChatgptAuthTokens)));
|
|
assert_eq!(manager.auth_mode(), Some(AuthMode::ChatgptAuthTokens));
|
|
assert!(manager.set_auth_mode(/*auth_mode*/ None));
|
|
assert_eq!(manager.auth_mode(), None);
|
|
|
|
let manager_with_auth = PluginsManager::new_with_options(
|
|
tmp.path().join("auth"),
|
|
Some(Product::Codex),
|
|
Some(AuthMode::Chatgpt),
|
|
);
|
|
assert_eq!(manager_with_auth.auth_mode(), Some(AuthMode::Chatgpt));
|
|
}
|
|
|
|
fn write_auth_projection_plugin(codex_home: &Path, name: &str, include_app: bool) {
|
|
let plugin_root = codex_home
|
|
.join("plugins/cache")
|
|
.join("test")
|
|
.join(name)
|
|
.join("local");
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
&format!(r#"{{"name":"{name}"}}"#),
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
&format!(
|
|
r#"{{
|
|
"mcpServers": {{
|
|
"{name}": {{
|
|
"type": "stdio",
|
|
"command": "{name}-mcp"
|
|
}}
|
|
}}
|
|
}}"#
|
|
),
|
|
);
|
|
if include_app {
|
|
write_auth_projection_app(codex_home, name, name);
|
|
}
|
|
}
|
|
|
|
fn write_auth_projection_app(codex_home: &Path, plugin_name: &str, app_name: &str) {
|
|
let plugin_root = codex_home
|
|
.join("plugins/cache")
|
|
.join("test")
|
|
.join(plugin_name)
|
|
.join("local");
|
|
write_file(
|
|
&plugin_root.join(".app.json"),
|
|
&format!(r#"{{"apps":{{"{app_name}":{{"id":"connector_{plugin_name}"}}}}}}"#),
|
|
);
|
|
}
|
|
|
|
fn app_declaration(name: &str, connector_id: &str) -> AppDeclaration {
|
|
AppDeclaration {
|
|
name: name.to_string(),
|
|
connector_id: AppConnectorId(connector_id.to_string()),
|
|
category: None,
|
|
}
|
|
}
|
|
|
|
async fn auth_projection_config(codex_home: &Path) -> PluginsConfigInput {
|
|
let config_toml = r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."sample@test"]
|
|
enabled = true
|
|
|
|
[plugins."docs@test"]
|
|
enabled = true
|
|
"#
|
|
.to_string();
|
|
write_file(&codex_home.join(CONFIG_TOML_FILE), &config_toml);
|
|
load_config(codex_home, codex_home).await
|
|
}
|
|
|
|
fn sorted_effective_mcp_server_names(outcome: &PluginLoadOutcome) -> Vec<String> {
|
|
let mut names = outcome
|
|
.effective_mcp_servers()
|
|
.keys()
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
names.sort();
|
|
names
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_auth_projection_hides_apps_without_chatgpt_auth() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
|
|
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
|
|
let config = auth_projection_config(codex_home.path()).await;
|
|
let manager = PluginsManager::new_with_options(
|
|
codex_home.path().to_path_buf(),
|
|
Some(Product::Codex),
|
|
Some(AuthMode::ApiKey),
|
|
);
|
|
|
|
let outcome = manager.plugins_for_config(&config).await;
|
|
|
|
assert!(outcome.effective_apps().is_empty());
|
|
assert_eq!(
|
|
sorted_effective_mcp_server_names(&outcome),
|
|
vec!["docs".to_string(), "sample".to_string()]
|
|
);
|
|
let sample = outcome
|
|
.capability_summaries()
|
|
.iter()
|
|
.find(|plugin| plugin.config_name == "sample@test")
|
|
.expect("sample plugin summary should exist");
|
|
assert_eq!(sample.mcp_server_names, vec!["sample".to_string()]);
|
|
assert!(sample.app_connector_ids.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_auth_projection_hides_matching_mcp_with_chatgpt_apps_route() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
|
|
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
|
|
let config = auth_projection_config(codex_home.path()).await;
|
|
let manager = PluginsManager::new_with_options(
|
|
codex_home.path().to_path_buf(),
|
|
Some(Product::Codex),
|
|
Some(AuthMode::Chatgpt),
|
|
);
|
|
|
|
let outcome = manager.plugins_for_config(&config).await;
|
|
|
|
assert_eq!(
|
|
outcome.effective_apps(),
|
|
vec![AppConnectorId("connector_sample".to_string())]
|
|
);
|
|
assert_eq!(
|
|
sorted_effective_mcp_server_names(&outcome),
|
|
vec!["docs".to_string()]
|
|
);
|
|
let sample = outcome
|
|
.capability_summaries()
|
|
.iter()
|
|
.find(|plugin| plugin.config_name == "sample@test")
|
|
.expect("sample plugin summary should exist");
|
|
assert!(sample.mcp_server_names.is_empty());
|
|
assert_eq!(
|
|
sample.app_connector_ids,
|
|
vec![AppConnectorId("connector_sample".to_string())]
|
|
);
|
|
let docs = outcome
|
|
.capability_summaries()
|
|
.iter()
|
|
.find(|plugin| plugin.config_name == "docs@test")
|
|
.expect("docs plugin summary should exist");
|
|
assert_eq!(docs.mcp_server_names, vec!["docs".to_string()]);
|
|
assert!(docs.app_connector_ids.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_auth_projection_hides_dual_surface_mcp_with_agent_identity_apps_route() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
|
|
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
|
|
let config = auth_projection_config(codex_home.path()).await;
|
|
let manager = PluginsManager::new_with_options(
|
|
codex_home.path().to_path_buf(),
|
|
Some(Product::Codex),
|
|
Some(AuthMode::AgentIdentity),
|
|
);
|
|
|
|
let outcome = manager.plugins_for_config(&config).await;
|
|
|
|
assert_eq!(
|
|
outcome.effective_apps(),
|
|
vec![AppConnectorId("connector_sample".to_string())]
|
|
);
|
|
assert_eq!(
|
|
sorted_effective_mcp_server_names(&outcome),
|
|
vec!["docs".to_string()]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_auth_projection_keeps_non_conflicting_mcp_with_chatgpt_apps_route() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ false);
|
|
write_auth_projection_app(codex_home.path(), "sample", "sample_app");
|
|
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
|
|
let config = auth_projection_config(codex_home.path()).await;
|
|
let manager = PluginsManager::new_with_options(
|
|
codex_home.path().to_path_buf(),
|
|
Some(Product::Codex),
|
|
Some(AuthMode::Chatgpt),
|
|
);
|
|
|
|
let outcome = manager.plugins_for_config(&config).await;
|
|
|
|
assert_eq!(
|
|
outcome.effective_apps(),
|
|
vec![AppConnectorId("connector_sample".to_string())]
|
|
);
|
|
assert_eq!(
|
|
sorted_effective_mcp_server_names(&outcome),
|
|
vec!["docs".to_string(), "sample".to_string()]
|
|
);
|
|
let sample = outcome
|
|
.capability_summaries()
|
|
.iter()
|
|
.find(|plugin| plugin.config_name == "sample@test")
|
|
.expect("sample plugin summary should exist");
|
|
assert_eq!(sample.mcp_server_names, vec!["sample".to_string()]);
|
|
assert_eq!(
|
|
sample.app_connector_ids,
|
|
vec![AppConnectorId("connector_sample".to_string())]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_auth_projection_preserves_duplicate_connector_declaration_names() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test")
|
|
.join("sample")
|
|
.join("local");
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"foo": {
|
|
"type": "stdio",
|
|
"command": "foo-mcp"
|
|
},
|
|
"foo2": {
|
|
"type": "stdio",
|
|
"command": "foo2-mcp"
|
|
},
|
|
"other": {
|
|
"type": "stdio",
|
|
"command": "other-mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"foo": {
|
|
"id": "connector_shared"
|
|
},
|
|
"foo2": {
|
|
"id": "connector_shared"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&codex_home.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."sample@test"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
let config = load_config(codex_home.path(), codex_home.path()).await;
|
|
let manager = PluginsManager::new_with_options(
|
|
codex_home.path().to_path_buf(),
|
|
Some(Product::Codex),
|
|
Some(AuthMode::Chatgpt),
|
|
);
|
|
|
|
let outcome = manager.plugins_for_config(&config).await;
|
|
|
|
assert_eq!(
|
|
outcome.effective_apps(),
|
|
vec![AppConnectorId("connector_shared".to_string())]
|
|
);
|
|
assert_eq!(
|
|
sorted_effective_mcp_server_names(&outcome),
|
|
vec!["other".to_string()]
|
|
);
|
|
let sample = outcome
|
|
.capability_summaries()
|
|
.iter()
|
|
.find(|plugin| plugin.config_name == "sample@test")
|
|
.expect("sample plugin summary should exist");
|
|
assert_eq!(sample.mcp_server_names, vec!["other".to_string()]);
|
|
assert_eq!(
|
|
sample.app_connector_ids,
|
|
vec![AppConnectorId("connector_shared".to_string())]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_auth_projection_reprojects_cached_plugins_when_auth_changes() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
write_auth_projection_plugin(codex_home.path(), "sample", /*include_app*/ true);
|
|
write_auth_projection_plugin(codex_home.path(), "docs", /*include_app*/ false);
|
|
let config = auth_projection_config(codex_home.path()).await;
|
|
let manager = PluginsManager::new_with_options(
|
|
codex_home.path().to_path_buf(),
|
|
Some(Product::Codex),
|
|
Some(AuthMode::Chatgpt),
|
|
);
|
|
|
|
let chatgpt_outcome = manager.plugins_for_config(&config).await;
|
|
assert_eq!(
|
|
sorted_effective_mcp_server_names(&chatgpt_outcome),
|
|
vec!["docs".to_string()]
|
|
);
|
|
assert_eq!(
|
|
chatgpt_outcome.effective_apps(),
|
|
vec![AppConnectorId("connector_sample".to_string())]
|
|
);
|
|
assert_eq!(
|
|
chatgpt_outcome.capability_summaries(),
|
|
&[
|
|
PluginCapabilitySummary {
|
|
config_name: "docs@test".to_string(),
|
|
display_name: "docs".to_string(),
|
|
description: None,
|
|
has_skills: false,
|
|
mcp_server_names: vec!["docs".to_string()],
|
|
app_connector_ids: Vec::new(),
|
|
},
|
|
PluginCapabilitySummary {
|
|
config_name: "sample@test".to_string(),
|
|
display_name: "sample".to_string(),
|
|
description: None,
|
|
has_skills: false,
|
|
mcp_server_names: Vec::new(),
|
|
app_connector_ids: vec![AppConnectorId("connector_sample".to_string())],
|
|
},
|
|
]
|
|
);
|
|
|
|
assert!(manager.set_auth_mode(Some(AuthMode::ApiKey)));
|
|
let api_key_outcome = manager.plugins_for_config(&config).await;
|
|
|
|
assert_eq!(
|
|
sorted_effective_mcp_server_names(&api_key_outcome),
|
|
vec!["docs".to_string(), "sample".to_string()]
|
|
);
|
|
assert!(api_key_outcome.effective_apps().is_empty());
|
|
assert_eq!(
|
|
api_key_outcome.capability_summaries(),
|
|
&[
|
|
PluginCapabilitySummary {
|
|
config_name: "docs@test".to_string(),
|
|
display_name: "docs".to_string(),
|
|
description: None,
|
|
has_skills: false,
|
|
mcp_server_names: vec!["docs".to_string()],
|
|
app_connector_ids: Vec::new(),
|
|
},
|
|
PluginCapabilitySummary {
|
|
config_name: "sample@test".to_string(),
|
|
display_name: "sample".to_string(),
|
|
description: None,
|
|
has_skills: false,
|
|
mcp_server_names: vec!["sample".to_string()],
|
|
app_connector_ids: Vec::new(),
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
fn write_plugin_with_version(
|
|
root: &Path,
|
|
dir_name: &str,
|
|
manifest_name: &str,
|
|
manifest_version: Option<&str>,
|
|
) {
|
|
let plugin_root = root.join(dir_name);
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
|
fs::create_dir_all(plugin_root.join("skills")).unwrap();
|
|
let version = manifest_version
|
|
.map(|manifest_version| format!(r#","version":"{manifest_version}""#))
|
|
.unwrap_or_default();
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
format!(r#"{{"name":"{manifest_name}"{version}}}"#),
|
|
)
|
|
.unwrap();
|
|
fs::write(plugin_root.join("skills/SKILL.md"), "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 {
|
|
let mut root = toml::map::Map::new();
|
|
|
|
let mut features = toml::map::Map::new();
|
|
features.insert(
|
|
"plugins".to_string(),
|
|
Value::Boolean(plugins_feature_enabled),
|
|
);
|
|
root.insert("features".to_string(), Value::Table(features));
|
|
|
|
let mut plugin = toml::map::Map::new();
|
|
plugin.insert("enabled".to_string(), Value::Boolean(enabled));
|
|
|
|
let mut plugins = toml::map::Map::new();
|
|
plugins.insert("sample@test".to_string(), Value::Table(plugin));
|
|
root.insert("plugins".to_string(), Value::Table(plugins));
|
|
|
|
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize")
|
|
}
|
|
|
|
async fn load_plugins_from_config(
|
|
config_toml: &str,
|
|
codex_home: &Path,
|
|
auth_mode: Option<AuthMode>,
|
|
) -> PluginLoadOutcome {
|
|
write_file(&codex_home.join(CONFIG_TOML_FILE), config_toml);
|
|
let config = load_config(codex_home, codex_home).await;
|
|
PluginsManager::new_with_options(codex_home.to_path_buf(), Some(Product::Codex), auth_mode)
|
|
.plugins_for_config(&config)
|
|
.await
|
|
}
|
|
|
|
async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput {
|
|
load_plugins_config_input(codex_home, cwd).await
|
|
}
|
|
|
|
fn remote_installed_linear_plugin() -> RemoteInstalledPlugin {
|
|
remote_installed_plugin("linear")
|
|
}
|
|
|
|
fn remote_installed_plugin(name: &str) -> RemoteInstalledPlugin {
|
|
remote_installed_plugin_in_marketplace(name, REMOTE_GLOBAL_MARKETPLACE_NAME)
|
|
}
|
|
|
|
fn remote_installed_plugin_in_marketplace(
|
|
name: &str,
|
|
marketplace_name: &str,
|
|
) -> RemoteInstalledPlugin {
|
|
RemoteInstalledPlugin {
|
|
marketplace_name: marketplace_name.to_string(),
|
|
id: format!("plugins~Plugin_{name}"),
|
|
name: name.to_string(),
|
|
enabled: true,
|
|
install_policy: codex_app_server_protocol::PluginInstallPolicy::Available,
|
|
auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse,
|
|
availability: codex_app_server_protocol::PluginAvailability::Available,
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn write_cached_plugin(codex_home: &Path, marketplace_name: &str, plugin_name: &str) {
|
|
write_plugin_with_version(
|
|
&codex_home
|
|
.join("plugins/cache")
|
|
.join(marketplace_name)
|
|
.join(plugin_name),
|
|
"local",
|
|
plugin_name,
|
|
/*manifest_version*/ Some("local"),
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_loads_default_skills_and_mcp_servers() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "sample",
|
|
"description": "Plugin that includes the sample MCP server and Skills"
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/sample-search/SKILL.md"),
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"sample": {
|
|
"type": "http",
|
|
"url": "https://sample.example/mcp",
|
|
"oauth": {
|
|
"clientId": "client-id",
|
|
"callbackPort": 3118
|
|
}
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"example": {
|
|
"id": "connector_example"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
codex_home.path(),
|
|
Some(AuthMode::Chatgpt),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.plugins(),
|
|
vec![LoadedPlugin {
|
|
config_name: "sample@test".to_string(),
|
|
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,
|
|
},
|
|
environment_id: "local".to_string(),
|
|
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: Some(McpServerOAuthConfig {
|
|
client_id: Some("client-id".to_string()),
|
|
}),
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
)]),
|
|
apps: vec![app_declaration("example", "connector_example")],
|
|
hook_sources: Vec::new(),
|
|
hook_load_warnings: Vec::new(),
|
|
error: None,
|
|
}]
|
|
);
|
|
assert_eq!(
|
|
outcome.capability_summaries(),
|
|
&[PluginCapabilitySummary {
|
|
config_name: "sample@test".to_string(),
|
|
display_name: "sample".to_string(),
|
|
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_loads_manifest_mcp_server_objects() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/counter-sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "counter-sample",
|
|
"version": "1.1.1",
|
|
"description": "Plugin that declares MCP servers in the manifest",
|
|
"mcpServers": {
|
|
"counter": {
|
|
"type": "http",
|
|
"url": "https://sample.example/counter/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let config_toml = r#"
|
|
[features]
|
|
plugins = true
|
|
|
|
[plugins."counter-sample@test"]
|
|
enabled = true
|
|
"#;
|
|
let outcome =
|
|
load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await;
|
|
|
|
assert_eq!(outcome.plugins()[0].error, None);
|
|
assert_eq!(
|
|
outcome.plugins()[0].mcp_servers,
|
|
HashMap::from([(
|
|
"counter".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url: "https://sample.example/counter/mcp".to_string(),
|
|
bearer_token_env_var: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
},
|
|
environment_id: "local".to_string(),
|
|
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: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
)])
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_applies_plugin_mcp_server_policy() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "sample"
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"sample": {
|
|
"type": "http",
|
|
"url": "https://sample.example/mcp",
|
|
"default_tools_approval_mode": "prompt",
|
|
"enabled_tools": ["read", "search"],
|
|
"tools": {
|
|
"search": { "approval_mode": "prompt" }
|
|
}
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
let config_toml = r#"
|
|
[features]
|
|
plugins = true
|
|
|
|
[plugins."sample@test"]
|
|
enabled = true
|
|
|
|
[plugins."sample@test".mcp_servers.sample]
|
|
enabled = false
|
|
default_tools_approval_mode = "approve"
|
|
enabled_tools = ["search"]
|
|
disabled_tools = ["delete"]
|
|
|
|
[plugins."sample@test".mcp_servers.sample.tools.search]
|
|
approval_mode = "approve"
|
|
"#;
|
|
|
|
let outcome =
|
|
load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await;
|
|
let server = outcome.plugins()[0]
|
|
.mcp_servers
|
|
.get("sample")
|
|
.expect("sample server");
|
|
|
|
assert!(!server.enabled);
|
|
assert_eq!(
|
|
server.default_tools_approval_mode,
|
|
Some(AppToolApproval::Approve)
|
|
);
|
|
assert_eq!(server.enabled_tools, Some(vec!["search".to_string()]));
|
|
assert_eq!(server.disabled_tools, Some(vec!["delete".to_string()]));
|
|
assert_eq!(
|
|
server.tools.get("search"),
|
|
Some(&McpServerToolConfig {
|
|
approval_mode: Some(AppToolApproval::Approve),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_installed_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![remote_installed_linear_plugin()]);
|
|
|
|
let outcome = manager.plugins_for_config(&config).await;
|
|
assert_eq!(outcome, PluginLoadOutcome::default());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
write_file(
|
|
&codex_home.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
remote_plugin = false
|
|
|
|
[plugins."linear@openai-curated"]
|
|
enabled = true
|
|
|
|
[plugins."calendar@openai-curated"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
write_cached_plugin(codex_home.path(), "openai-curated", "linear");
|
|
write_cached_plugin(codex_home.path(), "openai-curated", "calendar");
|
|
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
|
|
write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only");
|
|
|
|
let config = load_config(codex_home.path(), codex_home.path()).await;
|
|
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
|
manager.write_remote_installed_plugins_cache(vec![
|
|
remote_installed_plugin("linear"),
|
|
remote_installed_plugin("remote-only"),
|
|
]);
|
|
|
|
let outcome = manager.plugins_for_config(&config).await;
|
|
assert_eq!(
|
|
outcome
|
|
.plugins()
|
|
.iter()
|
|
.map(|plugin| plugin.config_name.clone())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"calendar@openai-curated".to_string(),
|
|
"linear@openai-curated".to_string(),
|
|
"remote-only@openai-curated-remote".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_installed_cache_prefers_remote_curated_conflicts_when_remote_plugin_enabled() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
write_file(
|
|
&codex_home.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
|
|
[plugins."linear@openai-curated"]
|
|
enabled = true
|
|
|
|
[plugins."linear@openai-api-curated"]
|
|
enabled = true
|
|
|
|
[plugins."calendar@openai-curated"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
write_cached_plugin(codex_home.path(), "openai-curated", "linear");
|
|
write_cached_plugin(codex_home.path(), "openai-api-curated", "linear");
|
|
write_cached_plugin(codex_home.path(), "openai-curated", "calendar");
|
|
write_cached_plugin(codex_home.path(), "openai-curated-remote", "linear");
|
|
write_cached_plugin(codex_home.path(), "openai-curated-remote", "remote-only");
|
|
|
|
let config = load_config(codex_home.path(), codex_home.path()).await;
|
|
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
|
manager.write_remote_installed_plugins_cache(vec![
|
|
remote_installed_plugin("linear"),
|
|
remote_installed_plugin("remote-only"),
|
|
]);
|
|
|
|
let outcome = manager.plugins_for_config(&config).await;
|
|
assert_eq!(
|
|
outcome
|
|
.plugins()
|
|
.iter()
|
|
.map(|plugin| plugin.config_name.clone())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"calendar@openai-curated".to_string(),
|
|
"linear@openai-curated-remote".to_string(),
|
|
"remote-only@openai-curated-remote".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metadata() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
|
let mut plugin = remote_installed_linear_plugin();
|
|
plugin.install_policy = codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault;
|
|
plugin.auth_policy = codex_app_server_protocol::PluginAuthPolicy::OnInstall;
|
|
plugin.interface = Some(codex_app_server_protocol::PluginInterface {
|
|
display_name: Some("Linear".to_string()),
|
|
short_description: Some("Track remote work".to_string()),
|
|
long_description: None,
|
|
developer_name: None,
|
|
category: None,
|
|
capabilities: Vec::new(),
|
|
website_url: None,
|
|
privacy_policy_url: None,
|
|
terms_of_service_url: None,
|
|
default_prompt: None,
|
|
brand_color: Some("#111111".to_string()),
|
|
composer_icon: None,
|
|
composer_icon_url: None,
|
|
logo: None,
|
|
logo_url: None,
|
|
screenshots: Vec::new(),
|
|
screenshot_urls: Vec::new(),
|
|
});
|
|
plugin.keywords = vec!["issues".to_string()];
|
|
manager.write_remote_installed_plugins_cache(vec![plugin]);
|
|
|
|
let marketplaces = manager
|
|
.build_remote_installed_plugin_marketplaces_from_cache(&[REMOTE_GLOBAL_MARKETPLACE_NAME])
|
|
.expect("remote installed cache should be present");
|
|
assert_eq!(marketplaces.len(), 1);
|
|
assert_eq!(marketplaces[0].name, "openai-curated-remote");
|
|
assert_eq!(marketplaces[0].display_name, "OpenAI Curated Remote");
|
|
assert_eq!(marketplaces[0].plugins.len(), 1);
|
|
let plugin = &marketplaces[0].plugins[0];
|
|
assert_eq!(plugin.id, "linear@openai-curated-remote");
|
|
assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear");
|
|
assert_eq!(plugin.name, "linear");
|
|
assert_eq!(plugin.installed, true);
|
|
assert_eq!(plugin.enabled, true);
|
|
assert_eq!(
|
|
plugin.install_policy,
|
|
codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault
|
|
);
|
|
assert_eq!(
|
|
plugin.auth_policy,
|
|
codex_app_server_protocol::PluginAuthPolicy::OnInstall
|
|
);
|
|
assert_eq!(plugin.keywords, vec!["issues".to_string()]);
|
|
assert_eq!(
|
|
plugin
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Linear")
|
|
);
|
|
assert_eq!(
|
|
plugin
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.short_description.as_deref()),
|
|
Some("Track remote work")
|
|
);
|
|
assert_eq!(
|
|
manager
|
|
.build_remote_installed_plugin_marketplaces_from_cache(&[
|
|
REMOTE_WORKSPACE_MARKETPLACE_NAME
|
|
])
|
|
.expect("remote installed cache should be present"),
|
|
Vec::new()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_remote_installed_plugin_marketplaces_from_cache_filters_by_marketplace_name() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
|
manager.write_remote_installed_plugins_cache(vec![
|
|
remote_installed_plugin_in_marketplace(
|
|
"workspace-linear",
|
|
REMOTE_WORKSPACE_MARKETPLACE_NAME,
|
|
),
|
|
remote_installed_plugin_in_marketplace(
|
|
"shared-linear",
|
|
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
|
|
),
|
|
]);
|
|
|
|
let marketplaces = manager
|
|
.build_remote_installed_plugin_marketplaces_from_cache(&[REMOTE_WORKSPACE_MARKETPLACE_NAME])
|
|
.expect("remote installed cache should be present");
|
|
|
|
assert_eq!(marketplaces.len(), 1);
|
|
assert_eq!(marketplaces[0].name, REMOTE_WORKSPACE_MARKETPLACE_NAME);
|
|
assert_eq!(
|
|
marketplaces[0]
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| plugin.id.as_str())
|
|
.collect::<Vec<_>>(),
|
|
vec!["workspace-linear@workspace-directory"]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
let skill_path = plugin_root.join("skills/sample-search/SKILL.md");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&skill_path,
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
|
|
let config_toml = r#"[features]
|
|
plugins = true
|
|
|
|
[[skills.config]]
|
|
name = "sample:sample-search"
|
|
enabled = false
|
|
|
|
[plugins."sample@test"]
|
|
enabled = true
|
|
"#;
|
|
let outcome =
|
|
load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await;
|
|
let skill_path = std::fs::canonicalize(skill_path)
|
|
.expect("skill path should canonicalize")
|
|
.abs();
|
|
|
|
assert_eq!(
|
|
outcome.plugins()[0].disabled_skill_paths,
|
|
HashSet::from([skill_path])
|
|
);
|
|
assert!(!outcome.plugins()[0].has_enabled_skills);
|
|
assert!(outcome.capability_summaries().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_ignores_unknown_disabled_skill_names() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/sample-search/SKILL.md"),
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
|
|
let config_toml = r#"[features]
|
|
plugins = true
|
|
|
|
[[skills.config]]
|
|
name = "sample:missing-skill"
|
|
enabled = false
|
|
|
|
[plugins."sample@test"]
|
|
enabled = true
|
|
"#;
|
|
let outcome =
|
|
load_plugins_from_config(config_toml, codex_home.path(), /*auth_mode*/ None).await;
|
|
|
|
assert!(outcome.plugins()[0].disabled_skill_paths.is_empty());
|
|
assert!(outcome.plugins()[0].has_enabled_skills);
|
|
assert_eq!(
|
|
outcome.capability_summaries(),
|
|
&[PluginCapabilitySummary {
|
|
config_name: "sample@test".to_string(),
|
|
display_name: "sample".to_string(),
|
|
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 plugin_telemetry_metadata_uses_manifest_mcp_server_objects() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/counter-sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "counter-sample",
|
|
"version": "1.1.1",
|
|
"mcpServers": {
|
|
"counter": {
|
|
"type": "http",
|
|
"url": "https://sample.example/counter/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let metadata = plugin_telemetry_metadata_from_root(
|
|
&PluginId::parse("counter-sample@test").expect("plugin id should parse"),
|
|
&plugin_root.abs(),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
metadata.capability_summary,
|
|
Some(PluginCapabilitySummary {
|
|
config_name: "counter-sample@test".to_string(),
|
|
display_name: "counter-sample".to_string(),
|
|
description: None,
|
|
has_skills: false,
|
|
mcp_server_names: vec!["counter".to_string()],
|
|
app_connector_ids: Vec::new(),
|
|
})
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn capability_summary_sanitizes_plugin_descriptions_to_one_line() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "sample",
|
|
"description": "Plugin that\n includes the sample\tserver"
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/sample-search/SKILL.md"),
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
codex_home.path(),
|
|
/*auth_mode*/ None,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.plugins()[0].manifest_description.as_deref(),
|
|
Some("Plugin that\n includes the sample\tserver")
|
|
);
|
|
assert_eq!(
|
|
outcome.capability_summaries()[0].description.as_deref(),
|
|
Some("Plugin that includes the sample server")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn capability_summary_truncates_overlong_plugin_descriptions() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
let too_long = "x".repeat(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN + 1);
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
&format!(
|
|
r#"{{
|
|
"name": "sample",
|
|
"description": "{too_long}"
|
|
}}"#
|
|
),
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/sample-search/SKILL.md"),
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
codex_home.path(),
|
|
/*auth_mode*/ None,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.plugins()[0].manifest_description.as_deref(),
|
|
Some(too_long.as_str())
|
|
);
|
|
assert_eq!(
|
|
outcome.capability_summaries()[0].description,
|
|
Some("x".repeat(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_uses_manifest_configured_component_paths() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "sample",
|
|
"skills": "./custom-skills/",
|
|
"mcpServers": "./config/custom.mcp.json",
|
|
"apps": "./config/custom.app.json"
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/default-skill/SKILL.md"),
|
|
"---\nname: default-skill\ndescription: default skill\n---\n",
|
|
);
|
|
write_file(
|
|
&plugin_root.join("custom-skills/custom-skill/SKILL.md"),
|
|
"---\nname: custom-skill\ndescription: custom skill\n---\n",
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"default": {
|
|
"type": "http",
|
|
"url": "https://default.example/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("config/custom.mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"custom": {
|
|
"type": "http",
|
|
"url": "https://custom.example/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"default-app": {
|
|
"id": "connector_default"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("config/custom.app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"custom-app": {
|
|
"id": "connector_custom"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
codex_home.path(),
|
|
Some(AuthMode::Chatgpt),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.plugins()[0].skill_roots,
|
|
vec![
|
|
plugin_root.join("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,
|
|
},
|
|
environment_id: "local".to_string(),
|
|
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: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
)])
|
|
);
|
|
assert_eq!(
|
|
outcome.plugins()[0].apps,
|
|
vec![app_declaration("custom-app", "connector_custom")]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "sample",
|
|
"skills": "custom-skills",
|
|
"mcpServers": "config/custom.mcp.json",
|
|
"apps": "config/custom.app.json"
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/default-skill/SKILL.md"),
|
|
"---\nname: default-skill\ndescription: default skill\n---\n",
|
|
);
|
|
write_file(
|
|
&plugin_root.join("custom-skills/custom-skill/SKILL.md"),
|
|
"---\nname: custom-skill\ndescription: custom skill\n---\n",
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"default": {
|
|
"type": "http",
|
|
"url": "https://default.example/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("config/custom.mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"custom": {
|
|
"type": "http",
|
|
"url": "https://custom.example/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"default-app": {
|
|
"id": "connector_default"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("config/custom.app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"custom-app": {
|
|
"id": "connector_custom"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
codex_home.path(),
|
|
Some(AuthMode::Chatgpt),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.plugins()[0].skill_roots,
|
|
vec![plugin_root.join("skills").abs()]
|
|
);
|
|
assert_eq!(
|
|
outcome.plugins()[0].mcp_servers,
|
|
HashMap::from([(
|
|
"default".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url: "https://default.example/mcp".to_string(),
|
|
bearer_token_env_var: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
},
|
|
environment_id: "local".to_string(),
|
|
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: None,
|
|
oauth_resource: None,
|
|
tools: HashMap::new(),
|
|
},
|
|
)])
|
|
);
|
|
assert_eq!(
|
|
outcome.plugins()[0].apps,
|
|
vec![app_declaration("default-app", "connector_default")]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_ignores_invalid_manifest_skills_shape() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "sample",
|
|
"skills": ["./custom-skills/"]
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/default-skill/SKILL.md"),
|
|
"---\nname: default-skill\ndescription: default skill\n---\n",
|
|
);
|
|
write_file(
|
|
&plugin_root.join("custom-skills/custom-skill/SKILL.md"),
|
|
"---\nname: custom-skill\ndescription: custom skill\n---\n",
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
codex_home.path(),
|
|
/*auth_mode*/ None,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(outcome.plugins()[0].error, None);
|
|
assert_eq!(
|
|
outcome.plugins()[0].skill_roots,
|
|
vec![plugin_root.join("skills").abs()]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"sample": {
|
|
"type": "http",
|
|
"url": "https://sample.example/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(
|
|
/*enabled*/ false, /*plugins_feature_enabled*/ true,
|
|
),
|
|
codex_home.path(),
|
|
/*auth_mode*/ None,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.plugins(),
|
|
vec![LoadedPlugin {
|
|
config_name: "sample@test".to_string(),
|
|
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));
|
|
features.insert("apps".to_string(), Value::Boolean(true));
|
|
root.insert("features".to_string(), Value::Table(features));
|
|
|
|
let mut plugins = toml::map::Map::new();
|
|
|
|
let mut plugin_a = toml::map::Map::new();
|
|
plugin_a.insert("enabled".to_string(), Value::Boolean(true));
|
|
plugins.insert("plugin-a@test".to_string(), Value::Table(plugin_a));
|
|
|
|
let mut plugin_b = toml::map::Map::new();
|
|
plugin_b.insert("enabled".to_string(), Value::Boolean(true));
|
|
plugins.insert("plugin-b@test".to_string(), Value::Table(plugin_b));
|
|
|
|
root.insert("plugins".to_string(), Value::Table(plugins));
|
|
let config_toml =
|
|
toml::to_string(&Value::Table(root)).expect("plugin test config should serialize");
|
|
|
|
let outcome =
|
|
load_plugins_from_config(&config_toml, codex_home.path(), Some(AuthMode::Chatgpt)).await;
|
|
|
|
assert_eq!(
|
|
outcome.effective_apps(),
|
|
vec![
|
|
AppConnectorId("connector_example".to_string()),
|
|
AppConnectorId("connector_gmail".to_string()),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn effective_apps_preserves_app_config_order() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"slack": {
|
|
"id": "connector_slack"
|
|
},
|
|
"github": {
|
|
"id": "connector_github"
|
|
},
|
|
"slack-copy": {
|
|
"id": "connector_slack"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
codex_home.path(),
|
|
Some(AuthMode::Chatgpt),
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
outcome.effective_apps(),
|
|
vec![
|
|
AppConnectorId("connector_slack".to_string()),
|
|
AppConnectorId("connector_github".to_string()),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let connector = |id: &str| AppConnectorId(id.to_string());
|
|
let app = |name: &str, connector_id: &str| app_declaration(name, connector_id);
|
|
let http_server = |url: &str| McpServerConfig {
|
|
transport: McpServerTransportConfig::StreamableHttp {
|
|
url: url.to_string(),
|
|
bearer_token_env_var: None,
|
|
http_headers: None,
|
|
env_http_headers: None,
|
|
},
|
|
environment_id: "local".to_string(),
|
|
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: 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![app("example", "connector_example")],
|
|
..plugin("alpha@test", "alpha-plugin", "alpha-plugin")
|
|
},
|
|
LoadedPlugin {
|
|
mcp_servers: HashMap::from([("beta".to_string(), http_server("https://beta"))]),
|
|
apps: vec![
|
|
app("example", "connector_example"),
|
|
app("gmail", "connector_gmail"),
|
|
],
|
|
..plugin("beta@test", "beta-plugin", "beta-plugin")
|
|
},
|
|
plugin("empty@test", "empty-plugin", "empty-plugin"),
|
|
LoadedPlugin {
|
|
enabled: false,
|
|
skill_roots: vec![codex_home.path().join("disabled-plugin/skills").abs()],
|
|
apps: vec![app("hidden", "connector_hidden")],
|
|
..plugin("disabled@test", "disabled-plugin", "disabled-plugin")
|
|
},
|
|
LoadedPlugin {
|
|
apps: vec![app("broken", "connector_broken")],
|
|
error: Some("failed to load".to_string()),
|
|
..plugin("broken@test", "broken-plugin", "broken-plugin")
|
|
},
|
|
]);
|
|
|
|
assert_eq!(
|
|
outcome.capability_summaries(),
|
|
&[
|
|
PluginCapabilitySummary {
|
|
has_skills: true,
|
|
..summary("skills@test", "skills-plugin")
|
|
},
|
|
PluginCapabilitySummary {
|
|
mcp_server_names: vec!["alpha".to_string()],
|
|
app_connector_ids: vec![connector("connector_example")],
|
|
..summary("alpha@test", "alpha-plugin")
|
|
},
|
|
PluginCapabilitySummary {
|
|
mcp_server_names: vec!["beta".to_string()],
|
|
app_connector_ids: vec![
|
|
connector("connector_example"),
|
|
connector("connector_gmail"),
|
|
],
|
|
..summary("beta@test", "beta-plugin")
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_returns_empty_when_feature_disabled() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
write_file(
|
|
&plugin_root.join("skills/sample-search/SKILL.md"),
|
|
"---\nname: sample-search\ndescription: search sample data\n---\n",
|
|
);
|
|
write_file(
|
|
&codex_home.path().join(CONFIG_TOML_FILE),
|
|
&plugin_config_toml(
|
|
/*enabled*/ true, /*plugins_feature_enabled*/ false,
|
|
),
|
|
);
|
|
|
|
let config = load_config(codex_home.path(), codex_home.path()).await;
|
|
let outcome = PluginsManager::new(codex_home.path().to_path_buf())
|
|
.plugins_for_config(&config)
|
|
.await;
|
|
|
|
assert_eq!(outcome, PluginLoadOutcome::default());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_cache_ignores_unrelated_session_overrides() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
write_plugin(
|
|
codex_home.path().join("plugins/cache/test").as_path(),
|
|
"sample/local",
|
|
"sample",
|
|
);
|
|
write_file(
|
|
&plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"sample": {
|
|
"url": "https://sample.example/mcp"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
|
|
let user_file = codex_home.path().join(CONFIG_TOML_FILE).abs();
|
|
let user_config: toml::Value = toml::from_str(&plugin_config_toml(
|
|
/*enabled*/ true, /*plugins_feature_enabled*/ true,
|
|
))
|
|
.expect("user config should parse");
|
|
let stack = |session_config: &str| {
|
|
ConfigLayerStack::new(
|
|
vec![
|
|
ConfigLayerEntry::new(
|
|
ConfigLayerSource::User {
|
|
file: user_file.clone(),
|
|
profile: None,
|
|
},
|
|
user_config.clone(),
|
|
),
|
|
ConfigLayerEntry::new(
|
|
ConfigLayerSource::SessionFlags,
|
|
toml::from_str(session_config).expect("session config should parse"),
|
|
),
|
|
],
|
|
ConfigRequirements::default(),
|
|
ConfigRequirementsToml::default(),
|
|
)
|
|
.expect("config layer stack should build")
|
|
};
|
|
let config = |session_config| {
|
|
PluginsConfigInput::new(
|
|
stack(session_config),
|
|
/*plugins_enabled*/ true,
|
|
/*remote_plugin_enabled*/ false,
|
|
"https://chatgpt.com".to_string(),
|
|
)
|
|
};
|
|
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
|
|
|
let first = manager
|
|
.plugins_for_config(&config(r#"model = "first""#))
|
|
.await;
|
|
std::fs::remove_file(plugin_root.join(".mcp.json")).unwrap();
|
|
let second = manager
|
|
.plugins_for_config(&config(r#"model = "second""#))
|
|
.await;
|
|
|
|
assert_eq!(second, first);
|
|
assert_eq!(second.plugins()[0].mcp_servers.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn loaded_plugins_cache_invalidation_rejects_stale_load_completion() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let manager = PluginsManager::new(codex_home.path().to_path_buf());
|
|
let cache_key = PluginLoadCacheKey {
|
|
configured_plugins: HashMap::new(),
|
|
skill_config_rules: SkillConfigRules::default(),
|
|
remote_plugin_enabled: false,
|
|
};
|
|
let stale_generation = manager.loaded_plugins_cache_generation();
|
|
|
|
manager.clear_loaded_plugins_cache();
|
|
manager.cache_loaded_plugins_if_current(stale_generation, cache_key.clone(), Vec::new());
|
|
|
|
assert_eq!(manager.cached_loaded_plugins(&cache_key), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_plugins_rejects_invalid_plugin_keys() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
|
|
write_file(
|
|
&plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
);
|
|
|
|
let mut root = toml::map::Map::new();
|
|
let mut features = toml::map::Map::new();
|
|
features.insert("plugins".to_string(), Value::Boolean(true));
|
|
root.insert("features".to_string(), Value::Table(features));
|
|
|
|
let mut plugin = toml::map::Map::new();
|
|
plugin.insert("enabled".to_string(), Value::Boolean(true));
|
|
|
|
let mut plugins = toml::map::Map::new();
|
|
plugins.insert("sample".to_string(), Value::Table(plugin));
|
|
root.insert("plugins".to_string(), Value::Table(plugins));
|
|
|
|
let outcome = load_plugins_from_config(
|
|
&toml::to_string(&Value::Table(root)).expect("plugin test config should serialize"),
|
|
codex_home.path(),
|
|
/*auth_mode*/ None,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(outcome.plugins().len(), 1);
|
|
assert_eq!(
|
|
outcome.plugins()[0].error.as_deref(),
|
|
Some("invalid plugin key `sample`; expected <plugin>@<marketplace>")
|
|
);
|
|
assert!(outcome.effective_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()],
|
|
/*include_openai_curated*/ true,
|
|
)
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
let marketplace = marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| {
|
|
marketplace.path
|
|
== AbsolutePathBuf::try_from(
|
|
tmp.path().join("repo/.agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
})
|
|
.expect("expected repo marketplace entry");
|
|
|
|
assert_eq!(
|
|
marketplace,
|
|
ConfiguredMarketplace {
|
|
name: "debug".to_string(),
|
|
path: AbsolutePathBuf::try_from(
|
|
tmp.path().join("repo/.agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap(),
|
|
interface: None,
|
|
plugins: vec![
|
|
ConfiguredMarketplacePlugin {
|
|
id: "enabled-plugin@debug".to_string(),
|
|
name: "enabled-plugin".to_string(),
|
|
local_version: None,
|
|
installed_version: Some("local".to_string()),
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
|
|
.unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
installed: true,
|
|
enabled: true,
|
|
},
|
|
ConfiguredMarketplacePlugin {
|
|
id: "disabled-plugin@debug".to_string(),
|
|
name: "disabled-plugin".to_string(),
|
|
local_version: None,
|
|
installed_version: Some("local".to_string()),
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),)
|
|
.unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
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()],
|
|
/*include_openai_curated*/ true,
|
|
)
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
assert_eq!(marketplaces, Vec::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_excludes_plugins_with_explicit_empty_products() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
|
fs::write(
|
|
repo_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "disabled-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./disabled-plugin"
|
|
},
|
|
"policy": {
|
|
"products": []
|
|
}
|
|
},
|
|
{
|
|
"name": "default-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./default-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
|
|
let config = load_config(tmp.path(), &repo_root).await;
|
|
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
|
.list_marketplaces_for_config(
|
|
&config,
|
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
|
/*include_openai_curated*/ true,
|
|
)
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
let marketplace = marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| {
|
|
marketplace.path
|
|
== AbsolutePathBuf::try_from(
|
|
tmp.path().join("repo/.agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
})
|
|
.expect("expected repo marketplace entry");
|
|
assert_eq!(
|
|
marketplace.plugins,
|
|
vec![ConfiguredMarketplacePlugin {
|
|
id: "default-plugin@debug".to_string(),
|
|
name: "default-plugin".to_string(),
|
|
local_version: None,
|
|
installed_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
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_filters_mcp_servers_for_codex_backend_auth() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./sample-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&repo_root.join("sample-plugin/.codex-plugin/plugin.json"),
|
|
r#"{"name":"sample-plugin"}"#,
|
|
);
|
|
write_file(
|
|
&repo_root.join("sample-plugin/.app.json"),
|
|
r#"{"apps":{"sample-mcp":{"id":"connector_sample"}}}"#,
|
|
);
|
|
write_file(
|
|
&repo_root.join("sample-plugin/.mcp.json"),
|
|
r#"{"mcpServers":{"other-mcp":{"command":"other-mcp"},"sample-mcp":{"command":"sample-mcp"}}}"#,
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
|
|
let config = load_config(tmp.path(), &repo_root).await;
|
|
let request = PluginReadRequest {
|
|
plugin_name: "sample-plugin".to_string(),
|
|
marketplace_path: AbsolutePathBuf::try_from(
|
|
repo_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap(),
|
|
};
|
|
|
|
let chatgpt_outcome = PluginsManager::new_with_options(
|
|
tmp.path().to_path_buf(),
|
|
Some(Product::Codex),
|
|
Some(AuthMode::Chatgpt),
|
|
)
|
|
.read_plugin_for_config(&config, &request)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
chatgpt_outcome.plugin.mcp_server_names,
|
|
vec!["other-mcp".to_string()]
|
|
);
|
|
assert_eq!(
|
|
chatgpt_outcome.plugin.apps,
|
|
vec![AppConnectorId("connector_sample".to_string())]
|
|
);
|
|
|
|
let api_key_outcome = PluginsManager::new_with_options(
|
|
tmp.path().to_path_buf(),
|
|
Some(Product::Codex),
|
|
Some(AuthMode::ApiKey),
|
|
)
|
|
.read_plugin_for_config(&config, &request)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
api_key_outcome.plugin.mcp_server_names,
|
|
vec!["other-mcp".to_string(), "sample-mcp".to_string()]
|
|
);
|
|
assert!(api_key_outcome.plugin.apps.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn read_plugin_for_config_uses_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",
|
|
"category": "First Category"
|
|
},
|
|
"calendar_duplicate": {
|
|
"id": "connector_calendar",
|
|
"category": "Second Category"
|
|
}
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&cached_plugin_root.join(".mcp.json"),
|
|
r#"{"mcpServers":{"toolkit":{"command":"toolkit-mcp"}}}"#,
|
|
);
|
|
write_file(
|
|
&cached_plugin_root.join("hooks/hooks.json"),
|
|
r#"{
|
|
"hooks": {
|
|
"SessionStart": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "echo startup"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"PreToolUse": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "echo first"
|
|
},
|
|
{
|
|
"type": "command",
|
|
"command": "echo second"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."toolkit@debug"]
|
|
enabled = true
|
|
|
|
[hooks.state."toolkit@debug:hooks/hooks.json:pre_tool_use:0:0"]
|
|
enabled = false
|
|
"#,
|
|
);
|
|
|
|
let config = load_config(tmp.path(), &repo_root).await;
|
|
let outcome = 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.app_category_by_id,
|
|
HashMap::from([(
|
|
"connector_calendar".to_string(),
|
|
"First Category".to_string()
|
|
)])
|
|
);
|
|
assert_eq!(
|
|
outcome.plugin.hooks,
|
|
vec![
|
|
PluginHookSummary {
|
|
key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:0".to_string(),
|
|
event_name: HookEventName::PreToolUse,
|
|
},
|
|
PluginHookSummary {
|
|
key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:1".to_string(),
|
|
event_name: HookEventName::PreToolUse,
|
|
},
|
|
PluginHookSummary {
|
|
key: "toolkit@debug:hooks/hooks.json:session_start:0:0".to_string(),
|
|
event_name: HookEventName::SessionStart,
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]);
|
|
assert!(
|
|
!tmp.path()
|
|
.join("plugins/.marketplace-plugin-source-staging")
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_installed_git_source_reads_metadata_from_cache_without_cloning() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo");
|
|
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
|
|
.unwrap()
|
|
.to_string();
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
&format!(
|
|
r#"{{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{{
|
|
"name": "toolkit",
|
|
"source": {{
|
|
"source": "git-subdir",
|
|
"url": "{missing_remote_repo_url}",
|
|
"path": "plugins/toolkit"
|
|
}},
|
|
"category": "Developer Tools"
|
|
}}
|
|
]
|
|
}}"#
|
|
),
|
|
);
|
|
let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local");
|
|
write_file(
|
|
&cached_plugin_root.join(".codex-plugin/plugin.json"),
|
|
r##"{
|
|
"name": "toolkit",
|
|
"interface": {
|
|
"displayName": "Toolkit",
|
|
"shortDescription": "Search cached data",
|
|
"category": "Cached Category",
|
|
"brandColor": "#3B82F6",
|
|
"composerIcon": "./assets/icon.png",
|
|
"logo": "./assets/logo.png",
|
|
"screenshots": ["./assets/screenshot.png"]
|
|
}
|
|
}"##,
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."toolkit@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
let config = load_config(tmp.path(), &repo_root).await;
|
|
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
|
.list_marketplaces_for_config(
|
|
&config,
|
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
|
/*include_openai_curated*/ true,
|
|
)
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
let marketplace = marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.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,
|
|
installed_version: Some("local".to_string()),
|
|
source: MarketplacePluginSource::Git {
|
|
url: missing_remote_repo_url,
|
|
path: Some("plugins/toolkit".to_string()),
|
|
ref_name: None,
|
|
sha: None,
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: Some(PluginManifestInterface {
|
|
display_name: Some("Toolkit".to_string()),
|
|
short_description: Some("Search cached data".to_string()),
|
|
category: Some("Developer Tools".to_string()),
|
|
brand_color: Some("#3B82F6".to_string()),
|
|
composer_icon: Some(
|
|
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/icon.png")).unwrap(),
|
|
),
|
|
logo: Some(
|
|
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/logo.png")).unwrap(),
|
|
),
|
|
screenshots: vec![
|
|
AbsolutePathBuf::try_from(cached_plugin_root.join("assets/screenshot.png"))
|
|
.unwrap(),
|
|
],
|
|
..Default::default()
|
|
}),
|
|
keywords: Vec::new(),
|
|
installed: true,
|
|
enabled: true,
|
|
}]
|
|
);
|
|
assert!(
|
|
!tmp.path()
|
|
.join("plugins/.marketplace-plugin-source-staging")
|
|
.exists()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_includes_curated_repo_marketplace() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
let plugin_root = curated_root.join("plugins/linear");
|
|
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap();
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
|
fs::write(
|
|
curated_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "openai-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "linear",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/linear"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"linear"}"#,
|
|
)
|
|
.unwrap();
|
|
|
|
let config = load_config(tmp.path(), tmp.path()).await;
|
|
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
|
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
let curated_marketplace = marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated")
|
|
.expect("curated marketplace should be listed");
|
|
|
|
assert_eq!(
|
|
curated_marketplace,
|
|
ConfiguredMarketplace {
|
|
name: "openai-curated".to_string(),
|
|
path: AbsolutePathBuf::try_from(curated_root.join(".agents/plugins/marketplace.json"))
|
|
.unwrap(),
|
|
interface: None,
|
|
plugins: vec![ConfiguredMarketplacePlugin {
|
|
id: "linear@openai-curated".to_string(),
|
|
name: "linear".to_string(),
|
|
local_version: None,
|
|
installed_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
installed: false,
|
|
enabled: false,
|
|
}],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_can_skip_openai_curated_before_loading() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
write_file(
|
|
&curated_root.join(".agents/plugins/marketplace.json"),
|
|
"{not valid json",
|
|
);
|
|
|
|
let config = load_config(tmp.path(), tmp.path()).await;
|
|
let outcome = PluginsManager::new(tmp.path().to_path_buf())
|
|
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ false)
|
|
.unwrap();
|
|
|
|
assert_eq!(outcome.errors, Vec::new());
|
|
assert_eq!(
|
|
outcome
|
|
.marketplaces
|
|
.iter()
|
|
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME),
|
|
false
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_uses_api_curated_manifest_when_selected() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
write_file(
|
|
&curated_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "openai-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "siwc-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/siwc-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&curated_root.join(".agents/plugins/api_marketplace.json"),
|
|
r#"{
|
|
"name": "openai-api-curated",
|
|
"interface": {
|
|
"displayName": "OpenAI Curated"
|
|
},
|
|
"plugins": [
|
|
{
|
|
"name": "api-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/api-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
);
|
|
|
|
let config = load_config(tmp.path(), tmp.path()).await;
|
|
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
|
manager.set_auth_mode(Some(AuthMode::ApiKey));
|
|
let marketplaces = manager
|
|
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
|
.unwrap()
|
|
.marketplaces;
|
|
let curated_marketplace = marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.name == OPENAI_API_CURATED_MARKETPLACE_NAME)
|
|
.expect("API curated marketplace should be listed");
|
|
|
|
assert_eq!(
|
|
curated_marketplace,
|
|
ConfiguredMarketplace {
|
|
name: "openai-api-curated".to_string(),
|
|
path: AbsolutePathBuf::try_from(
|
|
curated_root.join(".agents/plugins/api_marketplace.json")
|
|
)
|
|
.unwrap(),
|
|
interface: Some(MarketplaceInterface {
|
|
display_name: Some("OpenAI Curated".to_string()),
|
|
}),
|
|
plugins: vec![ConfiguredMarketplacePlugin {
|
|
id: "api-plugin@openai-api-curated".to_string(),
|
|
name: "api-plugin".to_string(),
|
|
local_version: None,
|
|
installed_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(curated_root.join("plugins/api-plugin"))
|
|
.unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
installed: false,
|
|
enabled: false,
|
|
}],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_skips_missing_api_curated_manifest() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
write_file(
|
|
&curated_root.join(".agents/plugins/marketplace.json"),
|
|
"{not valid json",
|
|
);
|
|
|
|
let config = load_config(tmp.path(), tmp.path()).await;
|
|
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
|
manager.set_auth_mode(Some(AuthMode::BedrockApiKey));
|
|
let outcome = manager
|
|
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
|
.unwrap();
|
|
|
|
assert_eq!(outcome.errors, Vec::new());
|
|
assert_eq!(
|
|
outcome
|
|
.marketplaces
|
|
.iter()
|
|
.any(|marketplace| marketplace.name == OPENAI_API_CURATED_MARKETPLACE_NAME),
|
|
false
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_includes_installed_marketplace_roots() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
|
|
let plugin_root = marketplace_root.join("plugins/sample");
|
|
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[marketplaces.debug]
|
|
last_updated = "2026-04-10T12:34:56Z"
|
|
source_type = "git"
|
|
source = "/tmp/debug"
|
|
"#,
|
|
);
|
|
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
|
fs::write(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/sample"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
)
|
|
.unwrap();
|
|
let config = load_config(tmp.path(), tmp.path()).await;
|
|
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
|
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
let marketplace = marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| {
|
|
marketplace.path
|
|
== AbsolutePathBuf::try_from(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
})
|
|
.expect("installed marketplace should be listed");
|
|
|
|
assert_eq!(
|
|
marketplace.path,
|
|
AbsolutePathBuf::try_from(marketplace_root.join(".agents/plugins/marketplace.json"))
|
|
.unwrap()
|
|
);
|
|
assert_eq!(marketplace.plugins.len(), 1);
|
|
assert_eq!(marketplace.plugins[0].id, "sample@debug");
|
|
assert_eq!(
|
|
marketplace.plugins[0].source,
|
|
MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(plugin_root).unwrap(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn 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, &[], /*include_openai_curated*/ true)
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
let marketplace = marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| {
|
|
marketplace.path
|
|
== AbsolutePathBuf::try_from(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
})
|
|
.expect("configured marketplace should be discovered");
|
|
|
|
assert_eq!(marketplace.plugins[0].id, "sample@debug");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_ignores_installed_roots_missing_from_config() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let marketplace_root = marketplace_install_root(tmp.path()).join("debug");
|
|
let plugin_root = marketplace_root.join("plugins/sample");
|
|
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
fs::create_dir_all(marketplace_root.join(".agents/plugins")).unwrap();
|
|
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
|
fs::write(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/sample"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r#"{"name":"sample"}"#,
|
|
)
|
|
.unwrap();
|
|
let config = load_config(tmp.path(), tmp.path()).await;
|
|
let marketplaces = PluginsManager::new(tmp.path().to_path_buf())
|
|
.list_marketplaces_for_config(&config, &[], /*include_openai_curated*/ true)
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
assert!(
|
|
marketplaces.iter().all(|marketplace| {
|
|
marketplace.path
|
|
!= AbsolutePathBuf::try_from(
|
|
marketplace_root.join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
}),
|
|
"installed marketplace root missing from config should not be listed"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn list_marketplaces_uses_first_duplicate_plugin_entry() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_a_root = tmp.path().join("repo-a");
|
|
let repo_b_root = tmp.path().join("repo-b");
|
|
fs::create_dir_all(repo_a_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_b_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_a_root.join(".agents/plugins")).unwrap();
|
|
fs::create_dir_all(repo_b_root.join(".agents/plugins")).unwrap();
|
|
fs::write(
|
|
repo_a_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "dup-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./from-a"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
repo_b_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "dup-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./from-b"
|
|
}
|
|
},
|
|
{
|
|
"name": "b-only-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./from-b-only"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)
|
|
.unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."dup-plugin@debug"]
|
|
enabled = true
|
|
|
|
[plugins."b-only-plugin@debug"]
|
|
enabled = false
|
|
"#,
|
|
);
|
|
|
|
let config = load_config(tmp.path(), &repo_a_root).await;
|
|
let marketplaces = 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(),
|
|
],
|
|
/*include_openai_curated*/ true,
|
|
)
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
let repo_a_marketplace = marketplaces
|
|
.iter()
|
|
.find(|marketplace| {
|
|
marketplace.path
|
|
== AbsolutePathBuf::try_from(
|
|
tmp.path().join("repo-a/.agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
})
|
|
.expect("repo-a marketplace should be listed");
|
|
assert_eq!(
|
|
repo_a_marketplace.plugins,
|
|
vec![ConfiguredMarketplacePlugin {
|
|
id: "dup-plugin@debug".to_string(),
|
|
name: "dup-plugin".to_string(),
|
|
local_version: None,
|
|
installed_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
installed: false,
|
|
enabled: true,
|
|
}]
|
|
);
|
|
|
|
let repo_b_marketplace = marketplaces
|
|
.iter()
|
|
.find(|marketplace| {
|
|
marketplace.path
|
|
== AbsolutePathBuf::try_from(
|
|
tmp.path().join("repo-b/.agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
})
|
|
.expect("repo-b marketplace should be listed");
|
|
assert_eq!(
|
|
repo_b_marketplace.plugins,
|
|
vec![ConfiguredMarketplacePlugin {
|
|
id: "b-only-plugin@debug".to_string(),
|
|
name: "b-only-plugin".to_string(),
|
|
local_version: None,
|
|
installed_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
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()],
|
|
/*include_openai_curated*/ true,
|
|
)
|
|
.unwrap()
|
|
.marketplaces;
|
|
|
|
let marketplace = marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| {
|
|
marketplace.path
|
|
== AbsolutePathBuf::try_from(
|
|
tmp.path().join("repo/.agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap()
|
|
})
|
|
.expect("expected repo marketplace entry");
|
|
|
|
assert_eq!(
|
|
marketplace,
|
|
ConfiguredMarketplace {
|
|
name: "debug".to_string(),
|
|
path: AbsolutePathBuf::try_from(
|
|
tmp.path().join("repo/.agents/plugins/marketplace.json"),
|
|
)
|
|
.unwrap(),
|
|
interface: None,
|
|
plugins: vec![ConfiguredMarketplacePlugin {
|
|
id: "sample-plugin@debug".to_string(),
|
|
name: "sample-plugin".to_string(),
|
|
local_version: None,
|
|
installed_version: None,
|
|
source: MarketplacePluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(),
|
|
},
|
|
policy: MarketplacePluginPolicy {
|
|
installation: MarketplacePluginInstallPolicy::Available,
|
|
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
|
products: None,
|
|
},
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
installed: false,
|
|
enabled: true,
|
|
}],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/plugins/featured"))
|
|
.and(query_param("platform", "chat"))
|
|
.and(header("authorization", "Bearer Access Token"))
|
|
.and(header("chatgpt-account-id", "account_id"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["chat-plugin"]"#))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut config = load_config(tmp.path(), tmp.path()).await;
|
|
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
|
|
let manager = PluginsManager::new_with_options(
|
|
tmp.path().to_path_buf(),
|
|
Some(Product::Chatgpt),
|
|
/*auth_mode*/ None,
|
|
);
|
|
|
|
let featured_plugin_ids = manager
|
|
.featured_plugin_ids_for_config(
|
|
&config,
|
|
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(featured_plugin_ids, vec!["chat-plugin".to_string()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn featured_plugin_ids_for_config_defaults_query_param_to_codex() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/plugins/featured"))
|
|
.and(query_param("platform", "codex"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["codex-plugin"]"#))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut config = load_config(tmp.path(), tmp.path()).await;
|
|
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
|
|
let manager = PluginsManager::new_with_options(
|
|
tmp.path().to_path_buf(),
|
|
/*restriction_product*/ None,
|
|
/*auth_mode*/ None,
|
|
);
|
|
|
|
let featured_plugin_ids = manager
|
|
.featured_plugin_ids_for_config(&config, /*auth*/ None)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_eq!(featured_plugin_ids, vec!["codex-plugin".to_string()]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn remote_plugin_caches_refresh_warms_recommended_plugins_cache() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
"#,
|
|
);
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/ps/plugins/suggested"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"enabled": true,
|
|
"plugins": []
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut config = load_config(tmp.path(), tmp.path()).await;
|
|
config.chatgpt_base_url = server.uri();
|
|
let manager = std::sync::Arc::new(PluginsManager::new(tmp.path().to_path_buf()));
|
|
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
|
let cache_key = recommended_plugins_cache_key(&config);
|
|
|
|
manager.maybe_start_remote_plugin_caches_refresh(
|
|
&config,
|
|
Some(auth.clone()),
|
|
/*on_effective_plugins_changed*/ None,
|
|
);
|
|
|
|
let mode = tokio::time::timeout(Duration::from_secs(2), async {
|
|
loop {
|
|
if let Some(mode) = manager.cached_recommended_plugins_mode(&cache_key) {
|
|
break mode;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await
|
|
.expect("recommended plugins cache should be warmed");
|
|
assert_eq!(
|
|
mode,
|
|
RecommendedPluginsMode::Endpoint {
|
|
plugins: Vec::new()
|
|
}
|
|
);
|
|
assert_eq!(
|
|
manager
|
|
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
|
.await,
|
|
mode
|
|
);
|
|
manager.clear_recommended_plugins_cache();
|
|
assert_eq!(manager.cached_recommended_plugins_mode(&cache_key), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recommended_plugins_mode_deduplicates_concurrent_cache_misses() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
"#,
|
|
);
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/ps/plugins/suggested"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.and(header("authorization", "Bearer Access Token"))
|
|
.and(header("chatgpt-account-id", "account_id"))
|
|
.and(header("OAI-Product-Sku", "codex"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_json(serde_json::json!({
|
|
"enabled": true,
|
|
"plugins": [
|
|
{
|
|
"id": "plugin_slack",
|
|
"name": "slack",
|
|
"release": {
|
|
"display_name": "Slack",
|
|
"app_ids": ["connector_slack"]
|
|
}
|
|
},
|
|
{
|
|
"id": "plugin_github",
|
|
"name": "github",
|
|
"release": {"display_name": "GitHub"}
|
|
}
|
|
]
|
|
}))
|
|
.set_delay(Duration::from_millis(100)),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut config = load_config(tmp.path(), tmp.path()).await;
|
|
config.chatgpt_base_url = server.uri();
|
|
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
|
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
|
let expected = RecommendedPluginsMode::Endpoint {
|
|
plugins: vec![
|
|
RecommendedPlugin {
|
|
config_id: "github@openai-curated-remote".to_string(),
|
|
remote_plugin_id: "plugin_github".to_string(),
|
|
display_name: "GitHub".to_string(),
|
|
app_connector_ids: Vec::new(),
|
|
},
|
|
RecommendedPlugin {
|
|
config_id: "slack@openai-curated-remote".to_string(),
|
|
remote_plugin_id: "plugin_slack".to_string(),
|
|
display_name: "Slack".to_string(),
|
|
app_connector_ids: vec!["connector_slack".to_string()],
|
|
},
|
|
],
|
|
};
|
|
|
|
let (left, right) = tokio::join!(
|
|
manager.recommended_plugins_mode_for_config(&config, Some(&auth)),
|
|
manager.recommended_plugins_mode_for_config(&config, Some(&auth)),
|
|
);
|
|
assert_eq!((left, right), (expected.clone(), expected.clone()));
|
|
assert_eq!(
|
|
manager
|
|
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
|
.await,
|
|
expected
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recommended_plugin_candidates_filter_installed_and_disabled_plugins() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
"#,
|
|
);
|
|
write_cached_plugin(tmp.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear");
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/ps/plugins/suggested"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"enabled": true,
|
|
"plugins": [
|
|
{
|
|
"id": "plugin_linear",
|
|
"name": "linear",
|
|
"release": {"display_name": "Linear"}
|
|
},
|
|
{
|
|
"id": "plugin_github",
|
|
"name": "github",
|
|
"release": {"display_name": "GitHub"}
|
|
},
|
|
{
|
|
"id": "plugin_slack",
|
|
"name": "slack",
|
|
"release": {"display_name": "Slack"}
|
|
}
|
|
]
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut config = load_config(tmp.path(), tmp.path()).await;
|
|
config.chatgpt_base_url = server.uri();
|
|
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
|
manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]);
|
|
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
|
let disabled_tools = [ToolSuggestDisabledTool::plugin(
|
|
"github@openai-curated-remote",
|
|
)];
|
|
let loaded_plugins = manager.plugins_for_config(&config).await;
|
|
|
|
let candidates = manager
|
|
.recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput {
|
|
plugins_config: &config,
|
|
loaded_plugins: &loaded_plugins,
|
|
auth: Some(&auth),
|
|
disabled_tools: &disabled_tools,
|
|
app_server_client_name: None,
|
|
})
|
|
.await;
|
|
|
|
assert_eq!(
|
|
candidates,
|
|
Some(vec![DiscoverableTool::from(DiscoverablePluginInfo {
|
|
id: "slack@openai-curated-remote".to_string(),
|
|
remote_plugin_id: Some("plugin_slack".to_string()),
|
|
name: "Slack".to_string(),
|
|
description: None,
|
|
has_skills: false,
|
|
mcp_server_names: Vec::new(),
|
|
app_connector_ids: Vec::new(),
|
|
})])
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recommended_plugins_mode_caches_explicit_false() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
"#,
|
|
);
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/ps/plugins/suggested"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"enabled": false,
|
|
"plugins": []
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut config = load_config(tmp.path(), tmp.path()).await;
|
|
config.chatgpt_base_url = server.uri();
|
|
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
|
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
|
assert_eq!(
|
|
manager
|
|
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
|
.await,
|
|
RecommendedPluginsMode::Legacy
|
|
);
|
|
assert_eq!(
|
|
manager
|
|
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
|
.await,
|
|
RecommendedPluginsMode::Legacy
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn recommended_plugins_mode_retries_after_fetch_failure() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
"#,
|
|
);
|
|
|
|
let server = MockServer::start().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/ps/plugins/suggested"))
|
|
.respond_with(ResponseTemplate::new(500).set_body_string("unavailable"))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut config = load_config(tmp.path(), tmp.path()).await;
|
|
config.chatgpt_base_url = server.uri();
|
|
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
|
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
|
assert_eq!(
|
|
manager
|
|
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
|
.await,
|
|
RecommendedPluginsMode::Legacy
|
|
);
|
|
|
|
server.reset().await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/ps/plugins/suggested"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
|
"enabled": true,
|
|
"plugins": []
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
assert_eq!(
|
|
manager
|
|
.recommended_plugins_mode_for_config(&config, Some(&auth))
|
|
.await,
|
|
RecommendedPluginsMode::Endpoint {
|
|
plugins: Vec::new()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_curated_plugin_cache_replaces_existing_local_version_with_short_sha_version() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
|
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
|
let plugin_id = PluginId::new(
|
|
"slack".to_string(),
|
|
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
|
)
|
|
.unwrap();
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/openai-curated"),
|
|
"slack/local",
|
|
"slack",
|
|
);
|
|
|
|
assert!(
|
|
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
|
.expect("cache refresh should succeed")
|
|
);
|
|
|
|
assert!(
|
|
!tmp.path()
|
|
.join("plugins/cache/openai-curated/slack/local")
|
|
.exists()
|
|
);
|
|
assert!(
|
|
tmp.path()
|
|
.join(format!(
|
|
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
|
|
))
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_current_short_version() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
|
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
|
let plugin_id = PluginId::new(
|
|
"slack".to_string(),
|
|
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(
|
|
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
|
.expect("cache refresh should recreate missing configured plugin")
|
|
);
|
|
|
|
assert!(
|
|
tmp.path()
|
|
.join(format!(
|
|
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
|
|
))
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_curated_plugin_cache_reinstalls_missing_api_curated_plugin() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &[]);
|
|
write_openai_api_curated_marketplace(&curated_root, &["api-only"]);
|
|
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
|
let plugin_id = PluginId::new(
|
|
"api-only".to_string(),
|
|
OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(
|
|
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
|
.expect("cache refresh should recreate missing configured API curated plugin")
|
|
);
|
|
|
|
assert!(
|
|
tmp.path()
|
|
.join(format!(
|
|
"plugins/cache/openai-api-curated/api-only/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
|
|
))
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_curated_plugin_cache_leaves_api_curated_plugin_when_api_manifest_missing() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &[]);
|
|
write_cached_plugin(tmp.path(), OPENAI_API_CURATED_MARKETPLACE_NAME, "api-only");
|
|
let plugin_id = PluginId::new(
|
|
"api-only".to_string(),
|
|
OPENAI_API_CURATED_MARKETPLACE_NAME.to_string(),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(
|
|
!refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
|
.expect("cache refresh should skip missing API curated manifest")
|
|
);
|
|
assert!(
|
|
tmp.path()
|
|
.join("plugins/cache/openai-api-curated/api-only/local")
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_curated_plugin_cache_removes_cache_for_plugin_removed_from_marketplace() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &[]);
|
|
let plugin_id = PluginId::new(
|
|
"google-sheets".to_string(),
|
|
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
|
)
|
|
.unwrap();
|
|
let plugin_cache_root = tmp
|
|
.path()
|
|
.join("plugins/cache/openai-curated/google-sheets");
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/openai-curated"),
|
|
&format!("google-sheets/{TEST_CURATED_PLUGIN_CACHE_VERSION}"),
|
|
"google-sheets",
|
|
);
|
|
|
|
assert!(
|
|
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
|
.expect("cache refresh should remove stale configured plugin")
|
|
);
|
|
|
|
assert!(!plugin_cache_root.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn curated_plugin_ids_from_config_keys_reads_latest_codex_home_user_config() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."slack@openai-curated"]
|
|
enabled = true
|
|
|
|
[plugins."api-only@openai-api-curated"]
|
|
enabled = true
|
|
|
|
[plugins."sample@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
assert_eq!(
|
|
configured_curated_plugin_ids_from_codex_home(tmp.path())
|
|
.into_iter()
|
|
.map(|plugin_id| plugin_id.as_key())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"api-only@openai-api-curated".to_string(),
|
|
"slack@openai-curated".to_string(),
|
|
]
|
|
);
|
|
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
);
|
|
|
|
assert_eq!(
|
|
configured_curated_plugin_ids_from_codex_home(tmp.path()),
|
|
Vec::<PluginId>::new()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
|
let plugin_id = PluginId::new(
|
|
"slack".to_string(),
|
|
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
|
)
|
|
.unwrap();
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/openai-curated"),
|
|
&format!("slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"),
|
|
"slack",
|
|
);
|
|
|
|
assert!(
|
|
!refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
|
.expect("cache refresh should be a no-op when configured plugins are current")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_curated_plugin_cache_migrates_full_sha_cache_version_to_short_version() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let curated_root = curated_plugins_repo_path(tmp.path());
|
|
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
|
let plugin_id = PluginId::new(
|
|
"slack".to_string(),
|
|
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
|
)
|
|
.unwrap();
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/openai-curated"),
|
|
&format!("slack/{TEST_CURATED_PLUGIN_SHA}"),
|
|
"slack",
|
|
);
|
|
|
|
assert!(
|
|
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
|
.expect("cache refresh should migrate the full sha cache version")
|
|
);
|
|
assert!(
|
|
!tmp.path()
|
|
.join(format!(
|
|
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_SHA}"
|
|
))
|
|
.exists()
|
|
);
|
|
assert!(
|
|
tmp.path()
|
|
.join(format!(
|
|
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
|
|
))
|
|
.is_dir()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn refresh_non_curated_plugin_cache_replaces_existing_local_version_with_manifest_version() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let repo_root = tmp.path().join("repo");
|
|
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
|
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
|
write_plugin_with_version(&repo_root, "sample-plugin", "sample-plugin", Some("1.2.3"));
|
|
write_file(
|
|
&repo_root.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./sample-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
);
|
|
write_plugin(
|
|
&tmp.path().join("plugins/cache/debug"),
|
|
"sample-plugin/local",
|
|
"sample-plugin",
|
|
);
|
|
write_file(
|
|
&tmp.path().join(CONFIG_TOML_FILE),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."sample-plugin@debug"]
|
|
enabled = true
|
|
"#,
|
|
);
|
|
|
|
assert!(
|
|
refresh_non_curated_plugin_cache(
|
|
tmp.path(),
|
|
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
|
)
|
|
.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 plugins = load_plugins_from_layer_stack(
|
|
&stack,
|
|
std::collections::HashMap::new(),
|
|
&PluginStore::new(codex_home.path().to_path_buf()),
|
|
Some(Product::Codex),
|
|
/*prefer_remote_curated_conflicts*/ false,
|
|
)
|
|
.await;
|
|
|
|
assert_eq!(plugins, Vec::new());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_hooks_for_layer_stack_loads_configured_plugin_hooks() {
|
|
let codex_home = TempDir::new().unwrap();
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join("test/sample/local");
|
|
write_plugin(
|
|
codex_home.path().join("plugins/cache/test").as_path(),
|
|
"sample/local",
|
|
"sample",
|
|
);
|
|
write_file(
|
|
&plugin_root.join("hooks/hooks.json"),
|
|
r#"{
|
|
"hooks": {
|
|
"SessionStart": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "echo startup"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}"#,
|
|
);
|
|
write_file(
|
|
&codex_home.path().join(CONFIG_TOML_FILE),
|
|
&plugin_config_toml(/*enabled*/ true, /*plugins_feature_enabled*/ true),
|
|
);
|
|
let config = load_config(codex_home.path(), codex_home.path()).await;
|
|
|
|
let outcome = PluginsManager::new(codex_home.path().to_path_buf())
|
|
.plugin_hooks_for_layer_stack(&config.config_layer_stack, &config)
|
|
.await;
|
|
|
|
assert_eq!(outcome.hook_sources.len(), 1);
|
|
assert_eq!(
|
|
outcome.hook_sources[0].source_relative_path,
|
|
"hooks/hooks.json"
|
|
);
|
|
assert_eq!(outcome.hook_load_warnings, Vec::<String>::new());
|
|
}
|