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.
1838 lines
57 KiB
Rust
1838 lines
57 KiB
Rust
use std::borrow::Cow;
|
|
use std::sync::Arc;
|
|
use std::sync::Mutex as StdMutex;
|
|
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::atomic::Ordering;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use anyhow::bail;
|
|
use app_test_support::ChatGptAuthFixture;
|
|
use app_test_support::DEFAULT_CLIENT_NAME;
|
|
use app_test_support::TestAppServer;
|
|
use app_test_support::start_analytics_events_server;
|
|
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::AppSummary;
|
|
use codex_app_server_protocol::AppsListParams;
|
|
use codex_app_server_protocol::AppsListResponse;
|
|
use codex_app_server_protocol::JSONRPCResponse;
|
|
use codex_app_server_protocol::PluginAuthPolicy;
|
|
use codex_app_server_protocol::PluginAvailability;
|
|
use codex_app_server_protocol::PluginInstallParams;
|
|
use codex_app_server_protocol::PluginInstallResponse;
|
|
use codex_app_server_protocol::RequestId;
|
|
use codex_config::types::AuthCredentialsStoreMode;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use flate2::Compression;
|
|
use flate2::write::GzEncoder;
|
|
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::Match;
|
|
use wiremock::Mock;
|
|
use wiremock::MockServer;
|
|
use wiremock::Request;
|
|
use wiremock::ResponseTemplate;
|
|
use wiremock::matchers::header;
|
|
use wiremock::matchers::method;
|
|
use wiremock::matchers::path;
|
|
use wiremock::matchers::query_param;
|
|
|
|
// Plugin install tests wait on connector discovery after the install response path
|
|
// starts, which is noticeably slower on Windows CI.
|
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
|
|
const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_00000000000000000000000000000000";
|
|
const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
|
|
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_rejects_relative_marketplace_paths() -> 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/install",
|
|
Some(serde_json::json!({
|
|
"marketplacePath": "relative-marketplace.json",
|
|
"pluginName": "missing-plugin",
|
|
})),
|
|
)
|
|
.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_install_rejects_missing_install_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_install_request(PluginInstallParams {
|
|
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_install_rejects_multiple_install_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_install_request(PluginInstallParams {
|
|
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_install_rejects_remote_marketplace_when_plugins_are_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
r#"[features]
|
|
plugins = false
|
|
"#,
|
|
)?;
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path: None,
|
|
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
|
plugin_name: "plugins~Plugin_22222222222222222222222222222222".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 install is not enabled")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
let installed_path = codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear/1.2.3");
|
|
let remote_app_manifest = json!({
|
|
"apps": {
|
|
"linear-remote": {
|
|
"id": "remote-linear-app"
|
|
}
|
|
}
|
|
});
|
|
let bundle_url = mount_remote_plugin_bundle(
|
|
&server,
|
|
/*status_code*/ 200,
|
|
remote_plugin_bundle_tar_gz_bytes_with_contents(
|
|
r#"{"name":"linear","version":"0.0.1"}"#,
|
|
Some(r#"{"apps":{"linear-bundled":{"id":"bundled-linear-app"}}}"#),
|
|
)?,
|
|
)
|
|
.await;
|
|
configure_remote_plugin_test(codex_home.path(), &server)?;
|
|
mount_remote_plugin_detail_with_app_manifest(
|
|
&server,
|
|
REMOTE_PLUGIN_ID,
|
|
"1.2.3",
|
|
Some(&bundle_url),
|
|
remote_app_manifest.clone(),
|
|
)
|
|
.await;
|
|
mount_empty_remote_installed_plugins(&server).await;
|
|
mount_remote_plugin_install_after_cache_write(
|
|
&server,
|
|
REMOTE_PLUGIN_ID,
|
|
installed_path.join(".codex-plugin/plugin.json"),
|
|
)
|
|
.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 request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstallResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginInstallResponse {
|
|
auth_policy: PluginAuthPolicy::OnUse,
|
|
apps_needing_auth: Vec::new(),
|
|
}
|
|
);
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"POST",
|
|
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"),
|
|
/*expected_count*/ 1,
|
|
)
|
|
.await?;
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"GET",
|
|
"/bundles/linear.tar.gz",
|
|
/*expected_count*/ 1,
|
|
)
|
|
.await?;
|
|
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
|
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["name"], json!("linear"));
|
|
assert_eq!(installed_plugin_manifest["version"], 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());
|
|
assert!(
|
|
!codex_home
|
|
.path()
|
|
.join(format!(
|
|
"plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}/1.2.3"
|
|
))
|
|
.exists()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
let bundle_url = mount_remote_plugin_bundle(
|
|
&server,
|
|
/*status_code*/ 200,
|
|
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
|
)
|
|
.await;
|
|
configure_remote_plugin_with_apps_test(codex_home.path(), &server)?;
|
|
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
|
|
mount_empty_remote_installed_plugins(&server).await;
|
|
mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).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 request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstallResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginInstallResponse {
|
|
auth_policy: PluginAuthPolicy::OnUse,
|
|
apps_needing_auth: vec![AppSummary {
|
|
id: "alpha".to_string(),
|
|
name: "alpha".to_string(),
|
|
description: None,
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
needs_auth: true,
|
|
}],
|
|
}
|
|
);
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"GET",
|
|
"/backend-api/connectors/directory/list",
|
|
/*expected_count*/ 0,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
configure_remote_plugin_test(codex_home.path(), &server)?;
|
|
mount_remote_plugin_detail(
|
|
&server,
|
|
REMOTE_PLUGIN_ID,
|
|
"1.2.3",
|
|
/*bundle_download_url*/ None,
|
|
)
|
|
.await;
|
|
mount_empty_remote_installed_plugins(&server).await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32603);
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("backend did not return a download URL")
|
|
);
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"POST",
|
|
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"),
|
|
/*expected_count*/ 0,
|
|
)
|
|
.await?;
|
|
assert!(
|
|
!codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear")
|
|
.exists()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_rejects_plain_http_remote_bundle_url() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
let bundle_url = format!("{}/bundles/linear.tar.gz", server.uri());
|
|
configure_remote_plugin_test(codex_home.path(), &server)?;
|
|
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
|
|
mount_empty_remote_installed_plugins(&server).await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32603);
|
|
assert!(
|
|
err.error
|
|
.message
|
|
.contains("unsupported download URL scheme")
|
|
);
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"POST",
|
|
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"),
|
|
/*expected_count*/ 0,
|
|
)
|
|
.await?;
|
|
assert!(
|
|
!codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear")
|
|
.exists()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
configure_remote_plugin_test(codex_home.path(), &server)?;
|
|
mount_remote_plugin_detail(
|
|
&server,
|
|
REMOTE_PLUGIN_ID,
|
|
"../1.2.3",
|
|
Some("https://127.0.0.1:1/bundles/linear.tar.gz"),
|
|
)
|
|
.await;
|
|
mount_empty_remote_installed_plugins(&server).await;
|
|
|
|
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32603);
|
|
assert!(err.error.message.contains("invalid release version"));
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"POST",
|
|
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"),
|
|
/*expected_count*/ 0,
|
|
)
|
|
.await?;
|
|
assert!(
|
|
!codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear")
|
|
.exists()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_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_install_request(PluginInstallParams {
|
|
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"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
let bundle_url = mount_remote_plugin_bundle(
|
|
&server,
|
|
/*status_code*/ 200,
|
|
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
|
)
|
|
.await;
|
|
configure_remote_plugin_test(codex_home.path(), &server)?;
|
|
mount_remote_plugin_detail_with_status(
|
|
&server,
|
|
REMOTE_PLUGIN_ID,
|
|
"1.2.3",
|
|
Some(&bundle_url),
|
|
PluginAvailability::DisabledByAdmin,
|
|
)
|
|
.await;
|
|
mount_empty_remote_installed_plugins(&server).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 request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).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("disabled by admin"));
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"GET",
|
|
"/bundles/linear.tar.gz",
|
|
/*expected_count*/ 0,
|
|
)
|
|
.await?;
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"POST",
|
|
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"),
|
|
/*expected_count*/ 0,
|
|
)
|
|
.await?;
|
|
assert!(
|
|
!codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear")
|
|
.exists()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = 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")
|
|
.plan_type("team"),
|
|
AuthCredentialsStoreMode::File,
|
|
)?;
|
|
write_plugin_marketplace(
|
|
repo_root.path(),
|
|
"debug",
|
|
"sample-plugin",
|
|
"./sample-plugin",
|
|
/*install_policy*/ None,
|
|
/*auth_policy*/ None,
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
|
|
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 mut mcp = TestAppServer::new(codex_home.path()).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path: Some(marketplace_path),
|
|
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("Codex plugins are disabled for this workspace")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> 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_install_request(PluginInstallParams {
|
|
marketplace_path: Some(AbsolutePathBuf::try_from(
|
|
codex_home.path().join("missing-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("marketplace file"));
|
|
assert!(err.error.message.contains("does not exist"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
write_plugin_marketplace(
|
|
repo_root.path(),
|
|
"debug",
|
|
"sample-plugin",
|
|
"./sample-plugin",
|
|
Some("NOT_AVAILABLE"),
|
|
/*auth_policy*/ None,
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
|
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_install_request(PluginInstallParams {
|
|
marketplace_path: Some(marketplace_path),
|
|
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("not available for install"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let repo_root = TempDir::new()?;
|
|
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
|
std::fs::write(
|
|
repo_root.path().join(".agents/plugins/marketplace.json"),
|
|
r#"{
|
|
"name": "debug",
|
|
"plugins": [
|
|
{
|
|
"name": "sample-plugin",
|
|
"source": {
|
|
"source": "local",
|
|
"path": "./sample-plugin"
|
|
},
|
|
"policy": {
|
|
"products": ["CHATGPT"]
|
|
}
|
|
}
|
|
]
|
|
}"#,
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
|
let marketplace_path =
|
|
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
|
|
|
let mut mcp =
|
|
TestAppServer::new_with_args(codex_home.path(), &["--session-source", "atlas"]).await?;
|
|
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path: Some(marketplace_path),
|
|
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("not available for install"));
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_tracks_analytics_event() -> Result<()> {
|
|
let analytics_server = start_analytics_events_server().await?;
|
|
let codex_home = TempDir::new()?;
|
|
write_analytics_config(codex_home.path(), &analytics_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 repo_root = TempDir::new()?;
|
|
write_plugin_marketplace(
|
|
repo_root.path(),
|
|
"debug",
|
|
"sample-plugin",
|
|
"./sample-plugin",
|
|
/*install_policy*/ None,
|
|
/*auth_policy*/ None,
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
|
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_install_request(PluginInstallParams {
|
|
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: PluginInstallResponse = to_response(response)?;
|
|
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
|
|
|
let payload = wait_for_plugin_analytics_payload(&analytics_server).await?;
|
|
assert_eq!(
|
|
payload,
|
|
json!({
|
|
"events": [{
|
|
"event_type": "codex_plugin_installed",
|
|
"event_params": {
|
|
"plugin_id": "sample-plugin@debug",
|
|
"plugin_name": "sample-plugin",
|
|
"marketplace_name": "debug",
|
|
"has_skills": false,
|
|
"mcp_server_count": 0,
|
|
"connector_ids": [],
|
|
"product_client_id": DEFAULT_CLIENT_NAME,
|
|
}
|
|
}]
|
|
})
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
let bundle_url = mount_remote_plugin_bundle(
|
|
&server,
|
|
/*status_code*/ 200,
|
|
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
|
)
|
|
.await;
|
|
configure_remote_plugin_test(codex_home.path(), &server)?;
|
|
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
|
|
mount_empty_remote_installed_plugins(&server).await;
|
|
mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await;
|
|
mount_backend_analytics_events(&server).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 request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
let response: PluginInstallResponse = to_response(response)?;
|
|
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
|
|
|
let payload = wait_for_plugin_analytics_payload(&server).await?;
|
|
assert_eq!(
|
|
payload,
|
|
json!({
|
|
"events": [{
|
|
"event_type": "codex_plugin_installed",
|
|
"event_params": {
|
|
"plugin_id": REMOTE_PLUGIN_ID,
|
|
"plugin_name": "linear",
|
|
"marketplace_name": "openai-curated-remote",
|
|
"has_skills": true,
|
|
"mcp_server_count": 0,
|
|
"connector_ids": [],
|
|
"product_client_id": DEFAULT_CLIENT_NAME,
|
|
}
|
|
}]
|
|
})
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
let server = MockServer::start().await;
|
|
let bundle_url = mount_remote_plugin_bundle(
|
|
&server,
|
|
/*status_code*/ 503,
|
|
b"bundle temporarily unavailable".to_vec(),
|
|
)
|
|
.await;
|
|
configure_remote_plugin_test(codex_home.path(), &server)?;
|
|
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
|
|
mount_empty_remote_installed_plugins(&server).await;
|
|
mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).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 request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?;
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32603);
|
|
assert!(err.error.message.contains("failed with status 503"));
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"GET",
|
|
"/bundles/linear.tar.gz",
|
|
/*expected_count*/ 1,
|
|
)
|
|
.await?;
|
|
wait_for_remote_plugin_request_count(
|
|
&server,
|
|
"POST",
|
|
&format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"),
|
|
/*expected_count*/ 0,
|
|
)
|
|
.await?;
|
|
assert!(
|
|
!codex_home
|
|
.path()
|
|
.join("plugins/cache/openai-curated-remote/linear")
|
|
.exists()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_returns_apps_needing_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, server_control) = 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",
|
|
/*install_policy*/ None,
|
|
/*auth_policy*/ None,
|
|
)?;
|
|
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 directory_requests_before_install = server_control.directory_request_count();
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
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: PluginInstallResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginInstallResponse {
|
|
auth_policy: PluginAuthPolicy::OnInstall,
|
|
apps_needing_auth: vec![AppSummary {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
needs_auth: true,
|
|
}],
|
|
}
|
|
);
|
|
assert!(server_control.directory_request_count() > directory_requests_before_install);
|
|
|
|
server_handle.abort();
|
|
let _ = server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_filters_disallowed_apps_needing_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(),
|
|
}];
|
|
let (server_url, server_handle, server_control) =
|
|
start_apps_server(connectors, Vec::new()).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",
|
|
/*install_policy*/ None,
|
|
Some("ON_USE"),
|
|
)?;
|
|
write_plugin_source(
|
|
repo_root.path(),
|
|
"sample-plugin",
|
|
&["alpha", "asdk_app_6938a94a61d881918ef32cb999ff937c"],
|
|
)?;
|
|
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 directory_requests_before_install =
|
|
warm_app_directory_cache(&mut mcp, &server_control, "Alpha").await?;
|
|
|
|
let request_id = mcp
|
|
.send_plugin_install_request(PluginInstallParams {
|
|
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: PluginInstallResponse = to_response(response)?;
|
|
|
|
assert_eq!(
|
|
response,
|
|
PluginInstallResponse {
|
|
auth_policy: PluginAuthPolicy::OnUse,
|
|
apps_needing_auth: vec![AppSummary {
|
|
id: "alpha".to_string(),
|
|
name: "Alpha".to_string(),
|
|
description: Some("Alpha connector".to_string()),
|
|
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
|
needs_auth: true,
|
|
}],
|
|
}
|
|
);
|
|
assert_eq!(
|
|
server_control.directory_request_count(),
|
|
directory_requests_before_install
|
|
);
|
|
|
|
server_handle.abort();
|
|
let _ = server_handle.await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests() -> Result<()> {
|
|
let codex_home = TempDir::new()?;
|
|
std::fs::write(
|
|
codex_home.path().join("config.toml"),
|
|
"[features]\nplugins = true\n",
|
|
)?;
|
|
let repo_root = TempDir::new()?;
|
|
write_plugin_marketplace(
|
|
repo_root.path(),
|
|
"debug",
|
|
"sample-plugin",
|
|
"./sample-plugin",
|
|
/*install_policy*/ None,
|
|
/*auth_policy*/ None,
|
|
)?;
|
|
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
|
std::fs::write(
|
|
repo_root.path().join("sample-plugin/.mcp.json"),
|
|
r#"{
|
|
"mcpServers": {
|
|
"sample-mcp": {
|
|
"command": "echo"
|
|
}
|
|
}
|
|
}"#,
|
|
)?;
|
|
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_install_request(PluginInstallParams {
|
|
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: PluginInstallResponse = to_response(response)?;
|
|
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
|
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
|
assert!(!config.contains("[mcp_servers.sample-mcp]"));
|
|
assert!(!config.contains("command = \"echo\""));
|
|
|
|
let request_id = mcp
|
|
.send_raw_request(
|
|
"mcpServer/oauth/login",
|
|
Some(json!({
|
|
"name": "sample-mcp",
|
|
})),
|
|
)
|
|
.await?;
|
|
let err = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
|
)
|
|
.await??;
|
|
|
|
assert_eq!(err.error.code, -32600);
|
|
assert_eq!(
|
|
err.error.message,
|
|
"OAuth login is only supported for streamable HTTP servers."
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct AppsServerState {
|
|
response: Arc<StdMutex<serde_json::Value>>,
|
|
directory_request_count: Arc<AtomicUsize>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct AppsServerControl {
|
|
directory_request_count: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl AppsServerControl {
|
|
fn directory_request_count(&self) -> usize {
|
|
self.directory_request_count.load(Ordering::SeqCst)
|
|
}
|
|
}
|
|
|
|
async fn warm_app_directory_cache(
|
|
mcp: &mut TestAppServer,
|
|
server_control: &AppsServerControl,
|
|
expected_app_name: &str,
|
|
) -> Result<usize> {
|
|
let app_list_request_id = mcp
|
|
.send_apps_list_request(AppsListParams {
|
|
force_refetch: true,
|
|
..Default::default()
|
|
})
|
|
.await?;
|
|
let response: JSONRPCResponse = timeout(
|
|
DEFAULT_TIMEOUT,
|
|
mcp.read_stream_until_response_message(RequestId::Integer(app_list_request_id)),
|
|
)
|
|
.await??;
|
|
let response: AppsListResponse = to_response(response)?;
|
|
assert!(
|
|
response
|
|
.data
|
|
.iter()
|
|
.any(|app| app.name == expected_app_name)
|
|
);
|
|
let directory_request_count = server_control.directory_request_count();
|
|
assert!(directory_request_count > 0);
|
|
Ok(directory_request_count)
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct PluginInstallMcpServer {
|
|
tools: Arc<StdMutex<Vec<Tool>>>,
|
|
}
|
|
|
|
impl ServerHandler for PluginInstallMcpServer {
|
|
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<()>, AppsServerControl)> {
|
|
let directory_request_count = Arc::new(AtomicUsize::new(0));
|
|
let state = Arc::new(AppsServerState {
|
|
response: Arc::new(StdMutex::new(
|
|
json!({ "apps": connectors, "next_token": null }),
|
|
)),
|
|
directory_request_count: directory_request_count.clone(),
|
|
});
|
|
let server_control = AppsServerControl {
|
|
directory_request_count,
|
|
};
|
|
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(PluginInstallMcpServer {
|
|
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, server_control))
|
|
}
|
|
|
|
async fn list_directory_connectors(
|
|
State(state): State<Arc<AppsServerState>>,
|
|
headers: HeaderMap,
|
|
uri: Uri,
|
|
) -> Result<impl axum::response::IntoResponse, StatusCode> {
|
|
state.directory_request_count.fetch_add(1, Ordering::SeqCst);
|
|
|
|
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]
|
|
connectors = 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
|
|
"#,
|
|
),
|
|
)
|
|
}
|
|
|
|
fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!("chatgpt_base_url = \"{base_url}\"\n"),
|
|
)
|
|
}
|
|
|
|
async fn mount_backend_analytics_events(server: &MockServer) {
|
|
Mock::given(method("POST"))
|
|
.and(path("/backend-api/codex/analytics-events/events"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"status":"ok"}"#))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn wait_for_plugin_analytics_payload(server: &MockServer) -> Result<serde_json::Value> {
|
|
timeout(DEFAULT_TIMEOUT, async {
|
|
loop {
|
|
let Some(requests) = server.received_requests().await else {
|
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
continue;
|
|
};
|
|
if let Some(request) = requests.iter().find(|request| {
|
|
request.method == "POST"
|
|
&& request
|
|
.url
|
|
.path()
|
|
.ends_with("/codex/analytics-events/events")
|
|
}) {
|
|
return serde_json::from_slice(&request.body)
|
|
.map_err(|err| anyhow::anyhow!("invalid analytics payload: {err}"));
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(25)).await;
|
|
}
|
|
})
|
|
.await?
|
|
}
|
|
|
|
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 configure_remote_plugin_test(codex_home: &std::path::Path, server: &MockServer) -> Result<()> {
|
|
write_remote_plugin_catalog_config(codex_home, &format!("{}/backend-api/", server.uri()))?;
|
|
write_chatgpt_auth(
|
|
codex_home,
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)
|
|
}
|
|
|
|
fn configure_remote_plugin_with_apps_test(
|
|
codex_home: &std::path::Path,
|
|
server: &MockServer,
|
|
) -> Result<()> {
|
|
std::fs::write(
|
|
codex_home.join("config.toml"),
|
|
format!(
|
|
r#"
|
|
chatgpt_base_url = "{}/backend-api/"
|
|
|
|
[features]
|
|
plugins = true
|
|
remote_plugin = true
|
|
connectors = true
|
|
"#,
|
|
server.uri()
|
|
),
|
|
)?;
|
|
write_chatgpt_auth(
|
|
codex_home,
|
|
ChatGptAuthFixture::new("chatgpt-token")
|
|
.account_id("account-123")
|
|
.chatgpt_user_id("user-123")
|
|
.chatgpt_account_id("account-123"),
|
|
AuthCredentialsStoreMode::File,
|
|
)
|
|
}
|
|
|
|
async fn mount_remote_plugin_bundle(
|
|
server: &MockServer,
|
|
status_code: u16,
|
|
body: Vec<u8>,
|
|
) -> String {
|
|
Mock::given(method("GET"))
|
|
.and(path("/bundles/linear.tar.gz"))
|
|
.respond_with(
|
|
ResponseTemplate::new(status_code)
|
|
.insert_header("content-type", "application/gzip")
|
|
.set_body_bytes(body),
|
|
)
|
|
.mount(server)
|
|
.await;
|
|
format!("{}/bundles/linear.tar.gz", server.uri())
|
|
}
|
|
|
|
async fn mount_remote_plugin_detail(
|
|
server: &MockServer,
|
|
remote_plugin_id: &str,
|
|
release_version: &str,
|
|
bundle_download_url: Option<&str>,
|
|
) {
|
|
mount_remote_plugin_detail_with_status(
|
|
server,
|
|
remote_plugin_id,
|
|
release_version,
|
|
bundle_download_url,
|
|
PluginAvailability::Available,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_remote_plugin_detail_with_app_manifest(
|
|
server: &MockServer,
|
|
remote_plugin_id: &str,
|
|
release_version: &str,
|
|
bundle_download_url: Option<&str>,
|
|
app_manifest: serde_json::Value,
|
|
) {
|
|
mount_remote_plugin_detail_with_status_and_app_manifest(
|
|
server,
|
|
remote_plugin_id,
|
|
release_version,
|
|
bundle_download_url,
|
|
PluginAvailability::Available,
|
|
Some(app_manifest),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_remote_plugin_detail_with_status(
|
|
server: &MockServer,
|
|
remote_plugin_id: &str,
|
|
release_version: &str,
|
|
bundle_download_url: Option<&str>,
|
|
status: PluginAvailability,
|
|
) {
|
|
mount_remote_plugin_detail_with_status_and_app_manifest(
|
|
server,
|
|
remote_plugin_id,
|
|
release_version,
|
|
bundle_download_url,
|
|
status,
|
|
/*app_manifest*/ None,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_remote_plugin_detail_with_status_and_app_manifest(
|
|
server: &MockServer,
|
|
remote_plugin_id: &str,
|
|
release_version: &str,
|
|
bundle_download_url: Option<&str>,
|
|
status: PluginAvailability,
|
|
app_manifest: Option<serde_json::Value>,
|
|
) {
|
|
let status = match status {
|
|
PluginAvailability::Available => "ENABLED",
|
|
PluginAvailability::DisabledByAdmin => "DISABLED_BY_ADMIN",
|
|
};
|
|
let bundle_download_url_field = bundle_download_url
|
|
.map(|url| format!(r#" "bundle_download_url": "{url}","#))
|
|
.unwrap_or_default();
|
|
let app_manifest_field = app_manifest
|
|
.map(|manifest| format!(r#" "app_manifest": {manifest},"#))
|
|
.unwrap_or_default();
|
|
let detail_body = format!(
|
|
r#"{{
|
|
"id": "{remote_plugin_id}",
|
|
"name": "linear",
|
|
"scope": "GLOBAL",
|
|
"installation_policy": "AVAILABLE",
|
|
"authentication_policy": "ON_USE",
|
|
"status": "{status}",
|
|
"release": {{
|
|
"version": "{release_version}",
|
|
{bundle_download_url_field}
|
|
"display_name": "Linear",
|
|
"description": "Track work in Linear",
|
|
"app_ids": [],
|
|
{app_manifest_field}
|
|
"interface": {{
|
|
"short_description": "Plan and track work"
|
|
}},
|
|
"skills": []
|
|
}}
|
|
}}"#
|
|
);
|
|
|
|
Mock::given(method("GET"))
|
|
.and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}")))
|
|
.and(query_param("includeDownloadUrls", "true"))
|
|
.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;
|
|
}
|
|
|
|
async fn mount_empty_remote_installed_plugins(server: &MockServer) {
|
|
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(
|
|
r#"{
|
|
"plugins": [],
|
|
"pagination": {
|
|
"limit": 50,
|
|
"next_page_token": null
|
|
}
|
|
}"#,
|
|
))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str) {
|
|
Mock::given(method("POST"))
|
|
.and(path(format!(
|
|
"/backend-api/ps/plugins/{remote_plugin_id}/install"
|
|
)))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_string(format!(r#"{{"id":"{remote_plugin_id}","enabled":true}}"#)),
|
|
)
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn mount_remote_plugin_install_with_apps_needing_auth(
|
|
server: &MockServer,
|
|
remote_plugin_id: &str,
|
|
app_ids_needing_auth: &[&str],
|
|
) {
|
|
Mock::given(method("POST"))
|
|
.and(path(format!(
|
|
"/backend-api/ps/plugins/{remote_plugin_id}/install"
|
|
)))
|
|
.and(query_param("includeAppsNeedingAuth", "true"))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
|
"id": remote_plugin_id,
|
|
"enabled": true,
|
|
"app_ids_needing_auth": app_ids_needing_auth,
|
|
})))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct CacheManifestExists {
|
|
manifest_path: std::path::PathBuf,
|
|
}
|
|
|
|
impl Match for CacheManifestExists {
|
|
fn matches(&self, _request: &Request) -> bool {
|
|
self.manifest_path.is_file()
|
|
}
|
|
}
|
|
|
|
async fn mount_remote_plugin_install_after_cache_write(
|
|
server: &MockServer,
|
|
remote_plugin_id: &str,
|
|
manifest_path: std::path::PathBuf,
|
|
) {
|
|
Mock::given(method("POST"))
|
|
.and(path(format!(
|
|
"/backend-api/ps/plugins/{remote_plugin_id}/install"
|
|
)))
|
|
.and(header("authorization", "Bearer chatgpt-token"))
|
|
.and(header("chatgpt-account-id", "account-123"))
|
|
.and(CacheManifestExists { manifest_path })
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_string(format!(r#"{{"id":"{remote_plugin_id}","enabled":true}}"#)),
|
|
)
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn send_remote_plugin_install_request(
|
|
mcp: &mut TestAppServer,
|
|
remote_plugin_id: &str,
|
|
) -> Result<i64> {
|
|
mcp.send_plugin_install_request(PluginInstallParams {
|
|
marketplace_path: None,
|
|
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
|
|
plugin_name: remote_plugin_id.to_string(),
|
|
})
|
|
.await
|
|
}
|
|
|
|
async fn wait_for_remote_plugin_request_count(
|
|
server: &MockServer,
|
|
method_name: &str,
|
|
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 == method_name && 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} {method_name} {path_suffix} requests, got {request_count}"
|
|
);
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
|
}
|
|
})
|
|
.await??;
|
|
Ok(())
|
|
}
|
|
|
|
fn write_plugin_marketplace(
|
|
repo_root: &std::path::Path,
|
|
marketplace_name: &str,
|
|
plugin_name: &str,
|
|
source_path: &str,
|
|
install_policy: Option<&str>,
|
|
auth_policy: Option<&str>,
|
|
) -> std::io::Result<()> {
|
|
let policy = if install_policy.is_some() || auth_policy.is_some() {
|
|
let installation = install_policy
|
|
.map(|installation| format!("\n \"installation\": \"{installation}\""))
|
|
.unwrap_or_default();
|
|
let separator = if install_policy.is_some() && auth_policy.is_some() {
|
|
","
|
|
} else {
|
|
""
|
|
};
|
|
let authentication = auth_policy
|
|
.map(|authentication| {
|
|
format!("{separator}\n \"authentication\": \"{authentication}\"")
|
|
})
|
|
.unwrap_or_default();
|
|
format!(",\n \"policy\": {{{installation}{authentication}\n }}")
|
|
} else {
|
|
String::new()
|
|
};
|
|
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}"
|
|
}}{policy}
|
|
}}
|
|
]
|
|
}}"#
|
|
),
|
|
)
|
|
}
|
|
|
|
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 remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result<Vec<u8>> {
|
|
let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);
|
|
remote_plugin_bundle_tar_gz_bytes_with_contents(&manifest, /*app_manifest*/ None)
|
|
}
|
|
|
|
fn remote_plugin_bundle_tar_gz_bytes_with_contents(
|
|
plugin_manifest: &str,
|
|
app_manifest: Option<&str>,
|
|
) -> Result<Vec<u8>> {
|
|
let skill = "# Plan Work\n\nTrack work in Linear.\n";
|
|
let encoder = GzEncoder::new(Vec::new(), Compression::default());
|
|
let mut tar = tar::Builder::new(encoder);
|
|
let mut entries = vec![
|
|
(
|
|
".codex-plugin/plugin.json",
|
|
plugin_manifest.as_bytes(),
|
|
/*mode*/ 0o644,
|
|
),
|
|
(
|
|
"skills/plan-work/SKILL.md",
|
|
skill.as_bytes(),
|
|
/*mode*/ 0o644,
|
|
),
|
|
];
|
|
if let Some(app_manifest) = app_manifest {
|
|
entries.push((".app.json", app_manifest.as_bytes(), /*mode*/ 0o644));
|
|
}
|
|
for (path, contents, mode) in entries {
|
|
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()?)
|
|
}
|