mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
9cd11e9e62
## Stack - Base: #27191 - This PR is the third vertical and should be reviewed against `jif/external-plugins-2`, not `main`. ## Why #27191 moves the host-owned Apps MCP registration behind an extension contributor, but deliberately preserves the existing endpoint-selection feature while that contribution contract lands. App-server can therefore resolve the server through extensions, yet the hosted plugin endpoint is still selected through temporary `apps_mcp_path_override` plumbing. That is not the long-term plugin model. A plugin can bundle skills, connectors, MCP servers, and hooks, and those components do not all need the same source or execution environment. In particular, an authenticated HTTP MCP server can expose plugin capabilities directly from a backend without an executor or an orchestrator filesystem. This PR completes that hosted vertical. App-server's MCP extension now owns the aggregate hosted plugin runtime at `/ps/mcp`. Connector actions continue to arrive as MCP tools, while backend-provided skills arrive as MCP resources and use Codex's existing resource list/read paths. No second backend client, skill filesystem, or generic plugin activation framework is introduced. The backend route remains the hosted implementation. This change replaces Codex's temporary endpoint-selection mechanism, not the service behind the endpoint. ## What changed ### Hosted plugin runtime The MCP extension now contributes `codex_apps` as the hosted plugin runtime rather than as a configurable Apps endpoint: - `https://chatgpt.com` resolves to `https://chatgpt.com/backend-api/ps/mcp`; - a bare custom ChatGPT base resolves to `/api/codex/ps/mcp`; - the existing product-SKU header and ChatGPT authentication behavior are preserved; - executor availability is never consulted for this streamable HTTP transport. The same MCP connection carries both component shapes supported by the hosted endpoint: - connector actions are discovered and invoked as MCP tools; - hosted skills are enumerated and read as MCP resources through the existing `list_mcp_resources` and `read_mcp_resource` paths. This keeps component access in the subsystem that already owns the protocol instead of downloading backend skills into an orchestrator filesystem or inventing a parallel hosted-skill client. ### Explicit runtime ordering `McpManager` now resolves the reserved `codex_apps` entry in three ordered phases: 1. install the legacy Apps fallback for compatibility; 2. apply ordered extension `Set` or `Remove` overlays; 3. apply the final ChatGPT-auth gate without synthesizing the server again. This ordering is important: - an ordinary configured or plugin MCP server cannot claim the auth-bearing `codex_apps` name; - an extension-contributed hosted runtime wins over the fallback; - an extension `Remove` remains authoritative; - a host without the MCP extension retains the legacy Apps endpoint and current local-only behavior. The temporary `legacy_apps_mcp_loader_enabled` coordination flag is no longer needed. ### Remove the path override The `apps_mcp_path_override` feature and its runtime plumbing are removed, including: - the feature registry entry and structured feature config; - `Config` and `McpConfig` fields; - config schema output; - config-lock materialization; - URL override handling in `codex-mcp`. Existing boolean and structured forms still deserialize as ignored compatibility input. They are omitted from new serialized config, and config-lock comparison normalizes the removed input so older locks remain replayable. ### App-server coverage App-server MCP fixtures now serve the hosted route at `/api/codex/ps/mcp`. Existing resource-read and tool/elicitation flows therefore exercise the extension-owned endpoint rather than succeeding through the legacy fallback. The stack also adds the missing `codex_chatgpt::connectors` re-export for the manager-backed connector helper introduced in #27191. ## Compatibility - App-server installs the extension and uses `/ps/mcp` for the hosted runtime. - CLI and other hosts that do not install the extension retain the legacy Apps endpoint. - Apps disabled or non-ChatGPT authentication removes `codex_apps` from the effective runtime view. - Existing local plugins, local skills, executor-selected skills, configured MCP servers, and MCP OAuth behavior are otherwise unchanged. - Backend plugin enablement remains account/workspace state owned by the hosted endpoint; this PR does not add thread-local backend plugin selection. ## Architectural fit The stack now proves two independent runtime shapes: 1. #27184 resolves filesystem-backed skills through the executor that owns a selected root. 2. #27191 and this PR resolve a backend-hosted HTTP MCP through an extension with no executor. Together they preserve the intended separation: - selection identifies a plugin/root when explicit selection is needed; - each component's owning extension resolves its concrete access mechanism; - execution stays with the runtime required by that component; - existing skills, MCP, connector, and hook subsystems remain the downstream consumers. ## Planned follow-ups 1. **Executor stdio MCP:** selecting an executor plugin registers a manifest-declared stdio MCP server and executes it in the environment that owns the plugin. 2. **Optional backend selection:** only if CCA needs thread-local selection distinct from backend account/workspace enablement, add a concrete backend-owned capability location and surface those selected skills through the skills catalog. 3. **Connector metadata and hooks:** activate those plugin components through their existing owning subsystems, with executor hooks remaining environment-bound. 4. **Propagation and persistence:** define explicit resume, fork, subagent, refresh, and environment-removal semantics once selected roots have multiple real consumers. 5. **Local convergence:** migrate legacy local skill, MCP, connector, and hook paths behind their owning extensions one vertical at a time, then remove duplicate core managers and compatibility plumbing after parity. ## Verification Coverage in this change exercises: - extension-owned `/backend-api/ps/mcp` registration without an executor; - preservation of the legacy endpoint in hosts without the extension; - extension `Set` and `Remove` precedence over the legacy fallback; - ChatGPT-auth gating for the reserved server; - hosted MCP resource reads with and without an active thread; - connector tool invocation and MCP elicitation through the hosted route; - ignored boolean and structured forms of the removed path override; - config-lock replay compatibility for the removed feature. `cargo check -p codex-features -p codex-mcp-extension -p codex-app-server` passes. Tests and Clippy were not run locally under the current development instruction; CI provides the full validation pass.
2082 lines
65 KiB
Rust
2082 lines
65 KiB
Rust
use std::borrow::Cow;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex as StdMutex;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use app_test_support::ChatGptAuthFixture;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::to_response;
|
|
use app_test_support::write_chatgpt_auth;
|
|
use axum::Json;
|
|
use axum::Router;
|
|
use axum::extract::State;
|
|
use axum::http::HeaderMap;
|
|
use axum::http::StatusCode;
|
|
use axum::http::Uri;
|
|
use axum::http::header::AUTHORIZATION;
|
|
use axum::routing::get;
|
|
use codex_app_server_protocol::AppInfo;
|
|
use codex_app_server_protocol::AppTemplateSummary;
|
|
use codex_app_server_protocol::AppTemplateUnavailableReason;
|
|
use codex_app_server_protocol::HookEventName;
|
|
use codex_app_server_protocol::JSONRPCError;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::PluginAuthPolicy;
|
|
use codex_app_server_protocol::PluginInstallPolicy;
|
|
use codex_app_server_protocol::PluginReadParams;
|
|
use codex_app_server_protocol::PluginReadResponse;
|
|
use codex_app_server_protocol::PluginShareDiscoverability;
|
|
use codex_app_server_protocol::PluginSharePrincipal;
|
|
use codex_app_server_protocol::PluginSharePrincipalRole;
|
|
use codex_app_server_protocol::PluginSharePrincipalType;
|
|
use codex_app_server_protocol::PluginSkillReadParams;
|
|
use codex_app_server_protocol::PluginSkillReadResponse;
|
|
use codex_app_server_protocol::PluginSource;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use pretty_assertions::assert_eq;
|
|
use rmcp::handler::server::ServerHandler;
|
|
use rmcp::model::JsonObject;
|
|
use rmcp::model::ListToolsResult;
|
|
use rmcp::model::Meta;
|
|
use rmcp::model::ServerCapabilities;
|
|
use rmcp::model::ServerInfo;
|
|
use rmcp::model::Tool;
|
|
use rmcp::model::ToolAnnotations;
|
|
use rmcp::transport::StreamableHttpServerConfig;
|
|
use rmcp::transport::StreamableHttpService;
|
|
use rmcp::transport::streamable_http_server::session::local::LocalSessionManager;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use tokio::net::TcpListener;
|
|
use tokio::task::JoinHandle;
|
|
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(10);
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_rejects_missing_read_source() -> 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_plugin_read_request(PluginReadParams {
|
|
marketplace_path: None,
|
|
remote_marketplace_name: None,
|
|
plugin_name: "sample-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("requires exactly one of marketplacePath or remoteMarketplaceName")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_rejects_multiple_read_sources() -> 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_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(AbsolutePathBuf::try_from(
|
|
codex_home.path().join("marketplace.json"),
|
|
)?),
|
|
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
|
plugin_name: "sample-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("requires exactly one of marketplacePath or remoteMarketplaceName")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_returns_remote_mcp_servers_when_uninstalled() -> 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
|
|
"#,
|
|
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 detail_body = r#"{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "example-plugin",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"release": {
|
|
"version": "1.2.1",
|
|
"display_name": "Example Plugin",
|
|
"description": "Example plugin",
|
|
"app_ids": [],
|
|
"keywords": [],
|
|
"interface": {
|
|
"short_description": "Example plugin",
|
|
"capabilities": [],
|
|
"default_prompt": "Use the legacy example prompt",
|
|
"default_prompts": []
|
|
},
|
|
"skills": [],
|
|
"mcp_servers": [
|
|
{
|
|
"key": "example-server",
|
|
"metadata": {
|
|
"command": "example-mcp"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}"#;
|
|
let installed_body = r#"{
|
|
"plugins": [],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(
|
|
"/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000",
|
|
))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(detail_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"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(installed_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_read_request(PluginReadParams {
|
|
marketplace_path: None,
|
|
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
|
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.plugin.marketplace_name, "openai-curated-remote");
|
|
assert_eq!(
|
|
response.plugin.summary.id,
|
|
"example-plugin@openai-curated-remote"
|
|
);
|
|
assert_eq!(
|
|
response.plugin.summary.remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_00000000000000000000000000000000")
|
|
);
|
|
assert_eq!(response.plugin.summary.name, "example-plugin");
|
|
assert_eq!(response.plugin.summary.source, PluginSource::Remote);
|
|
assert_eq!(response.plugin.summary.share_context, None);
|
|
assert_eq!(
|
|
response
|
|
.plugin
|
|
.summary
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.default_prompt.clone()),
|
|
Some(vec!["Use the legacy example prompt".to_string()])
|
|
);
|
|
assert_eq!(
|
|
response.plugin.mcp_servers,
|
|
vec!["example-server".to_string()]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> 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 detail_body = r#"{
|
|
"id": "plugins~Plugin_11111111111111111111111111111111",
|
|
"name": "shared-linear",
|
|
"scope": "WORKSPACE",
|
|
"discoverability": "PRIVATE",
|
|
"creator_account_user_id": "user-gavin__account-123",
|
|
"creator_name": "Gavin",
|
|
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
|
|
"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"
|
|
}
|
|
],
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"release": {
|
|
"version": "2.3.4",
|
|
"display_name": "Shared Linear",
|
|
"description": "Track shared work",
|
|
"app_ids": [],
|
|
"keywords": [],
|
|
"interface": {},
|
|
"skills": []
|
|
}
|
|
}"#;
|
|
let installed_body = r#"{
|
|
"plugins": [],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(
|
|
"/backend-api/ps/plugins/plugins~Plugin_11111111111111111111111111111111",
|
|
))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(detail_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"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(installed_body))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
for remote_marketplace_name in [
|
|
"workspace-shared-with-me-private",
|
|
"workspace-shared-with-me",
|
|
] {
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: None,
|
|
remote_marketplace_name: Some(remote_marketplace_name.to_string()),
|
|
plugin_name: "plugins~Plugin_11111111111111111111111111111111".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.plugin.marketplace_name, "workspace-shared-with-me");
|
|
assert_eq!(
|
|
response.plugin.summary.id,
|
|
"shared-linear@workspace-shared-with-me"
|
|
);
|
|
assert_eq!(
|
|
response.plugin.summary.remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_11111111111111111111111111111111")
|
|
);
|
|
let share_context = response
|
|
.plugin
|
|
.summary
|
|
.share_context
|
|
.as_ref()
|
|
.expect("expected share context");
|
|
assert_eq!(
|
|
share_context.remote_plugin_id,
|
|
"plugins~Plugin_11111111111111111111111111111111"
|
|
);
|
|
assert_eq!(share_context.remote_version.as_deref(), Some("2.3.4"));
|
|
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,
|
|
Some(vec![
|
|
PluginSharePrincipal {
|
|
principal_type: PluginSharePrincipalType::User,
|
|
principal_id: "user-gavin__account-123".to_string(),
|
|
role: PluginSharePrincipalRole::Owner,
|
|
name: "Gavin".to_string(),
|
|
},
|
|
PluginSharePrincipal {
|
|
principal_type: PluginSharePrincipalType::User,
|
|
principal_id: "user-ada__account-123".to_string(),
|
|
role: PluginSharePrincipalRole::Reader,
|
|
name: "Ada".to_string(),
|
|
},
|
|
])
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_reads_remote_plugin_details_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 detail_body = r#"{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"release": {
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
"app_templates": [
|
|
{
|
|
"template_id": "templated_apps_GitHubEnterprise",
|
|
"name": "GitHub Enterprise",
|
|
"description": "Connect GitHub Enterprise",
|
|
"canonical_connector_id": "github_enterprise",
|
|
"logo_url": "https://example.com/ghe-light.png",
|
|
"logo_url_dark": "https://example.com/ghe-dark.png",
|
|
"materialized_app_ids": ["asdk_app_ghe"],
|
|
"reason": null
|
|
},
|
|
{
|
|
"template_id": "templated_apps_Databricks",
|
|
"name": "Databricks",
|
|
"description": null,
|
|
"canonical_connector_id": null,
|
|
"logo_url": null,
|
|
"logo_url_dark": null,
|
|
"materialized_app_ids": [],
|
|
"reason": "NOT_CONFIGURED_FOR_WORKSPACE"
|
|
}
|
|
],
|
|
"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": [
|
|
{
|
|
"name": "plan-work",
|
|
"description": "Plan work from Linear issues",
|
|
"plugin_release_skill_id": "skill-1",
|
|
"interface": {
|
|
"display_name": "Plan Work",
|
|
"short_description": "Create a plan from issues"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
}"#;
|
|
let installed_body = r#"{
|
|
"plugins": [
|
|
{
|
|
"id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"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": [
|
|
{
|
|
"name": "plan-work",
|
|
"description": "Plan work from Linear issues",
|
|
"plugin_release_skill_id": "skill-1",
|
|
"interface": {
|
|
"display_name": "Plan Work",
|
|
"short_description": "Create a plan from issues"
|
|
}
|
|
}
|
|
]
|
|
},
|
|
"enabled": false,
|
|
"disabled_skill_names": ["plan-work"]
|
|
}
|
|
],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(
|
|
"/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000",
|
|
))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(detail_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"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(installed_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_read_request(PluginReadParams {
|
|
marketplace_path: None,
|
|
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
|
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.plugin.marketplace_name, "openai-curated-remote");
|
|
assert_eq!(response.plugin.marketplace_path, None);
|
|
assert_eq!(response.plugin.summary.source, PluginSource::Remote);
|
|
assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote");
|
|
assert_eq!(
|
|
response.plugin.summary.remote_plugin_id.as_deref(),
|
|
Some("plugins~Plugin_00000000000000000000000000000000")
|
|
);
|
|
assert_eq!(response.plugin.summary.name, "linear");
|
|
assert_eq!(response.plugin.summary.installed, true);
|
|
assert_eq!(response.plugin.summary.enabled, false);
|
|
assert_eq!(
|
|
response.plugin.description.as_deref(),
|
|
Some("Track work in Linear")
|
|
);
|
|
assert_eq!(
|
|
response.plugin.summary.keywords,
|
|
vec![
|
|
"issue-tracking".to_string(),
|
|
"project management".to_string()
|
|
]
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.plugin
|
|
.summary
|
|
.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!(response.plugin.skills.len(), 1);
|
|
assert_eq!(response.plugin.skills[0].name, "plan-work");
|
|
assert_eq!(response.plugin.skills[0].path, None);
|
|
assert_eq!(response.plugin.skills[0].enabled, false);
|
|
assert_eq!(response.plugin.apps.len(), 0);
|
|
assert_eq!(
|
|
response.plugin.app_templates,
|
|
vec![
|
|
AppTemplateSummary {
|
|
template_id: "templated_apps_GitHubEnterprise".to_string(),
|
|
name: "GitHub Enterprise".to_string(),
|
|
description: Some("Connect GitHub Enterprise".to_string()),
|
|
canonical_connector_id: Some("github_enterprise".to_string()),
|
|
logo_url: Some("https://example.com/ghe-light.png".to_string()),
|
|
logo_url_dark: Some("https://example.com/ghe-dark.png".to_string()),
|
|
materialized_app_ids: vec!["asdk_app_ghe".to_string()],
|
|
reason: None,
|
|
},
|
|
AppTemplateSummary {
|
|
template_id: "templated_apps_Databricks".to_string(),
|
|
name: "Databricks".to_string(),
|
|
description: None,
|
|
canonical_connector_id: None,
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
materialized_app_ids: Vec::new(),
|
|
reason: Some(AppTemplateUnavailableReason::NotConfiguredForWorkspace),
|
|
},
|
|
]
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_skill_read_reads_remote_skill_contents_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 skill_body = r##"{
|
|
"plugin_id": "plugins~Plugin_00000000000000000000000000000000",
|
|
"status": "ENABLED",
|
|
"plugin_release_id": "release-1",
|
|
"name": "plan-work",
|
|
"description": "Plan work from Linear issues",
|
|
"plugin_release_skill_id": "skill-1",
|
|
"skill_md_contents": "# Plan Work\n\nUse Linear issues to create a plan."
|
|
}"##;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(
|
|
"/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000/skills/plan-work",
|
|
))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(skill_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_skill_read_request(PluginSkillReadParams {
|
|
remote_marketplace_name: "openai-curated-remote".to_string(),
|
|
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
|
skill_name: "plan-work".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginSkillReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginSkillReadResponse {
|
|
contents: Some("# Plan Work\n\nUse Linear issues to create a plan.".to_string()),
|
|
}
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> 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,
|
|
)?;
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/plugins~Plugin_missing"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(404).set_body_string(r#"{"detail":"not found"}"#))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: None,
|
|
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
|
plugin_name: "plugins~Plugin_missing".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("read remote plugin details: remote plugin catalog request")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_rejects_remote_marketplace_when_plugins_are_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_read_request(PluginReadParams {
|
|
marketplace_path: None,
|
|
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
|
plugin_name: "linear".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("remote plugin read is not enabled")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?;
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: None,
|
|
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
|
plugin_name: "linear/../../oops".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(err.error.message.contains("invalid remote plugin id"));
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_returns_canonical_openai_curated_marketplace_name() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
write_plugin_marketplace(
|
|
repo_root.path(),
|
|
"openai-curated",
|
|
"demo-plugin",
|
|
"./demo-plugin",
|
|
)?;
|
|
std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?;
|
|
std::fs::write(
|
|
repo_root
|
|
.path()
|
|
.join("demo-plugin/.codex-plugin/plugin.json"),
|
|
r#"{
|
|
"name": "demo-plugin",
|
|
"description": "OpenAI curated plugin"
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[plugins."demo-plugin@openai-curated"]
|
|
enabled = true
|
|
"#,
|
|
)?;
|
|
write_installed_plugin(&codex_home, "openai-curated", "demo-plugin")?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(marketplace_path.clone()),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "demo-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.plugin.marketplace_name, "openai-curated");
|
|
assert_eq!(response.plugin.marketplace_path, Some(marketplace_path));
|
|
assert_eq!(response.plugin.summary.id, "demo-plugin@openai-curated");
|
|
assert_eq!(response.plugin.summary.name, "demo-plugin");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = 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_plugin_marketplace(
|
|
repo_root.path(),
|
|
"codex-curated",
|
|
"demo-plugin",
|
|
"./demo-plugin",
|
|
)?;
|
|
std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?;
|
|
std::fs::write(
|
|
repo_root
|
|
.path()
|
|
.join("demo-plugin/.codex-plugin/plugin.json"),
|
|
r#"{"name":"demo-plugin","version":"1.2.3"}"#,
|
|
)?;
|
|
let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;
|
|
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/plugins_123"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"id": "plugins_123",
|
|
"name": "demo-plugin",
|
|
"scope": "WORKSPACE",
|
|
"discoverability": "UNLISTED",
|
|
"creator_account_user_id": "user-owner__account-123",
|
|
"creator_name": "Owner",
|
|
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
|
|
"share_principals": [
|
|
{
|
|
"principal_type": "user",
|
|
"principal_id": "user-owner__account-123",
|
|
"role": "owner",
|
|
"name": "Owner",
|
|
},
|
|
{
|
|
"principal_type": "user",
|
|
"principal_id": "user-editor__account-123",
|
|
"role": "editor",
|
|
"name": "Editor",
|
|
},
|
|
],
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"release": {
|
|
"version": "1.2.4",
|
|
"display_name": "Demo Plugin",
|
|
"description": "Shared local plugin",
|
|
"app_ids": [],
|
|
"keywords": [],
|
|
"interface": {},
|
|
"skills": []
|
|
}
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)?),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "demo-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.plugin.summary.remote_plugin_id, None);
|
|
assert_eq!(
|
|
response.plugin.summary.local_version.as_deref(),
|
|
Some("1.2.3")
|
|
);
|
|
let share_context = response
|
|
.plugin
|
|
.summary
|
|
.share_context
|
|
.as_ref()
|
|
.expect("expected share context");
|
|
assert_eq!(share_context.remote_plugin_id, "plugins_123");
|
|
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4"));
|
|
assert_eq!(
|
|
share_context.discoverability,
|
|
Some(PluginShareDiscoverability::Unlisted)
|
|
);
|
|
assert_eq!(
|
|
share_context.share_url.as_deref(),
|
|
Some("https://chatgpt.example/plugins/share/share-key-1")
|
|
);
|
|
assert_eq!(
|
|
share_context.creator_account_user_id.as_deref(),
|
|
Some("user-owner__account-123")
|
|
);
|
|
assert_eq!(share_context.creator_name.as_deref(), Some("Owner"));
|
|
assert_eq!(
|
|
share_context.share_principals,
|
|
Some(vec![
|
|
PluginSharePrincipal {
|
|
principal_type: PluginSharePrincipalType::User,
|
|
principal_id: "user-owner__account-123".to_string(),
|
|
role: PluginSharePrincipalRole::Owner,
|
|
name: "Owner".to_string(),
|
|
},
|
|
PluginSharePrincipal {
|
|
principal_type: PluginSharePrincipalType::User,
|
|
principal_id: "user-editor__account-123".to_string(),
|
|
role: PluginSharePrincipalRole::Editor,
|
|
name: "Editor".to_string(),
|
|
},
|
|
])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = 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_plugin_marketplace(
|
|
repo_root.path(),
|
|
"codex-curated",
|
|
"demo-plugin",
|
|
"./demo-plugin",
|
|
)?;
|
|
std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?;
|
|
std::fs::write(
|
|
repo_root
|
|
.path()
|
|
.join("demo-plugin/.codex-plugin/plugin.json"),
|
|
r#"{"name":"demo-plugin","version":"1.2.3"}"#,
|
|
)?;
|
|
let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;
|
|
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;
|
|
Mock::given(method("GET"))
|
|
.and(path("/backend-api/ps/plugins/plugins_123"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"id": "plugins_123",
|
|
"name": "demo-plugin",
|
|
"scope": "WORKSPACE",
|
|
"discoverability": "UNLISTED",
|
|
"creator_account_user_id": "user-owner__account-123",
|
|
"creator_name": "Owner",
|
|
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
|
|
"share_principals": null,
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"release": {
|
|
"version": "1.2.4",
|
|
"display_name": "Demo Plugin",
|
|
"description": "Shared local plugin",
|
|
"app_ids": [],
|
|
"keywords": [],
|
|
"interface": {},
|
|
"skills": []
|
|
}
|
|
})))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)?),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "demo-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.plugin.summary.remote_plugin_id, None);
|
|
assert_eq!(
|
|
response.plugin.summary.local_version.as_deref(),
|
|
Some("1.2.3")
|
|
);
|
|
let share_context = response
|
|
.plugin
|
|
.summary
|
|
.share_context
|
|
.as_ref()
|
|
.expect("expected share context");
|
|
assert_eq!(share_context.remote_plugin_id, "plugins_123");
|
|
assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4"));
|
|
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_read_falls_back_to_local_share_context_without_remote_auth() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
write_plugins_enabled_config(&codex_home)?;
|
|
write_plugin_marketplace(
|
|
repo_root.path(),
|
|
"codex-curated",
|
|
"demo-plugin",
|
|
"./demo-plugin",
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "demo-plugin", &[])?;
|
|
let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?;
|
|
write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)?),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "demo-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.plugin.summary.remote_plugin_id, None);
|
|
assert_eq!(response.plugin.summary.local_version, None);
|
|
let share_context = response
|
|
.plugin
|
|
.summary
|
|
.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_read_fails_on_malformed_share_mapping() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
write_plugins_enabled_config(&codex_home)?;
|
|
write_plugin_marketplace(
|
|
repo_root.path(),
|
|
"codex-curated",
|
|
"demo-plugin",
|
|
"./demo-plugin",
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "demo-plugin", &[])?;
|
|
std::fs::create_dir_all(codex_home.path().join(".tmp"))?;
|
|
std::fs::write(
|
|
codex_home
|
|
.path()
|
|
.join(".tmp/plugin-share-local-paths-v1.json"),
|
|
"not valid json\n",
|
|
)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)?),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "demo-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let error: JSONRPCError = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(error.error.code, -32603);
|
|
assert!(
|
|
error
|
|
.error
|
|
.message
|
|
.contains("failed to load plugin share local path mapping")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
|
std::fs::create_dir_all(plugin_root.join("hooks"))?;
|
|
std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer"))?;
|
|
std::fs::create_dir_all(plugin_root.join("skills/chatgpt-only"))?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/demo-plugin"
|
|
},
|
|
"policy": {
|
|
"installation": "AVAILABLE",
|
|
"authentication": "ON_INSTALL"
|
|
},
|
|
"category": "Design"
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r##"{
|
|
"name": "demo-plugin",
|
|
"description": "Longer manifest description",
|
|
"keywords": ["api-key", "developer tools"],
|
|
"interface": {
|
|
"displayName": "Plugin Display Name",
|
|
"shortDescription": "Short description for subtitle",
|
|
"longDescription": "Long description for details page",
|
|
"developerName": "OpenAI",
|
|
"category": "Productivity",
|
|
"capabilities": ["Interactive", "Write"],
|
|
"websiteURL": "https://openai.com/",
|
|
"privacyPolicyURL": "https://openai.com/policies/row-privacy-policy/",
|
|
"termsOfServiceURL": "https://openai.com/policies/row-terms-of-use/",
|
|
"defaultPrompt": [
|
|
"Draft the reply",
|
|
"Find my next action"
|
|
],
|
|
"brandColor": "#3B82F6",
|
|
"composerIcon": "./assets/icon.png",
|
|
"logo": "./assets/logo.png",
|
|
"screenshots": ["./assets/screenshot1.png"]
|
|
}
|
|
}"##,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join("skills/thread-summarizer/SKILL.md"),
|
|
r#"---
|
|
name: thread-summarizer
|
|
description: Summarize email threads
|
|
---
|
|
|
|
# Thread Summarizer
|
|
"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join("skills/chatgpt-only/SKILL.md"),
|
|
r#"---
|
|
name: chatgpt-only
|
|
description: Visible only for ChatGPT
|
|
---
|
|
|
|
# ChatGPT Only
|
|
"#,
|
|
)?;
|
|
std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer/agents"))?;
|
|
std::fs::write(
|
|
plugin_root.join("skills/thread-summarizer/agents/openai.yaml"),
|
|
r#"policy:
|
|
products:
|
|
- CODEX
|
|
"#,
|
|
)?;
|
|
std::fs::create_dir_all(plugin_root.join("skills/chatgpt-only/agents"))?;
|
|
std::fs::write(
|
|
plugin_root.join("skills/chatgpt-only/agents/openai.yaml"),
|
|
r#"policy:
|
|
products:
|
|
- CHATGPT
|
|
"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join(".app.json"),
|
|
r#"{
|
|
"apps": {
|
|
"gmail": {
|
|
"id": "gmail"
|
|
}
|
|
}
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join(".mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"demo": {
|
|
"command": "demo-server"
|
|
}
|
|
}
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join("hooks/hooks.json"),
|
|
r#"{
|
|
"hooks": {
|
|
"SessionStart": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "echo startup"
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"PreToolUse": [
|
|
{
|
|
"hooks": [
|
|
{
|
|
"type": "command",
|
|
"command": "echo first"
|
|
},
|
|
{
|
|
"type": "command",
|
|
"command": "echo second"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
|
|
[[skills.config]]
|
|
name = "demo-plugin:thread-summarizer"
|
|
enabled = false
|
|
|
|
[plugins."demo-plugin@codex-curated"]
|
|
enabled = true
|
|
|
|
[hooks.state."demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0"]
|
|
enabled = false
|
|
"#,
|
|
)?;
|
|
write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(marketplace_path.clone()),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "demo-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(response.plugin.marketplace_name, "codex-curated");
|
|
assert_eq!(response.plugin.marketplace_path, Some(marketplace_path));
|
|
assert_eq!(response.plugin.summary.id, "demo-plugin@codex-curated");
|
|
assert_eq!(response.plugin.summary.name, "demo-plugin");
|
|
assert_eq!(
|
|
response.plugin.description.as_deref(),
|
|
Some("Longer manifest description")
|
|
);
|
|
assert_eq!(response.plugin.summary.installed, true);
|
|
assert_eq!(response.plugin.summary.enabled, true);
|
|
assert_eq!(
|
|
response.plugin.summary.install_policy,
|
|
PluginInstallPolicy::Available
|
|
);
|
|
assert_eq!(
|
|
response.plugin.summary.auth_policy,
|
|
PluginAuthPolicy::OnInstall
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.plugin
|
|
.summary
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref()),
|
|
Some("Plugin Display Name")
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.plugin
|
|
.summary
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.category.as_deref()),
|
|
Some("Design")
|
|
);
|
|
assert_eq!(
|
|
response
|
|
.plugin
|
|
.summary
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.default_prompt.clone()),
|
|
Some(vec![
|
|
"Draft the reply".to_string(),
|
|
"Find my next action".to_string()
|
|
])
|
|
);
|
|
assert_eq!(
|
|
response.plugin.summary.keywords,
|
|
vec!["api-key".to_string(), "developer tools".to_string()]
|
|
);
|
|
assert_eq!(response.plugin.skills.len(), 1);
|
|
assert_eq!(
|
|
response.plugin.skills[0].name,
|
|
"demo-plugin:thread-summarizer"
|
|
);
|
|
assert_eq!(
|
|
response.plugin.skills[0].description,
|
|
"Summarize email threads"
|
|
);
|
|
assert!(!response.plugin.skills[0].enabled);
|
|
assert_eq!(
|
|
response.plugin.hooks,
|
|
vec![
|
|
codex_app_server_protocol::PluginHookSummary {
|
|
key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0".to_string(),
|
|
event_name: HookEventName::PreToolUse,
|
|
},
|
|
codex_app_server_protocol::PluginHookSummary {
|
|
key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:1".to_string(),
|
|
event_name: HookEventName::PreToolUse,
|
|
},
|
|
codex_app_server_protocol::PluginHookSummary {
|
|
key: "demo-plugin@codex-curated:hooks/hooks.json:session_start:0:0".to_string(),
|
|
event_name: HookEventName::SessionStart,
|
|
},
|
|
]
|
|
);
|
|
assert_eq!(response.plugin.apps.len(), 1);
|
|
assert_eq!(response.plugin.apps[0].id, "gmail");
|
|
assert_eq!(response.plugin.apps[0].name, "gmail");
|
|
assert_eq!(
|
|
response.plugin.apps[0].install_url.as_deref(),
|
|
Some("https://chatgpt.com/apps/gmail/gmail")
|
|
);
|
|
assert_eq!(response.plugin.apps[0].needs_auth, true);
|
|
assert_eq!(response.plugin.mcp_servers.len(), 1);
|
|
assert_eq!(response.plugin.mcp_servers[0], "demo");
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_returns_app_needs_auth() -> Result<()> {
|
|
let connectors = vec![
|
|
AppInfo {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
logo_url: Some("https://example.com/alpha.png".to_string()),
|
|
logo_url_dark: None,
|
|
distribution_channel: Some("featured".to_string()),
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
AppInfo {
|
|
id: "beta".to_string(),
|
|
name: "Beta".to_string(),
|
|
description: Some("Beta connector".to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: None,
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
];
|
|
let tools = vec![connector_tool("beta", "Beta App")?];
|
|
let (server_url, server_handle) = start_apps_server(connectors, tools).await?;
|
|
|
|
let codex_home = TempDir::new()?;
|
|
write_connectors_config(codex_home.path(), &server_url)?;
|
|
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 repo_root = TempDir::new()?;
|
|
write_plugin_marketplace(
|
|
repo_root.path(),
|
|
"debug",
|
|
"sample-plugin",
|
|
"./sample-plugin",
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?;
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(marketplace_path),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "sample-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response
|
|
.plugin
|
|
.apps
|
|
.iter()
|
|
.map(|app| (app.id.as_str(), app.needs_auth))
|
|
.collect::<Vec<_>>(),
|
|
vec![("alpha", true), ("beta", false)]
|
|
);
|
|
|
|
server_handle.abort();
|
|
let _ = server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/demo-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
std::fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
r##"{
|
|
"name": "demo-plugin",
|
|
"interface": {
|
|
"defaultPrompt": "Starter prompt for trying a plugin"
|
|
}
|
|
}"##,
|
|
)?;
|
|
write_plugins_enabled_config(&codex_home)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)?),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "demo-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response
|
|
.plugin
|
|
.summary
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.default_prompt.clone()),
|
|
Some(vec!["Starter prompt for trying a plugin".to_string()])
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_describes_uninstalled_git_source_without_cloning() -> 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"
|
|
}}
|
|
}}
|
|
]
|
|
}}"#
|
|
),
|
|
)?;
|
|
write_plugins_enabled_config(&codex_home)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)?),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "toolkit".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginReadResponse = to_response(response)?;
|
|
|
|
let expected_description = format!(
|
|
"This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {missing_remote_repo_url}, path `plugins/toolkit`."
|
|
);
|
|
assert_eq!(
|
|
response.plugin.description.as_deref(),
|
|
Some(expected_description.as_str())
|
|
);
|
|
assert!(!response.plugin.summary.installed);
|
|
assert!(response.plugin.skills.is_empty());
|
|
assert!(response.plugin.apps.is_empty());
|
|
assert!(response.plugin.mcp_servers.is_empty());
|
|
assert!(
|
|
!codex_home
|
|
.path()
|
|
.join("plugins/.marketplace-plugin-source-staging")
|
|
.exists()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/demo-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
write_plugins_enabled_config(&codex_home)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)?),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "missing-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("plugin `missing-plugin` was not found")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
let plugin_root = repo_root.path().join("plugins/demo-plugin");
|
|
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::create_dir_all(&plugin_root)?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "codex-curated",
|
|
"plugins": [
|
|
{
|
|
"name": "demo-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./plugins/demo-plugin"
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
write_plugins_enabled_config(&codex_home)?;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_read_request(PluginReadParams {
|
|
marketplace_path: Some(AbsolutePathBuf::try_from(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
)?),
|
|
remote_marketplace_name: None,
|
|
plugin_name: "demo-plugin".to_string(),
|
|
})
|
|
.await?;
|
|
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert!(err.error.message.contains("missing or invalid plugin.json"));
|
|
Ok(())
|
|
}
|
|
|
|
fn write_installed_plugin(
|
|
codex_home: &TempDir,
|
|
marketplace_name: &str,
|
|
plugin_name: &str,
|
|
) -> Result<()> {
|
|
let plugin_root = codex_home
|
|
.path()
|
|
.join("plugins/cache")
|
|
.join(marketplace_name)
|
|
.join(plugin_name)
|
|
.join("local/.codex-plugin");
|
|
std::fs::create_dir_all(&plugin_root)?;
|
|
std::fs::write(
|
|
plugin_root.join("plugin.json"),
|
|
format!(r#"{{"name":"{plugin_name}"}}"#),
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_plugins_enabled_config(codex_home: &TempDir) -> Result<()> {
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = true
|
|
"#,
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct AppsServerState {
|
|
response: Arc<StdMutex<serde_json::Value>>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct PluginReadMcpServer {
|
|
tools: Arc<StdMutex<Vec<Tool>>>,
|
|
}
|
|
|
|
impl ServerHandler for PluginReadMcpServer {
|
|
fn get_info(&self) -> ServerInfo {
|
|
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
|
}
|
|
|
|
fn list_tools(
|
|
&self,
|
|
_request: Option<rmcp::model::PaginatedRequestParams>,
|
|
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
|
) -> impl std::future::Future<Output = Result<ListToolsResult, rmcp::ErrorData>> + Send + '_
|
|
{
|
|
let tools = self.tools.clone();
|
|
async move {
|
|
let tools = tools
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone();
|
|
Ok(ListToolsResult {
|
|
tools,
|
|
next_cursor: None,
|
|
meta: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn start_apps_server(
|
|
connectors: Vec<AppInfo>,
|
|
tools: Vec<Tool>,
|
|
) -> Result<(String, JoinHandle<()>)> {
|
|
let state = Arc::new(AppsServerState {
|
|
response: Arc::new(StdMutex::new(
|
|
json!({ "apps": connectors, "next_token": null }),
|
|
)),
|
|
});
|
|
let tools = Arc::new(StdMutex::new(tools));
|
|
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let addr = listener.local_addr()?;
|
|
let mcp_service = StreamableHttpService::new(
|
|
{
|
|
let tools = tools.clone();
|
|
move || {
|
|
Ok(PluginReadMcpServer {
|
|
tools: tools.clone(),
|
|
})
|
|
}
|
|
},
|
|
Arc::new(LocalSessionManager::default()),
|
|
StreamableHttpServerConfig::default(),
|
|
);
|
|
let router = Router::new()
|
|
.route("/connectors/directory/list", get(list_directory_connectors))
|
|
.route(
|
|
"/connectors/directory/list_workspace",
|
|
get(list_directory_connectors),
|
|
)
|
|
.with_state(state)
|
|
.nest_service("/api/codex/ps/mcp", mcp_service);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
let _ = axum::serve(listener, router).await;
|
|
});
|
|
|
|
Ok((format!("http://{addr}"), handle))
|
|
}
|
|
|
|
async fn list_directory_connectors(
|
|
State(state): State<Arc<AppsServerState>>,
|
|
headers: HeaderMap,
|
|
uri: Uri,
|
|
) -> Result<impl axum::response::IntoResponse, StatusCode> {
|
|
let bearer_ok = headers
|
|
.get(AUTHORIZATION)
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value == "Bearer chatgpt-token");
|
|
let account_ok = headers
|
|
.get("chatgpt-account-id")
|
|
.and_then(|value| value.to_str().ok())
|
|
.is_some_and(|value| value == "account-123");
|
|
let external_logos_ok = uri
|
|
.query()
|
|
.is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true"));
|
|
|
|
if !bearer_ok || !account_ok {
|
|
Err(StatusCode::UNAUTHORIZED)
|
|
} else if !external_logos_ok {
|
|
Err(StatusCode::BAD_REQUEST)
|
|
} else {
|
|
let response = state
|
|
.response
|
|
.lock()
|
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
.clone();
|
|
Ok(Json(response))
|
|
}
|
|
}
|
|
|
|
fn connector_tool(connector_id: &str, connector_name: &str) -> Result<Tool> {
|
|
let schema: JsonObject = serde_json::from_value(json!({
|
|
"type": "object",
|
|
"additionalProperties": false
|
|
}))?;
|
|
let mut tool = Tool::new(
|
|
Cow::Owned(format!("connector_{connector_id}")),
|
|
Cow::Borrowed("Connector test tool"),
|
|
Arc::new(schema),
|
|
);
|
|
tool.annotations = Some(ToolAnnotations::new().read_only(true));
|
|
|
|
let mut meta = Meta::new();
|
|
meta.0
|
|
.insert("connector_id".to_string(), json!(connector_id));
|
|
meta.0
|
|
.insert("connector_name".to_string(), json!(connector_name));
|
|
tool.meta = Some(meta);
|
|
Ok(tool)
|
|
}
|
|
|
|
fn write_connectors_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}"
|
|
mcp_oauth_credentials_store = "file"
|
|
|
|
[features]
|
|
plugins = true
|
|
connectors = 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_plugin_marketplace(
|
|
repo_root: &std::path::Path,
|
|
marketplace_name: &str,
|
|
plugin_name: &str,
|
|
source_path: &str,
|
|
) -> std::io::Result<()> {
|
|
std::fs::create_dir_all(repo_root.join(".git"))?;
|
|
std::fs::create_dir_all(repo_root.join(".agents/plugins"))?;
|
|
std::fs::write(
|
|
repo_root.join(".agents/plugins/marketplace.json"),
|
|
format!(
|
|
r#"{{
|
|
"name": "{marketplace_name}",
|
|
"plugins": [
|
|
{{
|
|
"name": "{plugin_name}",
|
|
"source": {{
|
|
"source": "local",
|
|
"path": "{source_path}"
|
|
}}
|
|
}}
|
|
]
|
|
}}"#
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_plugin_source(
|
|
repo_root: &std::path::Path,
|
|
plugin_name: &str,
|
|
app_ids: &[&str],
|
|
) -> Result<()> {
|
|
let plugin_root = repo_root.join(plugin_name);
|
|
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
|
std::fs::write(
|
|
plugin_root.join(".codex-plugin/plugin.json"),
|
|
format!(r#"{{"name":"{plugin_name}"}}"#),
|
|
)?;
|
|
|
|
let apps = app_ids
|
|
.iter()
|
|
.map(|app_id| ((*app_id).to_string(), json!({ "id": app_id })))
|
|
.collect::<serde_json::Map<_, _>>();
|
|
std::fs::write(
|
|
plugin_root.join(".app.json"),
|
|
serde_json::to_vec_pretty(&json!({ "apps": apps }))?,
|
|
)?;
|
|
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(&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"),
|
|
)
|
|
}
|