mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
381f0de531
## Summary This changes the default remote plugin marketplace listing to use the cached global remote catalog when it is already present on disk. The foreground `plugin/list` response can then return from the local catalog cache instead of waiting on `/ps/plugins/list`. When a cached global catalog was present at the start of the request, `plugin/list` still schedules a background refresh through the existing plugin-list background task path so the disk cache is updated for future requests. Cache misses keep the existing synchronous remote fetch path and write the cache, and they do not schedule an extra duplicate background `/ps/plugins/list` refresh. Installed/enabled state continues to come from the existing remote installed overlay path. This change only affects the global remote catalog directory data used by `plugin/list`. ## Testing - `just fmt` - `just test -p codex-app-server plugin_list_uses_cached_global_remote_catalog_and_refreshes_it` - `just test -p codex-core-plugins` - `git diff --check`
3597 lines
114 KiB
Rust
3597 lines
114 KiB
Rust
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use anyhow::bail;
|
|
use app_test_support::ChatGptAuthFixture;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::to_response;
|
|
use app_test_support::write_chatgpt_auth;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::PluginAuthPolicy;
|
|
use codex_app_server_protocol::PluginInstallPolicy;
|
|
use codex_app_server_protocol::PluginInstalledParams;
|
|
use codex_app_server_protocol::PluginInstalledResponse;
|
|
use codex_app_server_protocol::PluginListMarketplaceKind;
|
|
use codex_app_server_protocol::PluginListParams;
|
|
use codex_app_server_protocol::PluginListResponse;
|
|
use codex_app_server_protocol::PluginMarketplaceEntry;
|
|
use codex_app_server_protocol::PluginShareDiscoverability;
|
|
use codex_app_server_protocol::PluginSource;
|
|
use codex_app_server_protocol::PluginSummary;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use codex_core::config::set_project_trust_level;
|
|
use codex_protocol::config_types::TrustLevel;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use flate2::Compression;
|
|
use flate2::write::GzEncoder;
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
use tokio::time::timeout;
|
|
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 DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
|
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
|
|
const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
|
|
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
|
|
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
|
|
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
|
|
|
|
fn write_plugins_enabled_config(codex_home: &std::path::Path) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
)
|
|
}
|
|
|
|
fn write_plugins_enabled_config_with_base_url(
|
|
codex_home: &std::path::Path,
|
|
base_url: &str,
|
|
) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{base_url}"
|
|
|
|
[features]
|
|
plugins = true
|
|
"#,
|
|
),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> 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"))?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
std::fs::write(marketplace_path.as_path(), "{not json")?;
|
|
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::new_with_env(
|
|
codex_home.path(),
|
|
&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| { marketplace.path.as_ref() != Some(&marketplace_path) }),
|
|
"invalid marketplace should be skipped"
|
|
);
|
|
assert_eq!(response.marketplace_load_errors.len(), 1);
|
|
assert_eq!(
|
|
response.marketplace_load_errors[0].marketplace_path,
|
|
marketplace_path
|
|
);
|
|
assert!(
|
|
response.marketplace_load_errors[0]
|
|
.message
|
|
.contains("invalid marketplace file"),
|
|
"unexpected error: {:?}",
|
|
response.marketplace_load_errors
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_includes_installed_plugins_and_explicit_install_suggestions() -> Result<()>
|
|
{
|
|
let codex_home = TempDir::new()?;
|
|
write_openai_curated_marketplace(
|
|
codex_home.path(),
|
|
&["linear", "computer-use", "not-mentioned"],
|
|
)?;
|
|
write_installed_plugin(&codex_home, "openai-curated", "linear")?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."linear@openai-curated"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: Some(vec!["computer-use".to_string()]),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstalledResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.marketplaces.len(), 1);
|
|
assert_eq!(response.marketplaces[0].name, "openai-curated");
|
|
assert_eq!(
|
|
response.marketplaces[0]
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
("linear@openai-curated".to_string(), true, true),
|
|
("computer-use@openai-curated".to_string(), false, false),
|
|
]
|
|
);
|
|
assert_eq!(response.marketplace_load_errors, Vec::new());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_prefers_remote_curated_conflicts_when_remote_plugin_enabled() -> Result<()>
|
|
{
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_openai_curated_marketplace(codex_home.path(), &["linear", "calendar"])?;
|
|
write_installed_plugin(&codex_home, "openai-curated", "linear")?;
|
|
write_installed_plugin(&codex_home, "openai-curated", "calendar")?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
plugin_sharing = false
|
|
|
|
[plugins."linear@openai-curated"]
|
|
enabled = true
|
|
|
|
[plugins."calendar@openai-curated"]
|
|
enabled = true
|
|
"#,
|
|
server.uri()
|
|
),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
let mut global_installed_body: serde_json::Value = serde_json::from_str(
|
|
&remote_installed_plugin_body("", "1.2.3", /*enabled*/ true),
|
|
)?;
|
|
let mut remote_only = global_installed_body["plugins"][0].clone();
|
|
remote_only["id"] = serde_json::json!("plugins~Plugin_11111111111111111111111111111111");
|
|
remote_only["name"] = serde_json::json!("remote-only");
|
|
remote_only["release"]["display_name"] = serde_json::json!("Remote Only");
|
|
global_installed_body["plugins"]
|
|
.as_array_mut()
|
|
.expect("installed plugins should be an array")
|
|
.push(remote_only);
|
|
let global_installed_body = serde_json::to_string(&global_installed_body)?;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
|
|
let mut app_server = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??;
|
|
|
|
let request_id = app_server
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstalledResponse = to_response(response)?;
|
|
|
|
let local_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated")
|
|
.expect("expected openai-curated marketplace entry");
|
|
assert_eq!(
|
|
local_marketplace
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| plugin.id.clone())
|
|
.collect::<Vec<_>>(),
|
|
vec!["calendar@openai-curated".to_string()]
|
|
);
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected openai-curated-remote marketplace entry");
|
|
assert_eq!(
|
|
remote_marketplace
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| plugin.id.clone())
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
"linear@openai-curated-remote".to_string(),
|
|
"remote-only@openai-curated-remote".to_string(),
|
|
]
|
|
);
|
|
assert_eq!(response.marketplace_load_errors, Vec::new());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_ignores_local_cache_without_catalog() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
write_installed_plugin(&codex_home, "openai-curated", "linear")?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."linear@openai-curated"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstalledResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.marketplaces, Vec::new());
|
|
assert_eq!(response.marketplace_load_errors, Vec::new());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_rejects_relative_cwds() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_raw_request(
|
|
"plugin/list",
|
|
Some(serde_json::json!({
|
|
"cwds": ["relative-root"],
|
|
})),
|
|
)
|
|
.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("Invalid request"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_load() -> Result<()>
|
|
{
|
|
let codex_home = TempDir::new()?;
|
|
let valid_repo_root = TempDir::new()?;
|
|
let invalid_repo_root = TempDir::new()?;
|
|
std::fs::create_dir_all(valid_repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(valid_repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(
|
|
valid_repo_root
|
|
.path()
|
|
.join("plugins/valid-plugin/.codex-plugin"),
|
|
)?;
|
|
std::fs::create_dir_all(invalid_repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(invalid_repo_root.path().join(".agents/plugins"))?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
|
|
let valid_marketplace_path = AbsolutePathBuf::try_from(
|
|
valid_repo_root
|
|
.path()
|
|
.join(".agents/plugins/marketplace.json"),
|
|
)?;
|
|
let invalid_marketplace_path = AbsolutePathBuf::try_from(
|
|
invalid_repo_root
|
|
.path()
|
|
.join(".agents/plugins/marketplace.json"),
|
|
)?;
|
|
let valid_plugin_path =
|
|
AbsolutePathBuf::try_from(valid_repo_root.path().join("plugins/valid-plugin"))?;
|
|
|
|
std::fs::write(
|
|
valid_marketplace_path.as_path(),
|
|
r#"{
|
|
"name": "valid-marketplace",
|
|
"plugins": [
|
|
{
|
|
"name": "valid-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/valid-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
valid_repo_root
|
|
.path()
|
|
.join("plugins/valid-plugin/.codex-plugin/plugin.json"),
|
|
r#"{"name":"valid-plugin","keywords":["api-key","developer tools"]}"#,
|
|
)?;
|
|
std::fs::write(invalid_marketplace_path.as_path(), "{not json")?;
|
|
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::new_with_env(
|
|
codex_home.path(),
|
|
&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![
|
|
AbsolutePathBuf::try_from(valid_repo_root.path())?,
|
|
AbsolutePathBuf::try_from(invalid_repo_root.path())?,
|
|
]),
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response.marketplaces,
|
|
vec![PluginMarketplaceEntry {
|
|
name: "valid-marketplace".to_string(),
|
|
path: Some(valid_marketplace_path),
|
|
interface: None,
|
|
plugins: vec![PluginSummary {
|
|
id: "valid-plugin@valid-marketplace".to_string(),
|
|
remote_plugin_id: None,
|
|
local_version: None,
|
|
name: "valid-plugin".to_string(),
|
|
share_context: None,
|
|
source: PluginSource::Local {
|
|
path: valid_plugin_path,
|
|
},
|
|
installed: false,
|
|
enabled: false,
|
|
install_policy: PluginInstallPolicy::Available,
|
|
auth_policy: PluginAuthPolicy::OnInstall,
|
|
availability: codex_app_server_protocol::PluginAvailability::Available,
|
|
interface: None,
|
|
keywords: vec!["api-key".to_string(), "developer tools".to_string()],
|
|
}],
|
|
}]
|
|
);
|
|
assert_eq!(response.marketplace_load_errors.len(), 1);
|
|
assert_eq!(
|
|
response.marketplace_load_errors[0].marketplace_path,
|
|
invalid_marketplace_path
|
|
);
|
|
assert!(
|
|
response.marketplace_load_errors[0]
|
|
.message
|
|
.contains("invalid marketplace file"),
|
|
"unexpected error: {:?}",
|
|
response.marketplace_load_errors
|
|
);
|
|
assert!(response.featured_plugin_ids.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
write_plugins_enabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123")
|
|
.plan_type("team"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./demo-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/accounts/account-123/settings"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_string(r#"{"beta_settings":{"enable_plugins":false}}"#),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::new_without_managed_config_with_env(
|
|
codex_home.path(),
|
|
&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginListResponse {
|
|
marketplaces: Vec::new(),
|
|
marketplace_load_errors: Vec::new(),
|
|
featured_plugin_ids: Vec::new(),
|
|
}
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
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(repo_root.path().join("demo-plugin/.codex-plugin"))?;
|
|
write_plugins_enabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123")
|
|
.plan_type("team"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "local-marketplace",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./demo-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
repo_root
|
|
.path()
|
|
.join("demo-plugin/.codex-plugin/plugin.json"),
|
|
r#"{"name":"demo-plugin"}"#,
|
|
)?;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/accounts/account-123/settings"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_string(r#"{"beta_settings":{"enable_plugins":true}}"#),
|
|
)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::new_without_managed_config_with_env(
|
|
codex_home.path(),
|
|
&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
for _ in 0..2 {
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
assert_eq!(response.marketplaces.len(), 1);
|
|
assert_eq!(response.marketplaces[0].name, "local-marketplace");
|
|
}
|
|
|
|
wait_for_workspace_settings_request_count(&server, /*expected_count*/ 1).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverable_plugins()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let valid_plugin_root = repo_root.path().join("plugins/valid-plugin");
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(
|
|
repo_root
|
|
.path()
|
|
.join(ALTERNATE_MARKETPLACE_RELATIVE_PATH)
|
|
.parent()
|
|
.unwrap(),
|
|
)?;
|
|
std::fs::create_dir_all(
|
|
valid_plugin_root
|
|
.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH)
|
|
.parent()
|
|
.unwrap(),
|
|
)?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(ALTERNATE_MARKETPLACE_RELATIVE_PATH))?;
|
|
let valid_plugin_path = AbsolutePathBuf::try_from(valid_plugin_root.clone())?;
|
|
|
|
std::fs::write(
|
|
marketplace_path.as_path(),
|
|
r#"{
|
|
"name": "alternate-marketplace",
|
|
"plugins": [
|
|
{
|
|
"name": "valid-plugin",
|
|
"source": "./plugins/valid-plugin"
|
|
},
|
|
{
|
|
"name": "missing-plugin",
|
|
"source": "./plugins/missing-plugin"
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
valid_plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH),
|
|
r#"{
|
|
"name": "valid-plugin",
|
|
"interface": {
|
|
"displayName": "Valid Plugin"
|
|
}
|
|
}"#,
|
|
)?;
|
|
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::new_with_env(
|
|
codex_home.path(),
|
|
&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response.marketplaces,
|
|
vec![PluginMarketplaceEntry {
|
|
name: "alternate-marketplace".to_string(),
|
|
path: Some(marketplace_path),
|
|
interface: None,
|
|
plugins: vec![
|
|
PluginSummary {
|
|
id: "valid-plugin@alternate-marketplace".to_string(),
|
|
remote_plugin_id: None,
|
|
local_version: None,
|
|
name: "valid-plugin".to_string(),
|
|
share_context: None,
|
|
source: PluginSource::Local {
|
|
path: valid_plugin_path,
|
|
},
|
|
installed: false,
|
|
enabled: false,
|
|
install_policy: PluginInstallPolicy::Available,
|
|
auth_policy: PluginAuthPolicy::OnInstall,
|
|
availability: codex_app_server_protocol::PluginAvailability::Available,
|
|
interface: Some(codex_app_server_protocol::PluginInterface {
|
|
display_name: Some("Valid Plugin".to_string()),
|
|
short_description: None,
|
|
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: None,
|
|
composer_icon: None,
|
|
composer_icon_url: None,
|
|
logo: None,
|
|
logo_url: None,
|
|
screenshots: Vec::new(),
|
|
screenshot_urls: Vec::new(),
|
|
}),
|
|
keywords: Vec::new(),
|
|
},
|
|
PluginSummary {
|
|
id: "missing-plugin@alternate-marketplace".to_string(),
|
|
remote_plugin_id: None,
|
|
local_version: None,
|
|
name: "missing-plugin".to_string(),
|
|
share_context: None,
|
|
source: PluginSource::Local {
|
|
path: AbsolutePathBuf::try_from(
|
|
repo_root.path().join("plugins/missing-plugin"),
|
|
)?,
|
|
},
|
|
installed: false,
|
|
enabled: false,
|
|
install_policy: PluginInstallPolicy::Available,
|
|
auth_policy: PluginAuthPolicy::OnInstall,
|
|
availability: codex_app_server_protocol::PluginAvailability::Available,
|
|
interface: None,
|
|
keywords: Vec::new(),
|
|
},
|
|
],
|
|
}]
|
|
);
|
|
assert!(response.marketplace_load_errors.is_empty());
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
std::fs::write(
|
|
codex_home.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "home-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./home-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::new_with_env(
|
|
codex_home.path(),
|
|
&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let _: PluginListResponse = to_response(response)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_returns_share_context_for_shared_local_plugin() -> 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"))?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
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","version":"1.2.3"}"#,
|
|
)?;
|
|
write_plugin_share_local_path_mapping(
|
|
codex_home.path(),
|
|
"plugins_123",
|
|
&AbsolutePathBuf::try_from(plugin_root)?,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
let plugin = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| marketplace.plugins.iter())
|
|
.find(|plugin| plugin.name == "demo-plugin")
|
|
.expect("expected demo-plugin entry");
|
|
assert_eq!(plugin.remote_plugin_id, None);
|
|
assert_eq!(plugin.local_version.as_deref(), Some("1.2.3"));
|
|
let share_context = plugin
|
|
.share_context
|
|
.as_ref()
|
|
.expect("expected share context");
|
|
assert_eq!(share_context.remote_plugin_id, "plugins_123");
|
|
assert_eq!(share_context.remote_version, None);
|
|
assert_eq!(share_context.discoverability, None);
|
|
assert_eq!(share_context.share_url, None);
|
|
assert_eq!(share_context.creator_account_user_id, None);
|
|
assert_eq!(share_context.creator_name, None);
|
|
assert_eq!(share_context.share_principals, None);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_includes_install_and_enabled_state_from_config() -> 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"))?;
|
|
write_installed_plugin(&codex_home, "codex-curated", "enabled-plugin")?;
|
|
write_installed_plugin(&codex_home, "codex-curated", "disabled-plugin")?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"interface": {
|
|
"displayName": "ChatGPT Official"
|
|
},
|
|
"plugins": [
|
|
{
|
|
"name": "enabled-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./enabled-plugin"
|
|
}
|
|
},
|
|
{
|
|
"name": "disabled-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./disabled-plugin"
|
|
}
|
|
},
|
|
{
|
|
"name": "uninstalled-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./uninstalled-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."enabled-plugin@codex-curated"]
|
|
enabled = true
|
|
|
|
[plugins."disabled-plugin@codex-curated"]
|
|
enabled = false
|
|
"#,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
let marketplace = response
|
|
.marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| {
|
|
marketplace.path.as_ref()
|
|
== Some(
|
|
&AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)
|
|
.expect("absolute marketplace path"),
|
|
)
|
|
})
|
|
.expect("expected repo marketplace entry");
|
|
|
|
assert_eq!(marketplace.name, "codex-curated");
|
|
assert_eq!(
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("ChatGPT Official")
|
|
);
|
|
assert_eq!(marketplace.plugins.len(), 3);
|
|
assert_eq!(marketplace.plugins[0].id, "enabled-plugin@codex-curated");
|
|
assert_eq!(marketplace.plugins[0].name, "enabled-plugin");
|
|
assert_eq!(marketplace.plugins[0].installed, true);
|
|
assert_eq!(marketplace.plugins[0].enabled, true);
|
|
assert_eq!(
|
|
marketplace.plugins[0].install_policy,
|
|
PluginInstallPolicy::Available
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[0].auth_policy,
|
|
PluginAuthPolicy::OnInstall
|
|
);
|
|
assert_eq!(marketplace.plugins[1].id, "disabled-plugin@codex-curated");
|
|
assert_eq!(marketplace.plugins[1].name, "disabled-plugin");
|
|
assert_eq!(marketplace.plugins[1].installed, true);
|
|
assert_eq!(marketplace.plugins[1].enabled, false);
|
|
assert_eq!(
|
|
marketplace.plugins[1].install_policy,
|
|
PluginInstallPolicy::Available
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[1].auth_policy,
|
|
PluginAuthPolicy::OnInstall
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[2].id,
|
|
"uninstalled-plugin@codex-curated"
|
|
);
|
|
assert_eq!(marketplace.plugins[2].name, "uninstalled-plugin");
|
|
assert_eq!(marketplace.plugins[2].installed, false);
|
|
assert_eq!(marketplace.plugins[2].enabled, false);
|
|
assert_eq!(
|
|
marketplace.plugins[2].install_policy,
|
|
PluginInstallPolicy::Available
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[2].auth_policy,
|
|
PluginAuthPolicy::OnInstall
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_uses_home_config_for_enabled_state() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
std::fs::create_dir_all(codex_home.path().join(".agents/plugins"))?;
|
|
write_installed_plugin(&codex_home, "codex-curated", "shared-plugin")?;
|
|
std::fs::write(
|
|
codex_home.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "shared-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./shared-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."shared-plugin@codex-curated"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
|
|
let workspace_enabled = TempDir::new()?;
|
|
std::fs::create_dir_all(workspace_enabled.path().join(".git"))?;
|
|
std::fs::create_dir_all(workspace_enabled.path().join(".agents/plugins"))?;
|
|
std::fs::write(
|
|
workspace_enabled
|
|
.path()
|
|
.join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "shared-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./shared-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::create_dir_all(workspace_enabled.path().join(".codex"))?;
|
|
std::fs::write(
|
|
workspace_enabled.path().join(".codex/config.toml"),
|
|
r#"[plugins."shared-plugin@codex-curated"]
|
|
enabled = false
|
|
"#,
|
|
)?;
|
|
set_project_trust_level(
|
|
codex_home.path(),
|
|
workspace_enabled.path(),
|
|
TrustLevel::Trusted,
|
|
)?;
|
|
|
|
let workspace_default = TempDir::new()?;
|
|
let home = codex_home.path().to_string_lossy().into_owned();
|
|
let mut mcp = TestAppServer::new_with_env(
|
|
codex_home.path(),
|
|
&[
|
|
("HOME", Some(home.as_str())),
|
|
("USERPROFILE", Some(home.as_str())),
|
|
],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![
|
|
AbsolutePathBuf::try_from(workspace_enabled.path())?,
|
|
AbsolutePathBuf::try_from(workspace_default.path())?,
|
|
]),
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
let shared_plugin = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| marketplace.plugins.iter())
|
|
.find(|plugin| plugin.name == "shared-plugin")
|
|
.expect("expected shared-plugin entry");
|
|
assert_eq!(shared_plugin.id, "shared-plugin@codex-curated");
|
|
assert_eq!(shared_plugin.installed, true);
|
|
assert_eq!(shared_plugin.enabled, true);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> 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"))?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
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",
|
|
"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": [
|
|
"Starter prompt for trying a plugin",
|
|
"Find my next action"
|
|
],
|
|
"brandColor": "#3B82F6",
|
|
"composerIcon": "./assets/icon.png",
|
|
"logo": "./assets/logo.png",
|
|
"screenshots": ["./assets/screenshot1.png", "./assets/screenshot2.png"]
|
|
}
|
|
}"##,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
let plugin = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| marketplace.plugins.iter())
|
|
.find(|plugin| plugin.name == "demo-plugin")
|
|
.expect("expected demo-plugin entry");
|
|
|
|
assert_eq!(plugin.id, "demo-plugin@codex-curated");
|
|
assert_eq!(plugin.installed, false);
|
|
assert_eq!(plugin.enabled, false);
|
|
assert_eq!(plugin.install_policy, PluginInstallPolicy::Available);
|
|
assert_eq!(plugin.auth_policy, PluginAuthPolicy::OnInstall);
|
|
let interface = plugin
|
|
.interface
|
|
.as_ref()
|
|
.expect("expected plugin interface");
|
|
assert_eq!(
|
|
interface.display_name.as_deref(),
|
|
Some("Plugin Display Name")
|
|
);
|
|
assert_eq!(interface.category.as_deref(), Some("Design"));
|
|
assert_eq!(
|
|
interface.website_url.as_deref(),
|
|
Some("https://openai.com/")
|
|
);
|
|
assert_eq!(
|
|
interface.privacy_policy_url.as_deref(),
|
|
Some("https://openai.com/policies/row-privacy-policy/")
|
|
);
|
|
assert_eq!(
|
|
interface.terms_of_service_url.as_deref(),
|
|
Some("https://openai.com/policies/row-terms-of-use/")
|
|
);
|
|
assert_eq!(
|
|
interface.default_prompt,
|
|
Some(vec![
|
|
"Starter prompt for trying a plugin".to_string(),
|
|
"Find my next action".to_string()
|
|
])
|
|
);
|
|
assert_eq!(
|
|
interface.composer_icon,
|
|
Some(AbsolutePathBuf::try_from(
|
|
plugin_root.join("assets/icon.png")
|
|
)?)
|
|
);
|
|
assert_eq!(
|
|
interface.logo,
|
|
Some(AbsolutePathBuf::try_from(
|
|
plugin_root.join("assets/logo.png")
|
|
)?)
|
|
);
|
|
assert_eq!(
|
|
interface.screenshots,
|
|
vec![
|
|
AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot1.png"))?,
|
|
AbsolutePathBuf::try_from(plugin_root.join("assets/screenshot2.png"))?,
|
|
]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_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"))?;
|
|
write_plugins_enabled_config(codex_home.path())?;
|
|
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 = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
let plugin = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| marketplace.plugins.iter())
|
|
.find(|plugin| plugin.name == "demo-plugin")
|
|
.expect("expected demo-plugin entry");
|
|
assert_eq!(
|
|
plugin
|
|
.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_list_returns_installed_git_source_interface_from_cache() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let missing_remote_repo = repo_root.path().join("missing-remote-plugin-repo");
|
|
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
|
|
.unwrap()
|
|
.to_string();
|
|
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"),
|
|
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 = codex_home.path().join("plugins/cache/debug/toolkit/local");
|
|
std::fs::create_dir_all(cached_plugin_root.join(".codex-plugin"))?;
|
|
std::fs::write(
|
|
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"
|
|
}
|
|
}"##,
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."toolkit@debug"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
let plugin = response
|
|
.marketplaces
|
|
.iter()
|
|
.flat_map(|marketplace| marketplace.plugins.iter())
|
|
.find(|plugin| plugin.name == "toolkit")
|
|
.expect("expected toolkit entry");
|
|
|
|
assert_eq!(plugin.id, "toolkit@debug");
|
|
assert_eq!(plugin.installed, true);
|
|
assert_eq!(plugin.enabled, true);
|
|
assert_eq!(
|
|
plugin.source,
|
|
PluginSource::Git {
|
|
url: missing_remote_repo_url,
|
|
path: Some("plugins/toolkit".to_string()),
|
|
ref_name: None,
|
|
sha: None,
|
|
}
|
|
);
|
|
let interface = plugin
|
|
.interface
|
|
.as_ref()
|
|
.expect("expected cached plugin interface");
|
|
assert_eq!(interface.display_name.as_deref(), Some("Toolkit"));
|
|
assert_eq!(
|
|
interface.short_description.as_deref(),
|
|
Some("Search cached data")
|
|
);
|
|
assert_eq!(interface.category.as_deref(), Some("Developer Tools"));
|
|
assert_eq!(interface.brand_color.as_deref(), Some("#3B82F6"));
|
|
let canonical_cached_plugin_root = std::fs::canonicalize(&cached_plugin_root)?;
|
|
assert_eq!(
|
|
interface.composer_icon,
|
|
Some(AbsolutePathBuf::try_from(
|
|
canonical_cached_plugin_root.join("assets/icon.png")
|
|
)?)
|
|
);
|
|
assert_eq!(
|
|
interface.logo,
|
|
Some(AbsolutePathBuf::try_from(
|
|
canonical_cached_plugin_root.join("assets/logo.png")
|
|
)?)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let bundle_url = mount_remote_plugin_bundle(
|
|
&server,
|
|
"linear",
|
|
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
|
)
|
|
.await;
|
|
let remote_app_manifest = serde_json::json!({
|
|
"apps": {
|
|
"linear-remote": {
|
|
"id": "remote-linear-app"
|
|
}
|
|
}
|
|
});
|
|
let global_installed_body = remote_installed_plugin_body_with_app_manifest(
|
|
&bundle_url,
|
|
"1.2.3",
|
|
/*enabled*/ true,
|
|
remote_app_manifest.clone(),
|
|
);
|
|
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
|
|
let installed_path = codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear/1.2.3");
|
|
let mut mcp = TestAppServer::new_with_env_and_plugin_startup_tasks(
|
|
codex_home.path(),
|
|
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
wait_for_path_exists(&installed_path.join(".codex-plugin/plugin.json")).await?;
|
|
let installed_plugin_manifest: serde_json::Value = serde_json::from_str(
|
|
&std::fs::read_to_string(installed_path.join(".codex-plugin/plugin.json"))?,
|
|
)?;
|
|
assert_eq!(
|
|
installed_plugin_manifest["version"],
|
|
serde_json::json!("1.2.3")
|
|
);
|
|
let installed_app_manifest: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?;
|
|
assert_eq!(installed_app_manifest, remote_app_manifest);
|
|
assert!(installed_path.join("skills/plan-work/SKILL.md").is_file());
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(!config.contains("linear@openai-curated-remote"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "linear", "1.0.0")?;
|
|
write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "stale", "1.0.0")?;
|
|
|
|
let bundle_url = mount_remote_plugin_bundle(
|
|
&server,
|
|
"linear",
|
|
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
|
)
|
|
.await;
|
|
let remote_app_manifest = serde_json::json!({
|
|
"apps": {
|
|
"linear-remote": {
|
|
"id": "remote-linear-app"
|
|
}
|
|
}
|
|
});
|
|
let global_installed_body = remote_installed_plugin_body_with_app_manifest(
|
|
&bundle_url,
|
|
"1.2.3",
|
|
/*enabled*/ true,
|
|
remote_app_manifest.clone(),
|
|
);
|
|
mount_remote_plugin_list(&server, "GLOBAL", &global_installed_body).await;
|
|
mount_remote_plugin_list(&server, "WORKSPACE", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
|
|
let old_path = codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear/1.0.0");
|
|
let new_path = codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear/1.2.3");
|
|
let stale_path = codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/stale");
|
|
|
|
let mut mcp = TestAppServer::new_with_env(
|
|
codex_home.path(),
|
|
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected openai-curated-remote marketplace entry");
|
|
assert_eq!(
|
|
remote_marketplace
|
|
.plugins
|
|
.into_iter()
|
|
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![("linear@openai-curated-remote".to_string(), true, true)]
|
|
);
|
|
|
|
wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?;
|
|
let installed_plugin_manifest: serde_json::Value = serde_json::from_str(
|
|
&std::fs::read_to_string(new_path.join(".codex-plugin/plugin.json"))?,
|
|
)?;
|
|
assert_eq!(
|
|
installed_plugin_manifest["version"],
|
|
serde_json::json!("1.2.3")
|
|
);
|
|
let installed_app_manifest: serde_json::Value =
|
|
serde_json::from_str(&std::fs::read_to_string(new_path.join(".app.json"))?)?;
|
|
assert_eq!(installed_app_manifest, remote_app_manifest);
|
|
wait_for_path_missing(&old_path).await?;
|
|
wait_for_path_missing(&stale_path).await?;
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(!config.contains("linear@openai-curated-remote"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let global_directory_body = r#"{
|
|
"plugins": [
|
|
{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"status": "ENABLED",
|
|
"release": {
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
"keywords": ["issue-tracking", "project management"],
|
|
"interface": {
|
|
"short_description": "Plan and track work",
|
|
"capabilities": ["Read", "Write"],
|
|
"default_prompt": "Use the legacy Linear prompt",
|
|
"default_prompts": ["Create a Linear issue", "Review my Linear projects"],
|
|
"logo_url": "https://example.com/linear.png",
|
|
"screenshot_urls": ["https://example.com/linear-shot.png"]
|
|
},
|
|
"skills": []
|
|
}
|
|
}
|
|
],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
let empty_page_body = r#"{
|
|
"plugins": [],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
let global_installed_body = r#"{
|
|
"plugins": [
|
|
{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"status": "ENABLED",
|
|
"release": {
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
"interface": {
|
|
"short_description": "Plan and track work",
|
|
"capabilities": ["Read", "Write"],
|
|
"logo_url": "https://example.com/linear.png",
|
|
"screenshot_urls": ["https://example.com/linear-shot.png"]
|
|
},
|
|
"skills": []
|
|
},
|
|
"enabled": true,
|
|
"disabled_skill_names": []
|
|
}
|
|
],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.and(header("oai-product-sku", "codex"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(global_directory_body))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", "WORKSPACE"))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.and(header("oai-product-sku", "codex"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.and(header("oai-product-sku", "codex"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(global_installed_body))
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param("scope", "WORKSPACE"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.and(header("oai-product-sku", "codex"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected openai-curated remote marketplace");
|
|
assert_eq!(remote_marketplace.path, None);
|
|
assert_eq!(
|
|
remote_marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("OpenAI Curated Remote")
|
|
);
|
|
assert_eq!(remote_marketplace.plugins.len(), 1);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"linear@openai-curated-remote"
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_00000000000000000000000000000000")
|
|
);
|
|
assert_eq!(remote_marketplace.plugins[0].name, "linear");
|
|
assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote);
|
|
assert_eq!(remote_marketplace.plugins[0].installed, true);
|
|
assert_eq!(remote_marketplace.plugins[0].enabled, true);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].availability,
|
|
codex_app_server_protocol::PluginAvailability::Available
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0]
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Linear")
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0]
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.default_prompt.clone()),
|
|
Some(vec![
|
|
"Create a Linear issue".to_string(),
|
|
"Review my Linear projects".to_string(),
|
|
])
|
|
);
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].keywords,
|
|
vec![
|
|
"issue-tracking".to_string(),
|
|
"project management".to_string()
|
|
]
|
|
);
|
|
let cache_files = std::fs::read_dir(codex_home.path().join("cache/remote_plugin_catalog"))?
|
|
.map(|entry| entry.map(|entry| entry.path()))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
assert_eq!(cache_files.len(), 1);
|
|
let cached_catalog: serde_json::Value =
|
|
serde_json::from_slice(&std::fs::read(&cache_files[0])?)?;
|
|
assert_eq!(cached_catalog["schema_version"], serde_json::json!(1));
|
|
assert_eq!(
|
|
cached_catalog["plugins"][0]["release"]["interface"]["default_prompts"],
|
|
serde_json::json!(["Create a Linear issue", "Review my Linear projects"])
|
|
);
|
|
let cached_plugin_ids = cached_catalog["plugins"]
|
|
.as_array()
|
|
.expect("cached plugins should be an array")
|
|
.iter()
|
|
.map(|plugin| plugin["id"].as_str().expect("cached plugin id").to_string())
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(
|
|
cached_plugin_ids,
|
|
vec!["plugins~Plugin_00000000000000000000000000000000".to_string()]
|
|
);
|
|
assert_eq!(response.featured_plugin_ids, Vec::<String>::new());
|
|
assert!(
|
|
!server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests")
|
|
.iter()
|
|
.any(|request| request
|
|
.url
|
|
.query_pairs()
|
|
.any(|(name, value)| name == "collection" && value == "vertical"))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_uses_cached_global_remote_catalog_and_refreshes_it() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let cached_remote_plugin_id = "plugins~Plugin_00000000000000000000000000000000";
|
|
let refreshed_remote_plugin_id = "plugins~Plugin_11111111111111111111111111111111";
|
|
let cached_body =
|
|
remote_plugin_list_body(cached_remote_plugin_id, "linear", "Linear", "Plan work");
|
|
let refreshed_body = remote_plugin_list_body(
|
|
refreshed_remote_plugin_id,
|
|
"notion",
|
|
"Notion",
|
|
"Capture notes",
|
|
);
|
|
mount_remote_plugin_list(&server, "GLOBAL", &cached_body).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected warmed remote marketplace");
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"linear@openai-curated-remote"
|
|
);
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
|
|
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[cached_remote_plugin_id])
|
|
.await?;
|
|
|
|
server.reset().await;
|
|
mount_remote_plugin_list(&server, "GLOBAL", &refreshed_body).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
let response: PluginListResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected cached remote marketplace");
|
|
assert_eq!(
|
|
remote_marketplace.plugins[0].id,
|
|
"linear@openai-curated-remote"
|
|
);
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 1).await?;
|
|
wait_for_cached_remote_catalog_plugin_ids(codex_home.path(), &[refreshed_remote_plugin_id])
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_includes_openai_curated_remote_collection_when_requested() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_plugins_enabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let collection_body = r#"{
|
|
"plugins": [
|
|
{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"status": "ENABLED",
|
|
"release": {
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
"interface": {
|
|
"short_description": "Plan and track work",
|
|
"capabilities": ["Read", "Write"]
|
|
},
|
|
"skills": []
|
|
}
|
|
}
|
|
],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
mount_openai_curated_remote_collection_plugin_list(&server, collection_body).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected openai-curated remote marketplace");
|
|
assert_eq!(remote_marketplace.path, None);
|
|
assert_eq!(
|
|
remote_marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("OpenAI Curated Remote")
|
|
);
|
|
assert_eq!(remote_marketplace.plugins.len(), 1);
|
|
let plugin = &remote_marketplace.plugins[0];
|
|
assert_eq!(plugin.id, "linear@openai-curated-remote");
|
|
assert_eq!(
|
|
plugin.remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_00000000000000000000000000000000")
|
|
);
|
|
assert_eq!(plugin.name, "linear");
|
|
assert_eq!(plugin.source, PluginSource::Remote);
|
|
assert_eq!(plugin.installed, false);
|
|
assert_eq!(plugin.enabled, false);
|
|
|
|
let requests = server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests");
|
|
assert!(requests.iter().any(|request| {
|
|
request.method == "GET"
|
|
&& request.url.path().ends_with("/ps/plugins/list")
|
|
&& request
|
|
.url
|
|
.query_pairs()
|
|
.any(|(name, value)| name == "collection" && value == "vertical")
|
|
}));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_fail_opens_openai_curated_remote_collection_errors() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_plugins_enabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.and(query_param("limit", "200"))
|
|
.and(query_param("collection", "vertical"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(500).set_body_string("temporary failure"))
|
|
.mount(&server)
|
|
.await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| marketplace.name != "openai-curated-remote")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_plugins_enabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| marketplace.name != "openai-curated-remote")
|
|
);
|
|
assert!(
|
|
server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests")
|
|
.iter()
|
|
.all(|request| !request
|
|
.url
|
|
.query_pairs()
|
|
.any(|(name, value)| name == "collection" && value == "vertical"))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_vertical_kind_noops_when_remote_plugin_enabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| marketplace.name != "openai-curated-remote")
|
|
);
|
|
assert!(
|
|
server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests")
|
|
.iter()
|
|
.all(|request| !request
|
|
.url
|
|
.query_pairs()
|
|
.any(|(name, value)| name == "collection" && value == "vertical"))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_explicit()
|
|
-> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert!(
|
|
response
|
|
.marketplaces
|
|
.iter()
|
|
.all(|marketplace| marketplace.name != "openai-curated-remote")
|
|
);
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_includes_remote_shared_with_me_plugins() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = false
|
|
plugin_sharing = true
|
|
"#,
|
|
server.uri()
|
|
),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
let mut workspace_installed_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_22222222222222222222222222222222",
|
|
"shared-linear",
|
|
"Shared Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ Some(true),
|
|
))?;
|
|
let unlisted_installed_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_33333333333333333333333333333333",
|
|
"unlisted-linear",
|
|
"Unlisted Linear",
|
|
"UNLISTED",
|
|
/*enabled*/ Some(false),
|
|
))?;
|
|
workspace_installed_body["plugins"]
|
|
.as_array_mut()
|
|
.expect("installed plugins should be an array")
|
|
.push(unlisted_installed_body["plugins"][0].clone());
|
|
let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?;
|
|
let global_installed_body = remote_installed_plugin_body("", "1.2.3", /*enabled*/ true);
|
|
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstalledResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.marketplaces.len(), 1);
|
|
let marketplace = &response.marketplaces[0];
|
|
assert_eq!(marketplace.name, "workspace-shared-with-me");
|
|
assert_eq!(
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Shared with me")
|
|
);
|
|
assert_eq!(
|
|
marketplace
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(
|
|
"shared-linear@workspace-shared-with-me".to_string(),
|
|
true,
|
|
true
|
|
),
|
|
(
|
|
"unlisted-linear@workspace-shared-with-me".to_string(),
|
|
true,
|
|
false
|
|
)
|
|
]
|
|
);
|
|
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
|
|
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_installed_starts_remote_installed_bundle_sync() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
plugin_sharing = false
|
|
"#,
|
|
server.uri()
|
|
),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let bundle_url = mount_remote_plugin_bundle(
|
|
&server,
|
|
"linear",
|
|
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
|
)
|
|
.await;
|
|
let global_installed_body =
|
|
remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true);
|
|
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::new_with_env(
|
|
codex_home.path(),
|
|
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
|
|
)
|
|
.await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let plugin_installed_request_id = mcp
|
|
.send_plugin_installed_request(PluginInstalledParams {
|
|
cwds: None,
|
|
install_suggestion_plugin_names: None,
|
|
})
|
|
.await?;
|
|
let response: PluginInstalledResponse = to_response(
|
|
timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(plugin_installed_request_id)),
|
|
)
|
|
.await??,
|
|
)?;
|
|
|
|
assert_eq!(response.marketplaces.len(), 1);
|
|
assert_eq!(response.marketplaces[0].name, "openai-curated-remote");
|
|
assert_eq!(
|
|
response.marketplaces[0]
|
|
.plugins
|
|
.iter()
|
|
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
|
|
.collect::<Vec<_>>(),
|
|
vec![("linear@openai-curated-remote".to_string(), true, true)]
|
|
);
|
|
let installed_path = codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear/1.2.3/.codex-plugin/plugin.json");
|
|
wait_for_path_exists(&installed_path).await?;
|
|
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
|
|
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_plugins_enabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let workspace_plugin_body = workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_11111111111111111111111111111111",
|
|
"workspace-linear",
|
|
"Workspace Linear",
|
|
"LISTED",
|
|
/*enabled*/ None,
|
|
);
|
|
let workspace_installed_body = workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_11111111111111111111111111111111",
|
|
"workspace-linear",
|
|
"Workspace Linear",
|
|
"LISTED",
|
|
/*enabled*/ Some(false),
|
|
);
|
|
mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.marketplaces.len(), 1);
|
|
let marketplace = &response.marketplaces[0];
|
|
assert_eq!(marketplace.name, "workspace-directory");
|
|
assert_eq!(
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Workspace Directory")
|
|
);
|
|
assert_eq!(marketplace.plugins.len(), 1);
|
|
assert_eq!(
|
|
marketplace.plugins[0].id,
|
|
"workspace-linear@workspace-directory"
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[0].remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_11111111111111111111111111111111")
|
|
);
|
|
assert_eq!(marketplace.plugins[0].name, "workspace-linear");
|
|
assert_eq!(marketplace.plugins[0].installed, true);
|
|
assert_eq!(marketplace.plugins[0].enabled, false);
|
|
assert!(
|
|
!server
|
|
.received_requests()
|
|
.await
|
|
.expect("wiremock should record requests")
|
|
.iter()
|
|
.any(|request| request
|
|
.url
|
|
.query()
|
|
.is_some_and(|query| query.contains("scope=GLOBAL")))
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_plugins_enabled_config_with_base_url(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut shared_plugin_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_22222222222222222222222222222222",
|
|
"shared-linear",
|
|
"Shared Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ None,
|
|
))?;
|
|
shared_plugin_body["plugins"][0]["share_principals"] = serde_json::Value::Null;
|
|
let shared_unlisted_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_44444444444444444444444444444444",
|
|
"shared-unlisted-linear",
|
|
"Shared Unlisted Linear",
|
|
"UNLISTED",
|
|
/*enabled*/ None,
|
|
))?;
|
|
shared_plugin_body["plugins"]
|
|
.as_array_mut()
|
|
.expect("shared plugins should be an array")
|
|
.push(shared_unlisted_body["plugins"][0].clone());
|
|
let shared_plugin_body = serde_json::to_string(&shared_plugin_body)?;
|
|
let mut workspace_installed_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_22222222222222222222222222222222",
|
|
"shared-linear",
|
|
"Shared Linear",
|
|
"PRIVATE",
|
|
/*enabled*/ Some(true),
|
|
))?;
|
|
let unlisted_installed_body: serde_json::Value =
|
|
serde_json::from_str(&workspace_remote_plugin_page_body(
|
|
"plugins~Plugin_33333333333333333333333333333333",
|
|
"unlisted-linear",
|
|
"Unlisted Linear",
|
|
"UNLISTED",
|
|
/*enabled*/ Some(false),
|
|
))?;
|
|
workspace_installed_body["plugins"]
|
|
.as_array_mut()
|
|
.expect("installed plugins should be an array")
|
|
.push(unlisted_installed_body["plugins"][0].clone());
|
|
let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?;
|
|
mount_shared_workspace_plugins(&server, &shared_plugin_body).await;
|
|
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
|
mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.marketplaces.len(), 2);
|
|
let marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "workspace-shared-with-me-private")
|
|
.expect("expected private shared-with-me marketplace");
|
|
assert_eq!(
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Shared with me")
|
|
);
|
|
assert_eq!(marketplace.plugins.len(), 2);
|
|
assert_eq!(
|
|
marketplace.plugins[0].id,
|
|
"shared-linear@workspace-shared-with-me"
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[0].remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_22222222222222222222222222222222")
|
|
);
|
|
assert_eq!(marketplace.plugins[0].name, "shared-linear");
|
|
assert_eq!(marketplace.plugins[0].installed, true);
|
|
assert_eq!(marketplace.plugins[0].enabled, true);
|
|
let share_context = marketplace.plugins[0]
|
|
.share_context
|
|
.as_ref()
|
|
.expect("expected share context");
|
|
assert_eq!(
|
|
share_context.remote_plugin_id,
|
|
"plugins~Plugin_22222222222222222222222222222222"
|
|
);
|
|
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3"));
|
|
assert_eq!(
|
|
share_context.discoverability,
|
|
Some(PluginShareDiscoverability::Private)
|
|
);
|
|
assert_eq!(
|
|
share_context.creator_account_user_id.as_deref(),
|
|
Some("user-gavin__account-123")
|
|
);
|
|
assert_eq!(share_context.creator_name.as_deref(), Some("Gavin"));
|
|
assert_eq!(
|
|
share_context.share_url.as_deref(),
|
|
Some("https://chatgpt.example/plugins/share/share-key-1")
|
|
);
|
|
assert_eq!(share_context.share_principals, None);
|
|
assert_eq!(
|
|
marketplace.plugins[1].id,
|
|
"shared-unlisted-linear@workspace-shared-with-me"
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[1].remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_44444444444444444444444444444444")
|
|
);
|
|
assert_eq!(marketplace.plugins[1].name, "shared-unlisted-linear");
|
|
assert_eq!(marketplace.plugins[1].installed, false);
|
|
assert_eq!(marketplace.plugins[1].enabled, false);
|
|
let share_context = marketplace.plugins[1]
|
|
.share_context
|
|
.as_ref()
|
|
.expect("expected share context");
|
|
assert_eq!(
|
|
share_context.remote_plugin_id,
|
|
"plugins~Plugin_44444444444444444444444444444444"
|
|
);
|
|
assert_eq!(
|
|
share_context.discoverability,
|
|
Some(PluginShareDiscoverability::Unlisted)
|
|
);
|
|
|
|
let marketplace = response
|
|
.marketplaces
|
|
.iter()
|
|
.find(|marketplace| marketplace.name == "workspace-shared-with-me-unlisted")
|
|
.expect("expected unlisted shared-with-me marketplace");
|
|
assert_eq!(
|
|
marketplace
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Shared with me (unlisted)")
|
|
);
|
|
assert_eq!(marketplace.plugins.len(), 1);
|
|
assert_eq!(
|
|
marketplace.plugins[0].id,
|
|
"unlisted-linear@workspace-shared-with-me"
|
|
);
|
|
assert_eq!(
|
|
marketplace.plugins[0].remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_33333333333333333333333333333333")
|
|
);
|
|
assert_eq!(marketplace.plugins[0].name, "unlisted-linear");
|
|
assert_eq!(marketplace.plugins[0].installed, true);
|
|
assert_eq!(marketplace.plugins[0].enabled, false);
|
|
let share_context = marketplace.plugins[0]
|
|
.share_context
|
|
.as_ref()
|
|
.expect("expected share context");
|
|
assert_eq!(
|
|
share_context.remote_plugin_id,
|
|
"plugins~Plugin_33333333333333333333333333333333"
|
|
);
|
|
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3"));
|
|
assert_eq!(
|
|
share_context.discoverability,
|
|
Some(PluginShareDiscoverability::Unlisted)
|
|
);
|
|
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
|
|
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_omits_shared_with_me_kind_when_plugin_sharing_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
plugin_sharing = false
|
|
"#,
|
|
server.uri()
|
|
),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginListResponse {
|
|
marketplaces: Vec::new(),
|
|
marketplace_load_errors: Vec::new(),
|
|
featured_plugin_ids: Vec::new(),
|
|
}
|
|
);
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"/ps/plugins/workspace/shared",
|
|
/*expected_count*/ 0,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_remote_plugin_catalog_config(
|
|
codex_home.path(),
|
|
&format!("{}/backend-api/", server.uri()),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let global_directory_body = r#"{
|
|
"plugins": [
|
|
{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"status": "DISABLED_BY_ADMIN",
|
|
"release": {
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
"interface": {},
|
|
"skills": []
|
|
}
|
|
}
|
|
],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
let global_installed_body = r#"{
|
|
"plugins": [
|
|
{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"status": "DISABLED_BY_ADMIN",
|
|
"release": {
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
"interface": {},
|
|
"skills": []
|
|
},
|
|
"enabled": true,
|
|
"disabled_skill_names": []
|
|
}
|
|
],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
let empty_page_body = r#"{
|
|
"plugins": [],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
|
|
for (scope, body) in [
|
|
("GLOBAL", global_directory_body),
|
|
("WORKSPACE", empty_page_body),
|
|
] {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", scope))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
for (scope, body) in [
|
|
("GLOBAL", global_installed_body),
|
|
("WORKSPACE", empty_page_body),
|
|
] {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param("scope", scope))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(&server)
|
|
.await;
|
|
}
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
let remote_marketplace = response
|
|
.marketplaces
|
|
.into_iter()
|
|
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
|
.expect("expected ChatGPT remote marketplace");
|
|
let plugin = remote_marketplace
|
|
.plugins
|
|
.first()
|
|
.expect("expected remote plugin");
|
|
assert_eq!(plugin.installed, true);
|
|
assert_eq!(plugin.enabled, true);
|
|
assert_eq!(
|
|
plugin.availability,
|
|
codex_app_server_protocol::PluginAvailability::DisabledByAdmin
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
format!(
|
|
r#"
|
|
chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = false
|
|
remote_plugin = true
|
|
"#,
|
|
server.uri()
|
|
),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home.path(),
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert!(response.marketplaces.is_empty());
|
|
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?;
|
|
write_openai_curated_marketplace(codex_home.path(), &["linear", "gmail"])?;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/plugins/featured"))
|
|
.and(query_param("platform", "codex"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response.featured_plugin_ids,
|
|
vec!["linear@openai-curated".to_string()]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?;
|
|
write_openai_curated_marketplace(codex_home.path(), &["linear", "gmail"])?;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/plugins/featured"))
|
|
.and(query_param("platform", "codex"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_list_request(PluginListParams {
|
|
cwds: None,
|
|
marketplace_kinds: None,
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginListResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response.featured_plugin_ids,
|
|
vec!["linear@openai-curated".to_string()]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_featured_plugin_request_count(
|
|
server: &MockServer,
|
|
expected_count: usize,
|
|
) -> Result<()> {
|
|
wait_for_remote_plugin_request_count(server, "/plugins/featured", expected_count).await
|
|
}
|
|
|
|
async fn wait_for_workspace_settings_request_count(
|
|
server: &MockServer,
|
|
expected_count: usize,
|
|
) -> Result<()> {
|
|
wait_for_remote_plugin_request_count(server, "/accounts/account-123/settings", expected_count)
|
|
.await
|
|
}
|
|
|
|
async fn wait_for_remote_plugin_request_count(
|
|
server: &MockServer,
|
|
path_suffix: &str,
|
|
expected_count: usize,
|
|
) -> Result<()> {
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
let Some(requests) = server.received_requests().await else {
|
|
bail!("wiremock did not record requests");
|
|
};
|
|
let request_count = requests
|
|
.iter()
|
|
.filter(|request| {
|
|
request.method == "GET" && request.url.path().ends_with(path_suffix)
|
|
})
|
|
.count();
|
|
if request_count == expected_count {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
if request_count > expected_count {
|
|
bail!(
|
|
"expected exactly {expected_count} {path_suffix} requests, got {request_count}"
|
|
);
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &str) -> Result<()> {
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
let Some(requests) = server.received_requests().await else {
|
|
bail!("wiremock did not record requests");
|
|
};
|
|
if requests.iter().any(|request| {
|
|
request.method == "GET"
|
|
&& request.url.path().ends_with("/ps/plugins/installed")
|
|
&& request
|
|
.url
|
|
.query_pairs()
|
|
.any(|(name, value)| name == "scope" && value == scope)
|
|
}) {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_cached_remote_catalog_plugin_ids(
|
|
codex_home: &std::path::Path,
|
|
expected_plugin_ids: &[&str],
|
|
) -> Result<()> {
|
|
let mut expected_plugin_ids = expected_plugin_ids
|
|
.iter()
|
|
.copied()
|
|
.map(str::to_string)
|
|
.collect::<Vec<_>>();
|
|
expected_plugin_ids.sort();
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
let plugin_ids = cached_remote_catalog_plugin_ids(codex_home)?;
|
|
if plugin_ids == expected_plugin_ids {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
fn cached_remote_catalog_plugin_ids(codex_home: &std::path::Path) -> Result<Vec<String>> {
|
|
let cache_dir = codex_home.join("cache/remote_plugin_catalog");
|
|
if !cache_dir.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let mut plugin_ids = Vec::new();
|
|
for entry in std::fs::read_dir(cache_dir)? {
|
|
let path = entry?.path();
|
|
let cached_catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(path)?)?;
|
|
let Some(plugins) = cached_catalog["plugins"].as_array() else {
|
|
continue;
|
|
};
|
|
plugin_ids.extend(
|
|
plugins
|
|
.iter()
|
|
.filter_map(|plugin| plugin["id"].as_str())
|
|
.map(str::to_string),
|
|
);
|
|
}
|
|
plugin_ids.sort();
|
|
Ok(plugin_ids)
|
|
}
|
|
|
|
async fn wait_for_path_exists(path: &std::path::Path) -> Result<()> {
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
if path.exists() {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn wait_for_path_missing(path: &std::path::Path) -> Result<()> {
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
if !path.exists() {
|
|
return Ok::<(), anyhow::Error>(());
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str) {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", scope))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
fn remote_plugin_list_body(
|
|
remote_plugin_id: &str,
|
|
plugin_name: &str,
|
|
display_name: &str,
|
|
short_description: &str,
|
|
) -> String {
|
|
format!(
|
|
r#"{{
|
|
"plugins": [
|
|
{{
|
|
"id": "{remote_plugin_id}",
|
|
"name": "{plugin_name}",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"status": "ENABLED",
|
|
"release": {{
|
|
"version": "1.2.3",
|
|
"display_name": "{display_name}",
|
|
"description": "{display_name}",
|
|
"app_ids": [],
|
|
"interface": {{
|
|
"short_description": "{short_description}",
|
|
"capabilities": ["Read"]
|
|
}},
|
|
"skills": []
|
|
}}
|
|
}}
|
|
],
|
|
"pagination": {{
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}}
|
|
}}"#
|
|
)
|
|
}
|
|
|
|
async fn mount_openai_curated_remote_collection_plugin_list(server: &MockServer, body: &str) {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/list"))
|
|
.and(query_param("scope", "GLOBAL"))
|
|
.and(query_param("limit", "200"))
|
|
.and(query_param("collection", "vertical"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/workspace/shared"))
|
|
.and(query_param("limit", "200"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: &str) {
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/installed"))
|
|
.and(query_param("scope", scope))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
fn empty_remote_installed_plugins_body() -> &'static str {
|
|
r#"{
|
|
"plugins": [],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#
|
|
}
|
|
|
|
fn workspace_remote_plugin_page_body(
|
|
remote_plugin_id: &str,
|
|
plugin_name: &str,
|
|
display_name: &str,
|
|
discoverability: &str,
|
|
enabled: Option<bool>,
|
|
) -> String {
|
|
let enabled_field = enabled
|
|
.map(|enabled| format!(r#", "enabled": {enabled}, "disabled_skill_names": []"#))
|
|
.unwrap_or_default();
|
|
format!(
|
|
r#"{{
|
|
"plugins": [
|
|
{{
|
|
"id": "{remote_plugin_id}",
|
|
"name": "{plugin_name}",
|
|
"scope": "WORKSPACE",
|
|
"discoverability": "{discoverability}",
|
|
"creator_account_user_id": "user-gavin__account-123",
|
|
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"status": "ENABLED",
|
|
"creator_name": "Gavin",
|
|
"share_principals": [
|
|
{{
|
|
"principal_type": "user",
|
|
"principal_id": "user-gavin__account-123",
|
|
"role": "owner",
|
|
"name": "Gavin"
|
|
}},
|
|
{{
|
|
"principal_type": "user",
|
|
"principal_id": "user-ada__account-123",
|
|
"role": "reader",
|
|
"name": "Ada"
|
|
}}
|
|
],
|
|
"release": {{
|
|
"version": "1.2.3",
|
|
"display_name": "{display_name}",
|
|
"description": "Track work",
|
|
"app_ids": [],
|
|
"interface": {{}},
|
|
"skills": []
|
|
}}{enabled_field}
|
|
}}
|
|
],
|
|
"pagination": {{
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}}
|
|
}}"#
|
|
)
|
|
}
|
|
|
|
fn remote_installed_plugin_body(
|
|
bundle_download_url: &str,
|
|
release_version: &str,
|
|
enabled: bool,
|
|
) -> String {
|
|
remote_installed_plugin_body_with_optional_app_manifest(
|
|
bundle_download_url,
|
|
release_version,
|
|
enabled,
|
|
/*app_manifest*/ None,
|
|
)
|
|
}
|
|
|
|
fn remote_installed_plugin_body_with_app_manifest(
|
|
bundle_download_url: &str,
|
|
release_version: &str,
|
|
enabled: bool,
|
|
app_manifest: serde_json::Value,
|
|
) -> String {
|
|
remote_installed_plugin_body_with_optional_app_manifest(
|
|
bundle_download_url,
|
|
release_version,
|
|
enabled,
|
|
Some(app_manifest),
|
|
)
|
|
}
|
|
|
|
fn remote_installed_plugin_body_with_optional_app_manifest(
|
|
bundle_download_url: &str,
|
|
release_version: &str,
|
|
enabled: bool,
|
|
app_manifest: Option<serde_json::Value>,
|
|
) -> String {
|
|
let app_manifest_field = app_manifest
|
|
.map(|manifest| format!(r#" "app_manifest": {manifest},"#))
|
|
.unwrap_or_default();
|
|
format!(
|
|
r#"{{
|
|
"plugins": [
|
|
{{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"release": {{
|
|
"version": "{release_version}",
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"bundle_download_url": "{bundle_download_url}",
|
|
"app_ids": [],
|
|
{app_manifest_field}
|
|
"interface": {{}},
|
|
"skills": []
|
|
}},
|
|
"enabled": {enabled},
|
|
"disabled_skill_names": []
|
|
}}
|
|
],
|
|
"pagination": {{
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}}
|
|
}}"#
|
|
)
|
|
}
|
|
|
|
async fn mount_remote_plugin_bundle(
|
|
server: &MockServer,
|
|
plugin_name: &str,
|
|
body: Vec<u8>,
|
|
) -> String {
|
|
let bundle_path = format!("/bundles/{plugin_name}.tar.gz");
|
|
Mock::given(method("GET"))
|
|
.and(path(bundle_path.as_str()))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.insert_header("content-type", "application/gzip")
|
|
.set_body_bytes(body),
|
|
)
|
|
.mount(server)
|
|
.await;
|
|
format!("{}{bundle_path}", server.uri())
|
|
}
|
|
|
|
fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result<Vec<u8>> {
|
|
let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);
|
|
let skill = "---\nname: plan-work\ndescription: Track work in Linear.\n---\n\n# Plan Work\n";
|
|
let encoder = GzEncoder::new(Vec::new(), Compression::default());
|
|
let mut tar = tar::Builder::new(encoder);
|
|
for (path, contents, mode) in [
|
|
(
|
|
".codex-plugin/plugin.json",
|
|
manifest.as_bytes(),
|
|
/*mode*/ 0o644,
|
|
),
|
|
(
|
|
"skills/plan-work/SKILL.md",
|
|
skill.as_bytes(),
|
|
/*mode*/ 0o644,
|
|
),
|
|
] {
|
|
let mut header = tar::Header::new_gnu();
|
|
header.set_size(contents.len() as u64);
|
|
header.set_mode(mode);
|
|
header.set_cksum();
|
|
tar.append_data(&mut header, path, contents)?;
|
|
}
|
|
Ok(tar.into_inner()?.finish()?)
|
|
}
|
|
|
|
fn write_installed_plugin(
|
|
codex_home: &TempDir,
|
|
marketplace_name: &str,
|
|
plugin_name: &str,
|
|
) -> Result<()> {
|
|
write_installed_plugin_with_version(codex_home, marketplace_name, plugin_name, "local")
|
|
}
|
|
|
|
fn write_installed_plugin_with_version(
|
|
codex_home: &TempDir,
|
|
marketplace_name: &str,
|
|
plugin_name: &str,
|
|
plugin_version: &str,
|
|
) -> Result<()> {
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join(marketplace_name)
|
|
.join(plugin_name)
|
|
.join(plugin_version)
|
|
.join(".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_plugin_sync_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"
|
|
chatgpt_base_url = "{base_url}"
|
|
|
|
[features]
|
|
plugins = true
|
|
|
|
[plugins."linear@openai-curated"]
|
|
enabled = false
|
|
|
|
[plugins."gmail@openai-curated"]
|
|
enabled = false
|
|
|
|
[plugins."calendar@openai-curated"]
|
|
enabled = true
|
|
"#
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_remote_plugin_catalog_config(
|
|
codex_home: &std::path::Path,
|
|
base_url: &str,
|
|
) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"
|
|
chatgpt_base_url = "{base_url}"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
"#
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_openai_curated_marketplace(
|
|
codex_home: &std::path::Path,
|
|
plugin_names: &[&str],
|
|
) -> std::io::Result<()> {
|
|
let curated_root = codex_home.join(".tmp/plugins");
|
|
std::fs::create_dir_all(curated_root.join(".git"))?;
|
|
std::fs::create_dir_all(curated_root.join(".agents/plugins"))?;
|
|
let plugins = plugin_names
|
|
.iter()
|
|
.map(|plugin_name| {
|
|
format!(
|
|
r#"{{
|
|
"name": "{plugin_name}",
|
|
"source": {{
|
|
"source": "local",
|
|
"path": "./plugins/{plugin_name}"
|
|
}}
|
|
}}"#
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(",\n");
|
|
std::fs::write(
|
|
curated_root.join(".agents/plugins/marketplace.json"),
|
|
format!(
|
|
r#"{{
|
|
"name": "openai-curated",
|
|
"plugins": [
|
|
{plugins}
|
|
]
|
|
}}"#
|
|
),
|
|
)?;
|
|
|
|
for plugin_name in plugin_names {
|
|
let plugin_root = curated_root.join(format!("plugins/{plugin_name}/.codex-plugin"));
|
|
std::fs::create_dir_all(&plugin_root)?;
|
|
std::fs::write(
|
|
plugin_root.join("plugin.json"),
|
|
format!(r#"{{"name":"{plugin_name}"}}"#),
|
|
)?;
|
|
}
|
|
std::fs::create_dir_all(codex_home.join(".tmp"))?;
|
|
std::fs::write(
|
|
codex_home.join(".tmp/plugins.sha"),
|
|
format!("{TEST_CURATED_PLUGIN_SHA}\n"),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_plugin_share_local_path_mapping(
|
|
codex_home: &std::path::Path,
|
|
remote_plugin_id: &str,
|
|
plugin_path: &AbsolutePathBuf,
|
|
) -> std::io::Result<()> {
|
|
let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new();
|
|
local_plugin_paths_by_remote_plugin_id.insert(
|
|
remote_plugin_id.to_string(),
|
|
serde_json::to_value(plugin_path).map_err(std::io::Error::other)?,
|
|
);
|
|
let contents = serde_json::to_string_pretty(&serde_json::json!({
|
|
"localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id,
|
|
}))
|
|
.map_err(std::io::Error::other)?;
|
|
std::fs::create_dir_all(codex_home.join(".tmp"))?;
|
|
std::fs::write(
|
|
codex_home.join(".tmp/plugin-share-local-paths-v1.json"),
|
|
format!("{contents}\n"),
|
|
)
|
|
}
|