feat: Add workspace plugin sharing APIs (#20278)

1. Adds v2 plugin/share/save, plugin/share/list, and plugin/share/delete
RPCs.
2. Implements save by archiving a local plugin root, enforcing a size
limit, uploading through the workspace upload flow, and supporting
updates via remotePluginId.
3. Lists created workspace plugins
4. Deletes a previously uploaded/shared plugin.
This commit is contained in:
xl-openai
2026-04-29 23:49:20 -07:00
committed by GitHub
parent ae863e72a2
commit 87d0cf1a62
29 changed files with 2402 additions and 108 deletions

View File

@@ -33,6 +33,7 @@ mod plan_item;
mod plugin_install;
mod plugin_list;
mod plugin_read;
mod plugin_share;
mod plugin_uninstall;
mod rate_limits;
mod realtime_conversation;

View File

@@ -177,7 +177,6 @@ async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disable
.message
.contains("remote plugin install is not enabled")
);
assert!(err.error.message.contains("chatgpt-global"));
Ok(())
}
@@ -405,11 +404,6 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> {
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(())
}
@@ -1314,7 +1308,7 @@ async fn send_remote_plugin_install_request(
) -> Result<i64> {
mcp.send_plugin_install_request(PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
plugin_name: remote_plugin_id.to_string(),
})
.await

View File

@@ -139,7 +139,6 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled()
.message
.contains("remote plugin read is not enabled")
);
assert!(err.error.message.contains("chatgpt-global"));
Ok(())
}
@@ -253,7 +252,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
})
.await?;
@@ -413,11 +412,6 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {
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(())
}

View File

@@ -0,0 +1,326 @@
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::PluginShareDeleteResponse;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
#[tokio::test]
async fn plugin_share_save_uploads_local_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
let plugin_root = TempDir::new()?;
let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?;
let server = MockServer::start().await;
write_remote_plugin_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("POST"))
.and(path("/backend-api/public/plugins/workspace/upload-url"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"file_id": "file_123",
"upload_url": format!("{}/upload/file_123", server.uri()),
"etag": "\"upload_etag_123\"",
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/upload/file_123"))
.and(header("x-ms-blob-type", "BlockBlob"))
.and(header("content-type", "application/gzip"))
.respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\""))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.and(body_json(json!({
"file_id": "file_123",
"etag": "\"upload_etag_123\"",
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"plugin_id": "plugins_123",
"share_url": "https://chatgpt.example/plugins/share/share-key-1",
})))
.expect(1)
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/share/save",
Some(json!({
"pluginPath": AbsolutePathBuf::try_from(plugin_path)?,
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareSaveResponse = to_response(response)?;
assert_eq!(
response,
PluginShareSaveResponse {
remote_plugin_id: "plugins_123".to_string(),
share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(),
}
);
Ok(())
}
#[tokio::test]
async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_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/workspace/created"))
.and(query_param("limit", "200"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [remote_plugin_json("plugins_123")],
"pagination": empty_pagination_json(),
})))
.expect(1)
.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_json(json!({
"plugins": [installed_remote_plugin_json("plugins_123")],
"pagination": empty_pagination_json(),
})))
.expect(1)
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request("plugin/share/list", Some(json!({})))
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareListResponse = to_response(response)?;
assert_eq!(
response,
PluginShareListResponse {
data: vec![PluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
source: PluginSource::Remote,
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
interface: Some(expected_plugin_interface()),
}],
}
);
Ok(())
}
#[tokio::test]
async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_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("DELETE"))
.and(path("/backend-api/public/plugins/workspace/plugins_123"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_raw_request(
"plugin/share/delete",
Some(json!({
"remotePluginId": "plugins_123",
})),
)
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginShareDeleteResponse = to_response(response)?;
assert_eq!(response, PluginShareDeleteResponse {});
Ok(())
}
fn write_remote_plugin_config(codex_home: &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 remote_plugin_json(plugin_id: &str) -> serde_json::Value {
json!({
"id": plugin_id,
"name": "demo-plugin",
"scope": "WORKSPACE",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Demo Plugin",
"description": "Demo plugin description",
"interface": {
"short_description": "A demo plugin",
"capabilities": ["Read", "Write"]
},
"skills": []
}
})
}
fn installed_remote_plugin_json(plugin_id: &str) -> serde_json::Value {
let mut plugin = remote_plugin_json(plugin_id);
let serde_json::Value::Object(fields) = &mut plugin else {
unreachable!("plugin json should be an object");
};
fields.insert("enabled".to_string(), json!(true));
fields.insert("disabled_skill_names".to_string(), json!([]));
plugin
}
fn empty_pagination_json() -> serde_json::Value {
json!({
"next_page_token": null
})
}
fn expected_plugin_interface() -> PluginInterface {
PluginInterface {
display_name: Some("Demo Plugin".to_string()),
short_description: Some("A demo plugin".to_string()),
long_description: None,
developer_name: None,
category: None,
capabilities: vec!["Read".to_string(), "Write".to_string()],
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: None,
composer_icon: None,
composer_icon_url: None,
logo: None,
logo_url: None,
screenshots: Vec::new(),
screenshot_urls: Vec::new(),
}
}
fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result<PathBuf> {
let plugin_path = root.join(plugin_name);
write_file(
&plugin_path.join(".codex-plugin/plugin.json"),
&format!(r#"{{"name":"{plugin_name}"}}"#),
)?;
write_file(
&plugin_path.join("skills/example/SKILL.md"),
"# Example\n\nA test skill.\n",
)?;
Ok(plugin_path)
}
fn write_file(path: &Path, contents: &str) -> std::io::Result<()> {
let Some(parent) = path.parent() else {
return Err(std::io::Error::other(format!(
"file path `{}` should have a parent",
path.display()
)));
};
std::fs::create_dir_all(parent)?;
std::fs::write(path, contents)
}

View File

@@ -26,6 +26,7 @@ use wiremock::matchers::path;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_linear";
const WORKSPACE_REMOTE_PLUGIN_ID: &str = "plugins_69f27c3e67848191a45cbaa5f2adb39d";
#[tokio::test]
async fn plugin_uninstall_removes_plugin_cache_and_config_entry() -> Result<()> {
@@ -323,6 +324,79 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()>
Ok(())
}
#[tokio::test]
async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> 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,
)?;
mount_remote_plugin_detail_with_name(
&server,
WORKSPACE_REMOTE_PLUGIN_ID,
"skill-improver",
"1.0.0",
"WORKSPACE",
)
.await;
Mock::given(method("POST"))
.and(path(format!(
"/backend-api/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"
)))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
r#"{{"id":"{WORKSPACE_REMOTE_PLUGIN_ID}","enabled":false}}"#
)))
.mount(&server)
.await;
let remote_plugin_cache_root = codex_home
.path()
.join("plugins/cache/chatgpt-workspace/skill-improver");
std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?;
std::fs::write(
remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"),
r#"{"name":"skill-improver","version":"1.0.0"}"#,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_uninstall_request(PluginUninstallParams {
plugin_id: WORKSPACE_REMOTE_PLUGIN_ID.to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginUninstallResponse = to_response(response)?;
assert_eq!(response, PluginUninstallResponse {});
wait_for_remote_plugin_request_count(
&server,
"POST",
&format!("/plugins/{WORKSPACE_REMOTE_PLUGIN_ID}/uninstall"),
/*expected_count*/ 1,
)
.await?;
assert!(!remote_plugin_cache_root.exists());
Ok(())
}
#[tokio::test]
async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -380,7 +454,7 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -
}
#[tokio::test]
async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path() -> Result<()> {
async fn plugin_uninstall_rejects_invalid_plugin_id_before_remote_path() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
@@ -392,7 +466,7 @@ async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path()
let request_id = mcp
.send_plugin_uninstall_request(PluginUninstallParams {
plugin_id: "sample-plugin".to_string(),
plugin_id: "sample plugin".to_string(),
})
.await?;
@@ -407,7 +481,7 @@ async fn plugin_uninstall_rejects_malformed_local_plugin_id_before_remote_path()
wait_for_remote_plugin_request_count(
&server,
"POST",
"/plugins/sample-plugin/uninstall",
"/plugins/sample plugin/uninstall",
/*expected_count*/ 0,
)
.await?;
@@ -519,11 +593,28 @@ async fn mount_remote_plugin_detail(
remote_plugin_id: &str,
release_version: &str,
scope: &str,
) {
mount_remote_plugin_detail_with_name(
server,
remote_plugin_id,
"linear",
release_version,
scope,
)
.await;
}
async fn mount_remote_plugin_detail_with_name(
server: &MockServer,
remote_plugin_id: &str,
plugin_name: &str,
release_version: &str,
scope: &str,
) {
let detail_body = format!(
r#"{{
"id": "{remote_plugin_id}",
"name": "linear",
"name": "{plugin_name}",
"scope": "{scope}",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",