mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
ae863e72a2
commit
87d0cf1a62
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user