feat: Add remote plugin fields to plugin API (#17277)

## Summary
Update the plugin API for the new remote plugin model.

The mental model is no longer “keep local plugin state in sync with
remote.” Instead, local and remote plugins are becoming separate
sources. Remote catalog entries can be shown directly from the remote
API before installation; after installation they are still downloaded
into the local cache for execution, but remote installed state will come
from the API and be held in memory rather than being read from config.

• ## API changes
- Remove `forceRemoteSync` from `plugin/list`, `plugin/install`, and
`plugin/uninstall`.
  - Remove `remoteSyncError` from `plugin/list`.
  - Add remote-capable metadata to `plugin/list` / `plugin/read`:
    - nullable `marketplaces[].path`
    - `source: { type: "remote", downloadUrl }`
    - URL asset fields alongside local path fields:
  `composerIconUrl`, `logoUrl`, `screenshotUrls`
  - Make `plugin/read` and `plugin/install` source-compatible:
    - `marketplacePath?: AbsolutePathBuf | null`
    - `remoteMarketplaceName?: string | null`
    - exactly one source is required at runtime
This commit is contained in:
xl-openai
2026-04-17 16:47:58 -07:00
committed by GitHub
parent 120bbf46c1
commit 26d9894a27
31 changed files with 919 additions and 672 deletions

View File

@@ -45,6 +45,96 @@ use tokio::time::timeout;
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 = McpProcess::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 = McpProcess::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".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_rejects_remote_marketplace_until_remote_read_is_supported() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("openai-curated".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("remote plugin read is not supported yet")
);
assert!(err.error.message.contains("openai-curated"));
Ok(())
}
#[tokio::test]
async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -179,7 +269,8 @@ enabled = true
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: marketplace_path.clone(),
marketplace_path: Some(marketplace_path.clone()),
remote_marketplace_name: None,
plugin_name: "demo-plugin".to_string(),
})
.await?;
@@ -326,7 +417,8 @@ async fn plugin_read_returns_app_needs_auth() -> Result<()> {
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path,
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -392,9 +484,10 @@ async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> {
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: AbsolutePathBuf::try_from(
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?;
@@ -446,9 +539,10 @@ async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: AbsolutePathBuf::try_from(
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?;
@@ -498,9 +592,10 @@ async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() -
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: AbsolutePathBuf::try_from(
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?;