mirror of
https://github.com/openai/codex.git
synced 2026-04-29 02:41:12 +03:00
Resubmit https://github.com/openai/codex/pull/15020 with correct content. 1. Use requirement-resolved config.features as the plugin gate. 2. Guard plugin/list, plugin/read, and related flows behind that gate. 3. Skip bad marketplace.json files instead of failing the whole list. 4. Simplify plugin state and caching.
398 lines
11 KiB
Rust
398 lines
11 KiB
Rust
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use app_test_support::McpProcess;
|
|
use app_test_support::to_response;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::PluginAuthPolicy;
|
|
use codex_app_server_protocol::PluginInstallPolicy;
|
|
use codex_app_server_protocol::PluginReadParams;
|
|
use codex_app_server_protocol::PluginReadResponse;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
|
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
|
std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer"))?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/demo-plugin"
|
|
},
|
|
"policy": {
|
|
"installation": "AVAILABLE",
|
|
"authentication": "ON_INSTALL"
|
|
},
|
|
"category": "Design"
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r##"{
|
|
"name": "demo-plugin",
|
|
"description": "Longer manifest description",
|
|
"interface": {
|
|
"displayName": "Plugin Display Name",
|
|
"shortDescription": "Short description for subtitle",
|
|
"longDescription": "Long description for details page",
|
|
"developerName": "OpenAI",
|
|
"category": "Productivity",
|
|
"capabilities": ["Interactive", "Write"],
|
|
"websiteURL": "https://openai.com/",
|
|
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
|
|
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
|
|
"defaultPrompt": [
|
|
"Draft the reply",
|
|
"Find my next action"
|
|
],
|
|
"brandColor": "#3B82F6",
|
|
"composerIcon": "./assets/icon.png",
|
|
"logo": "./assets/logo.png",
|
|
"screenshots": ["./assets/screenshot1.png"]
|
|
}
|
|
}"##,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join("skills/thread-summarizer/SKILL.md"),
|
|
r#"---
|
|
name: thread-summarizer
|
|
description: Summarize email threads
|
|
---
|
|
|
|
# Thread Summarizer
|
|
"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join(".app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"gmail": {
|
|
"id": "gmail"
|
|
}
|
|
}
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"demo": {
|
|
"command": "demo-server"
|
|
}
|
|
}
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."demo-plugin@codex-curated"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: marketplace_path.clone(),
|
|
plugin_name: "demo-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.plugin.marketplace_name, "codex-curated");
|
|
assert_eq!(response.plugin.marketplace_path, marketplace_path);
|
|
assert_eq!(response.plugin.summary.id, "demo-plugin@codex-curated");
|
|
assert_eq!(response.plugin.summary.name, "demo-plugin");
|
|
assert_eq!(
|
|
response.plugin.description.as_deref(),
|
|
Some("Longer manifest description")
|
|
);
|
|
assert_eq!(response.plugin.summary.installed, true);
|
|
assert_eq!(response.plugin.summary.enabled, true);
|
|
assert_eq!(
|
|
response.plugin.summary.install_policy,
|
|
PluginInstallPolicy::Available
|
|
);
|
|
assert_eq!(
|
|
response.plugin.summary.auth_policy,
|
|
PluginAuthPolicy::OnInstall
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.plugin
|
|
.summary
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Plugin Display Name")
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.plugin
|
|
.summary
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.category.as_deref()),
|
|
Some("Design")
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.plugin
|
|
.summary
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.default_prompt.clone()),
|
|
Some(vec![
|
|
"Draft the reply".to_string(),
|
|
"Find my next action".to_string()
|
|
])
|
|
);
|
|
assert_eq!(response.plugin.skills.len(), 1);
|
|
assert_eq!(
|
|
response.plugin.skills[0].name,
|
|
"demo-plugin:thread-summarizer"
|
|
);
|
|
assert_eq!(
|
|
response.plugin.skills[0].description,
|
|
"Summarize email threads"
|
|
);
|
|
assert_eq!(response.plugin.apps.len(), 1);
|
|
assert_eq!(response.plugin.apps[0].id, "gmail");
|
|
assert_eq!(response.plugin.apps[0].name, "gmail");
|
|
assert_eq!(
|
|
response.plugin.apps[0].install_url.as_deref(),
|
|
Some("https://chatgpt.com/apps/gmail/gmail")
|
|
);
|
|
assert_eq!(response.plugin.mcp_servers.len(), 1);
|
|
assert_eq!(response.plugin.mcp_servers[0], "demo");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/demo-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r##"{
|
|
"name": "demo-plugin",
|
|
"interface": {
|
|
"defaultPrompt": "Starter prompt for trying a plugin"
|
|
}
|
|
}"##,
|
|
)?;
|
|
write_plugins_enabled_config(&codex_home)?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)?,
|
|
plugin_name: "demo-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response
|
|
.plugin
|
|
.summary
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.default_prompt.clone()),
|
|
Some(vec!["Starter prompt for trying a plugin".to_string()])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/demo-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
write_plugins_enabled_config(&codex_home)?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)?,
|
|
plugin_name: "missing-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("plugin `missing-plugin` was not found")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(&plugin_root)?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/demo-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
write_plugins_enabled_config(&codex_home)?;
|
|
|
|
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)?,
|
|
plugin_name: "demo-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("missing or invalid .codex-plugin/plugin.json")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn write_installed_plugin(
|
|
codex_home: &TempDir,
|
|
marketplace_name: &str,
|
|
plugin_name: &str,
|
|
) -> Result<()> {
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join(marketplace_name)
|
|
.join(plugin_name)
|
|
.join("local/.codex-plugin");
|
|
std::fs::create_dir_all(&plugin_root)?;
|
|
std::fs::write(
|
|
plugin_root.join("plugin.json"),
|
|
format!(r#"{{"name":"{plugin_name}"}}"#),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_plugins_enabled_config(codex_home: &TempDir) -> Result<()> {
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
)?;
|
|
Ok(())
|
|
}
|