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
Unverified
parent ae863e72a2
commit 87d0cf1a62
29 changed files with 2402 additions and 108 deletions
+41 -49
View File
@@ -16,6 +16,13 @@ use std::fs;
use std::path::PathBuf;
use std::time::Duration;
mod share;
pub use share::RemotePluginShareSaveResult;
pub use share::delete_remote_plugin_share;
pub use share::list_remote_plugin_shares;
pub use share::save_remote_plugin_share;
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global";
pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace";
pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins";
@@ -111,18 +118,6 @@ pub enum RemotePluginCatalogError {
source: serde_json::Error,
},
#[error("remote marketplace `{marketplace_name}` is not supported")]
UnknownMarketplace { marketplace_name: String },
#[error(
"remote plugin `{plugin_id}` belongs to marketplace `{actual_marketplace_name}`, not `{expected_marketplace_name}`"
)]
MarketplaceMismatch {
plugin_id: String,
expected_marketplace_name: String,
actual_marketplace_name: String,
},
#[error(
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
)]
@@ -137,6 +132,30 @@ pub enum RemotePluginCatalogError {
actual_enabled: bool,
},
#[error("invalid plugin path `{path}`: {reason}")]
InvalidPluginPath { path: PathBuf, reason: String },
#[error("failed to archive plugin at `{path}`: {source}")]
Archive {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to join plugin archive task: {0}")]
ArchiveJoin(#[source] tokio::task::JoinError),
#[error(
"plugin archive would be {bytes} bytes, exceeding the maximum upload size of {max_bytes} bytes"
)]
ArchiveTooLarge { bytes: usize, max_bytes: usize },
#[error("workspace plugin upload response did not include an etag")]
MissingUploadEtag,
#[error("{0}")]
UnexpectedResponse(String),
#[error("{0}")]
CacheRemove(String),
}
@@ -174,14 +193,6 @@ impl RemotePluginScope {
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME,
}
}
fn from_marketplace_name(name: &str) -> Option<Self> {
match name {
REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global),
REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
@@ -443,35 +454,19 @@ pub async fn fetch_remote_plugin_detail_with_download_urls(
async fn fetch_remote_plugin_detail_with_download_url_option(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
marketplace_name: &str,
_marketplace_name: &str,
plugin_id: &str,
include_download_urls: bool,
) -> Result<RemotePluginDetail, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let scope = RemotePluginScope::from_marketplace_name(marketplace_name).ok_or_else(|| {
RemotePluginCatalogError::UnknownMarketplace {
marketplace_name: marketplace_name.to_string(),
}
})?;
let plugin = fetch_plugin_detail(config, auth, plugin_id, include_download_urls).await?;
let actual_marketplace_name = plugin.scope.marketplace_name();
if actual_marketplace_name != marketplace_name {
return Err(RemotePluginCatalogError::MarketplaceMismatch {
plugin_id: plugin_id.to_string(),
expected_marketplace_name: marketplace_name.to_string(),
actual_marketplace_name: actual_marketplace_name.to_string(),
});
}
let scope = plugin.scope;
let marketplace_name = scope.marketplace_name().to_string();
// Remote plugin IDs uniquely identify remote plugins, so the caller-provided
// marketplace name is not validated here. The backend detail response is the
// source of truth for the plugin's actual scope/marketplace.
build_remote_plugin_detail(
config,
auth,
scope,
marketplace_name.to_string(),
plugin_id,
plugin,
)
.await
build_remote_plugin_detail(config, auth, scope, marketplace_name, plugin_id, plugin).await
}
async fn build_remote_plugin_detail(
@@ -527,15 +522,12 @@ async fn build_remote_plugin_detail(
pub async fn install_remote_plugin(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
marketplace_name: &str,
_marketplace_name: &str,
plugin_id: &str,
) -> Result<(), RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
if RemotePluginScope::from_marketplace_name(marketplace_name).is_none() {
return Err(RemotePluginCatalogError::UnknownMarketplace {
marketplace_name: marketplace_name.to_string(),
});
}
// Remote plugin IDs uniquely identify remote plugins, so the caller-provided
// marketplace name is not validated before sending the install mutation.
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/ps/plugins/{plugin_id}/install");
+418
View File
@@ -0,0 +1,418 @@
use super::*;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use flate2::Compression;
use flate2::write::GzEncoder;
use reqwest::RequestBuilder;
use reqwest::StatusCode;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
use std::fmt;
use std::fs;
use std::io;
use std::io::Write;
use std::path::Path;
const REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES: usize = 50 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginShareSaveResult {
pub remote_plugin_id: String,
pub share_url: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct RemoteWorkspacePluginUploadUrlRequest<'a> {
filename: &'a str,
mime_type: &'a str,
size_bytes: usize,
#[serde(skip_serializing_if = "Option::is_none")]
plugin_id: Option<&'a str>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemoteWorkspacePluginUploadUrlResponse {
file_id: String,
upload_url: String,
etag: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct RemoteWorkspacePluginCreateRequest {
file_id: String,
etag: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemoteWorkspacePluginCreateResponse {
plugin_id: String,
share_url: Option<String>,
}
pub async fn save_remote_plugin_share(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_path: &Path,
remote_plugin_id: Option<&str>,
) -> Result<RemotePluginShareSaveResult, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let plugin_path = plugin_path.to_path_buf();
let (filename, archive_bytes) = tokio::task::spawn_blocking(move || {
let filename = archive_filename(&plugin_path)?;
let archive_bytes = archive_plugin_for_upload(&plugin_path)?;
Ok::<_, RemotePluginCatalogError>((filename, archive_bytes))
})
.await
.map_err(RemotePluginCatalogError::ArchiveJoin)??;
let upload = create_workspace_plugin_upload(
config,
auth,
&filename,
archive_bytes.len(),
remote_plugin_id,
)
.await?;
let etag = upload
.etag
.ok_or(RemotePluginCatalogError::MissingUploadEtag)?;
put_workspace_plugin_upload(&upload.upload_url, archive_bytes).await?;
let response = finalize_workspace_plugin_upload(
config,
auth,
remote_plugin_id,
RemoteWorkspacePluginCreateRequest {
file_id: upload.file_id,
etag,
},
)
.await?;
if response.plugin_id.is_empty() {
return Err(RemotePluginCatalogError::UnexpectedResponse(
"workspace plugin create response did not include a plugin id".to_string(),
));
}
Ok(RemotePluginShareSaveResult {
remote_plugin_id: response.plugin_id,
share_url: response.share_url,
})
}
pub async fn list_remote_plugin_shares(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Vec<RemotePluginSummary>, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let created_plugins = fetch_created_workspace_plugins(config, auth).await?;
if created_plugins.is_empty() {
return Ok(Vec::new());
}
let installed_by_id =
fetch_installed_plugins_for_scope(config, auth, RemotePluginScope::Workspace)
.await?
.into_iter()
.map(|plugin| (plugin.plugin.id.clone(), plugin))
.collect::<BTreeMap<_, _>>();
Ok(created_plugins
.into_iter()
.map(|plugin| build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id)))
.collect())
}
pub async fn delete_remote_plugin_share(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
remote_plugin_id: &str,
) -> Result<(), RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/public/plugins/workspace/{remote_plugin_id}");
let client = build_reqwest_client();
let request = authenticated_request(client.delete(&url), auth)?;
send_and_expect_status(request, &url, &[StatusCode::NO_CONTENT]).await
}
async fn fetch_created_workspace_plugins(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
let mut plugins = Vec::new();
let mut page_token = None;
loop {
let response =
get_created_workspace_plugins_page(config, auth, page_token.as_deref()).await?;
plugins.extend(response.plugins);
let Some(next_page_token) = response.pagination.next_page_token else {
break;
};
page_token = Some(next_page_token);
}
Ok(plugins)
}
async fn get_created_workspace_plugins_page(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
page_token: Option<&str>,
) -> Result<RemotePluginListResponse, RemotePluginCatalogError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/ps/plugins/workspace/created");
let client = build_reqwest_client();
let mut request = authenticated_request(client.get(&url), auth)?;
request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]);
if let Some(page_token) = page_token {
request = request.query(&[("pageToken", page_token)]);
}
send_and_decode(request, &url).await
}
async fn create_workspace_plugin_upload(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
filename: &str,
size_bytes: usize,
remote_plugin_id: Option<&str>,
) -> Result<RemoteWorkspacePluginUploadUrlResponse, RemotePluginCatalogError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/public/plugins/workspace/upload-url");
let client = build_reqwest_client();
let request = authenticated_request(client.post(&url), auth)?.json(
&RemoteWorkspacePluginUploadUrlRequest {
filename,
mime_type: "application/gzip",
size_bytes,
plugin_id: remote_plugin_id,
},
);
send_and_decode(request, &url).await
}
async fn put_workspace_plugin_upload(
upload_url: &str,
archive_bytes: Vec<u8>,
) -> Result<(), RemotePluginCatalogError> {
let client = build_reqwest_client();
let request = client
.put(upload_url)
.timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT)
.header("x-ms-blob-type", "BlockBlob")
.header("Content-Type", "application/gzip")
.body(archive_bytes);
let response = request
.send()
.await
.map_err(|source| RemotePluginCatalogError::Request {
url: "workspace plugin upload URL".to_string(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if ![StatusCode::OK, StatusCode::CREATED].contains(&status) {
return Err(RemotePluginCatalogError::UnexpectedStatus {
url: "workspace plugin upload URL".to_string(),
status,
body,
});
}
Ok(())
}
async fn finalize_workspace_plugin_upload(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
remote_plugin_id: Option<&str>,
body: RemoteWorkspacePluginCreateRequest,
) -> Result<RemoteWorkspacePluginCreateResponse, RemotePluginCatalogError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = if let Some(remote_plugin_id) = remote_plugin_id {
format!("{base_url}/public/plugins/workspace/{remote_plugin_id}")
} else {
format!("{base_url}/public/plugins/workspace")
};
let client = build_reqwest_client();
let request = authenticated_request(client.post(&url), auth)?.json(&body);
send_and_decode(request, &url).await
}
fn archive_filename(plugin_path: &Path) -> Result<String, RemotePluginCatalogError> {
let plugin_name = plugin_path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| RemotePluginCatalogError::InvalidPluginPath {
path: plugin_path.to_path_buf(),
reason: "plugin path must end in a valid UTF-8 directory name".to_string(),
})?;
Ok(format!("{plugin_name}.tar.gz"))
}
fn archive_plugin_for_upload(plugin_path: &Path) -> Result<Vec<u8>, RemotePluginCatalogError> {
archive_plugin_for_upload_with_limit(plugin_path, REMOTE_PLUGIN_SHARE_MAX_ARCHIVE_BYTES)
}
fn archive_plugin_for_upload_with_limit(
plugin_path: &Path,
max_bytes: usize,
) -> Result<Vec<u8>, RemotePluginCatalogError> {
if !plugin_path.is_dir() {
return Err(RemotePluginCatalogError::InvalidPluginPath {
path: plugin_path.to_path_buf(),
reason: "expected a plugin directory".to_string(),
});
}
if !plugin_path.join(".codex-plugin/plugin.json").is_file() {
return Err(RemotePluginCatalogError::InvalidPluginPath {
path: plugin_path.to_path_buf(),
reason: "missing .codex-plugin/plugin.json".to_string(),
});
}
let encoder = GzEncoder::new(SizeLimitedBuffer::new(max_bytes), Compression::default());
let mut archive = tar::Builder::new(encoder);
append_plugin_tree(&mut archive, plugin_path, plugin_path)
.map_err(|source| archive_error(plugin_path, source))?;
let encoder = archive
.into_inner()
.map_err(|source| archive_error(plugin_path, source))?;
encoder
.finish()
.map(SizeLimitedBuffer::into_inner)
.map_err(|source| archive_error(plugin_path, source))
}
fn append_plugin_tree<W: Write>(
archive: &mut tar::Builder<W>,
plugin_root: &Path,
current: &Path,
) -> io::Result<()> {
let mut entries = fs::read_dir(current)?.collect::<Result<Vec<_>, io::Error>>()?;
entries.sort_by_key(fs::DirEntry::file_name);
for entry in entries {
let path = entry.path();
let file_type = entry.file_type()?;
let relative_path = path.strip_prefix(plugin_root).map_err(|err| {
io::Error::other(format!(
"failed to compute plugin archive path for `{}`: {err}",
path.display()
))
})?;
if file_type.is_dir() {
archive.append_dir(relative_path, &path)?;
append_plugin_tree(archive, plugin_root, &path)?;
} else if file_type.is_file() {
archive.append_path_with_name(&path, relative_path)?;
} else {
return Err(io::Error::other(format!(
"unsupported plugin archive entry type: {}",
path.display()
)));
}
}
Ok(())
}
fn archive_error(plugin_path: &Path, source: io::Error) -> RemotePluginCatalogError {
if let Some(limit) = source
.get_ref()
.and_then(|err| err.downcast_ref::<ArchiveSizeLimitExceeded>())
{
return RemotePluginCatalogError::ArchiveTooLarge {
bytes: limit.bytes,
max_bytes: limit.max_bytes,
};
}
RemotePluginCatalogError::Archive {
path: plugin_path.to_path_buf(),
source,
}
}
struct SizeLimitedBuffer {
bytes: Vec<u8>,
max_bytes: usize,
}
impl SizeLimitedBuffer {
fn new(max_bytes: usize) -> Self {
Self {
bytes: Vec::new(),
max_bytes,
}
}
fn into_inner(self) -> Vec<u8> {
self.bytes
}
}
impl Write for SizeLimitedBuffer {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let next_len = self.bytes.len().checked_add(buf.len()).ok_or_else(|| {
io::Error::other(ArchiveSizeLimitExceeded {
bytes: usize::MAX,
max_bytes: self.max_bytes,
})
})?;
if next_len > self.max_bytes {
return Err(io::Error::other(ArchiveSizeLimitExceeded {
bytes: next_len,
max_bytes: self.max_bytes,
}));
}
self.bytes.extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[derive(Debug)]
struct ArchiveSizeLimitExceeded {
bytes: usize,
max_bytes: usize,
}
impl fmt::Display for ArchiveSizeLimitExceeded {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"archive would be {} bytes, exceeding maximum size of {} bytes",
self.bytes, self.max_bytes
)
}
}
impl std::error::Error for ArchiveSizeLimitExceeded {}
async fn send_and_expect_status(
request: RequestBuilder,
url_for_error: &str,
expected_statuses: &[StatusCode],
) -> Result<(), RemotePluginCatalogError> {
let response = request
.send()
.await
.map_err(|source| RemotePluginCatalogError::Request {
url: url_for_error.to_string(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !expected_statuses.contains(&status) {
return Err(RemotePluginCatalogError::UnexpectedStatus {
url: url_for_error.to_string(),
status,
body,
});
}
Ok(())
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,417 @@
use super::*;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
use codex_login::CodexAuth;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::fs;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
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;
use wiremock::matchers::query_param_is_missing;
fn test_config(server: &MockServer) -> RemotePluginServiceConfig {
RemotePluginServiceConfig {
chatgpt_base_url: format!("{}/backend-api", server.uri()),
}
}
fn test_auth() -> CodexAuth {
CodexAuth::create_dummy_chatgpt_auth_for_testing()
}
fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
fn write_test_plugin(root: &Path, plugin_name: &str) -> 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",
);
plugin_path
}
fn archive_file_entries(archive_bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let decoder = flate2::read::GzDecoder::new(archive_bytes);
let mut archive = tar::Archive::new(decoder);
archive
.entries()
.unwrap()
.filter_map(|entry| {
let mut entry = entry.unwrap();
if !entry.header().entry_type().is_file() {
return None;
}
let path = entry.path().unwrap().to_string_lossy().into_owned();
let mut contents = Vec::new();
entry.read_to_end(&mut contents).unwrap();
Some((path, contents))
})
.collect()
}
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(),
}
}
#[tokio::test]
async fn save_remote_plugin_share_creates_workspace_plugin() {
let temp_dir = TempDir::new().unwrap();
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len();
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace/upload-url"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(body_json(json!({
"filename": "demo-plugin.tar.gz",
"mime_type": "application/gzip",
"size_bytes": archive_size,
})))
.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 Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.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 result = save_remote_plugin_share(
&config,
Some(&auth),
&plugin_path,
/*remote_plugin_id*/ None,
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginShareSaveResult {
remote_plugin_id: "plugins_123".to_string(),
share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()),
}
);
let requests = server.received_requests().await.unwrap_or_default();
let upload_request = requests
.iter()
.find(|request| request.method == "PUT" && request.url.path() == "/upload/file_123")
.unwrap();
let archive_files = archive_file_entries(&upload_request.body);
assert_eq!(
archive_files
.get(".codex-plugin/plugin.json")
.map(Vec::as_slice),
Some(br#"{"name":"demo-plugin"}"#.as_slice())
);
assert_eq!(
archive_files
.get("skills/example/SKILL.md")
.map(Vec::as_slice),
Some(b"# Example\n\nA test skill.\n".as_slice())
);
}
#[test]
fn archive_plugin_for_upload_rejects_archives_over_limit() {
let temp_dir = TempDir::new().unwrap();
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
write_file(
&plugin_path.join("large.txt"),
&"0123456789abcdef".repeat(1024),
);
let err = archive_plugin_for_upload_with_limit(&plugin_path, /*max_bytes*/ 16)
.expect_err("oversized plugin archive should fail");
assert!(matches!(
err,
RemotePluginCatalogError::ArchiveTooLarge { .. }
));
}
#[test]
fn archive_plugin_for_upload_places_manifest_at_archive_root() {
let temp_dir = TempDir::new().unwrap();
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
let archive_bytes = archive_plugin_for_upload(&plugin_path).unwrap();
let archive_files = archive_file_entries(&archive_bytes);
assert_eq!(
archive_files.keys().cloned().collect::<Vec<_>>(),
vec![
".codex-plugin/plugin.json".to_string(),
"skills/example/SKILL.md".to_string()
]
);
assert_eq!(
archive_files
.get(".codex-plugin/plugin.json")
.map(Vec::as_slice),
Some(br#"{"name":"demo-plugin"}"#.as_slice())
);
assert_eq!(
archive_files
.get("skills/example/SKILL.md")
.map(Vec::as_slice),
Some(b"# Example\n\nA test skill.\n".as_slice())
);
}
#[tokio::test]
async fn save_remote_plugin_share_updates_existing_workspace_plugin() {
let temp_dir = TempDir::new().unwrap();
let plugin_path = write_test_plugin(temp_dir.path(), "demo-plugin");
let archive_size = archive_plugin_for_upload(&plugin_path).unwrap().len();
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace/upload-url"))
.and(body_json(json!({
"filename": "demo-plugin.tar.gz",
"mime_type": "application/gzip",
"size_bytes": archive_size,
"plugin_id": "plugins_123",
})))
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
"file_id": "file_456",
"upload_url": format!("{}/upload/file_456", server.uri()),
"etag": "\"upload_etag_456\"",
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("PUT"))
.and(path("/upload/file_456"))
.respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_456\""))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/backend-api/public/plugins/workspace/plugins_123"))
.and(body_json(json!({
"file_id": "file_456",
"etag": "\"upload_etag_456\"",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugin_id": "plugins_123",
})))
.expect(1)
.mount(&server)
.await;
let result = save_remote_plugin_share(&config, Some(&auth), &plugin_path, Some("plugins_123"))
.await
.unwrap();
assert_eq!(
result,
RemotePluginShareSaveResult {
remote_plugin_id: "plugins_123".to_string(),
share_url: None,
}
);
}
#[tokio::test]
async fn list_remote_plugin_shares_fetches_created_workspace_plugins() {
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/workspace/created"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(query_param(
"limit",
REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string(),
))
.and(query_param_is_missing("pageToken"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [remote_plugin_json("plugins_123")],
"pagination": {
"next_page_token": "page-2"
},
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/workspace/created"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(query_param(
"limit",
REMOTE_PLUGIN_LIST_PAGE_LIMIT.to_string(),
))
.and(query_param("pageToken", "page-2"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [remote_plugin_json("plugins_456")],
"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"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"plugins": [installed_remote_plugin_json("plugins_456")],
"pagination": empty_pagination_json(),
})))
.expect(1)
.mount(&server)
.await;
let result = list_remote_plugin_shares(&config, Some(&auth))
.await
.unwrap();
assert_eq!(
result,
vec![
RemotePluginSummary {
id: "plugins_123".to_string(),
name: "demo-plugin".to_string(),
installed: false,
enabled: false,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
interface: Some(expected_plugin_interface()),
},
RemotePluginSummary {
id: "plugins_456".to_string(),
name: "demo-plugin".to_string(),
installed: true,
enabled: true,
install_policy: PluginInstallPolicy::Available,
auth_policy: PluginAuthPolicy::OnUse,
interface: Some(expected_plugin_interface()),
}
]
);
}
#[tokio::test]
async fn delete_remote_plugin_share_deletes_workspace_plugin() {
let server = MockServer::start().await;
let config = test_config(&server);
let auth = test_auth();
Mock::given(method("DELETE"))
.and(path("/backend-api/public/plugins/workspace/plugins_123"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&server)
.await;
delete_remote_plugin_share(&config, Some(&auth), "plugins_123")
.await
.unwrap();
}