mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
383 lines
11 KiB
Rust
383 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"
|
|
},
|
|
"installPolicy": "AVAILABLE",
|
|
"authPolicy": "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"
|
|
}
|
|
}"##,
|
|
)?;
|
|
|
|
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"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
|
|
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"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
|
|
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(())
|
|
}
|