mirror of
https://github.com/openai/codex.git
synced 2026-05-14 00:02:33 +00:00
## Summary - Canonicalize private and unlisted workspace shared plugin IDs to `workspace-shared-with-me`. - Keep `plugin/list` private/unlisted shared-with-me buckets as UI grouping only. - Update share read/list/checkout and cache cleanup coverage for the canonical namespace. ## Tests - `cargo test -p codex-app-server --test all plugin_list_fetches_shared_with_me_kind` - `cargo test -p codex-app-server --test all plugin_read_returns_share_context_for_shared_remote_plugin` - `cargo test -p codex-app-server --test all suite::v2::plugin_share` - `cargo test -p codex-core-plugins list_remote_plugin_shares_fetches_created_workspace_plugins` - `cargo test -p codex-core-plugins stale_remote_plugin_cleanup_removes_old_shared_with_me_cache_and_keeps_canonical_cache` - `git diff --check`
1297 lines
46 KiB
Rust
1297 lines
46 KiB
Rust
use crate::store::PLUGINS_CACHE_DIR;
|
|
use crate::store::PluginStore;
|
|
use codex_app_server_protocol::JSONRPCErrorError;
|
|
use codex_app_server_protocol::PluginAuthPolicy;
|
|
use codex_app_server_protocol::PluginAvailability;
|
|
use codex_app_server_protocol::PluginInstallPolicy;
|
|
use codex_app_server_protocol::PluginInterface;
|
|
use codex_app_server_protocol::SkillInterface;
|
|
use codex_login::CodexAuth;
|
|
use codex_login::default_client::build_reqwest_client;
|
|
use codex_plugin::PluginId;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use reqwest::RequestBuilder;
|
|
use serde::Deserialize;
|
|
use std::collections::BTreeMap;
|
|
use std::collections::BTreeSet;
|
|
use std::collections::HashSet;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
use url::Url;
|
|
|
|
mod remote_installed_plugin_sync;
|
|
mod share;
|
|
|
|
pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncError;
|
|
pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncOutcome;
|
|
pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard;
|
|
pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight;
|
|
pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync;
|
|
pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once;
|
|
pub use share::RemotePluginShareAccessPolicy;
|
|
pub use share::RemotePluginShareDiscoverability;
|
|
pub use share::RemotePluginSharePrincipal;
|
|
pub use share::RemotePluginSharePrincipalRole;
|
|
pub use share::RemotePluginSharePrincipalType;
|
|
pub use share::RemotePluginShareSaveResult;
|
|
pub use share::RemotePluginShareTarget;
|
|
pub use share::RemotePluginShareTargetRole;
|
|
pub use share::RemotePluginShareUpdateDiscoverability;
|
|
pub use share::RemotePluginShareUpdateTargetsResult;
|
|
pub use share::checkout_remote_plugin_share;
|
|
pub use share::delete_remote_plugin_share;
|
|
pub use share::list_remote_plugin_shares;
|
|
pub use share::load_plugin_share_remote_ids_by_local_path;
|
|
pub use share::save_remote_plugin_share;
|
|
pub use share::update_remote_plugin_share_targets;
|
|
|
|
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global";
|
|
pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory";
|
|
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "workspace-shared-with-me";
|
|
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str =
|
|
"workspace-shared-with-me-private";
|
|
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str =
|
|
"workspace-shared-with-me-unlisted";
|
|
pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins";
|
|
pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory";
|
|
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me";
|
|
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &str =
|
|
"Shared with me (unlisted)";
|
|
|
|
const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
|
|
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
|
|
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
|
|
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RemotePluginServiceConfig {
|
|
pub chatgpt_base_url: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct RemoteMarketplace {
|
|
pub name: String,
|
|
pub display_name: String,
|
|
pub plugins: Vec<RemotePluginSummary>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RemoteMarketplaceSource {
|
|
Global,
|
|
WorkspaceDirectory,
|
|
SharedWithMe,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct RemoteInstalledPlugin {
|
|
pub marketplace_name: String,
|
|
pub id: String,
|
|
pub name: String,
|
|
pub enabled: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct RemotePluginSummary {
|
|
pub id: String,
|
|
pub remote_plugin_id: String,
|
|
pub name: String,
|
|
pub share_context: Option<RemotePluginShareContext>,
|
|
pub installed: bool,
|
|
pub enabled: bool,
|
|
pub install_policy: PluginInstallPolicy,
|
|
pub auth_policy: PluginAuthPolicy,
|
|
pub availability: PluginAvailability,
|
|
pub interface: Option<PluginInterface>,
|
|
pub keywords: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RemotePluginShareContext {
|
|
pub remote_plugin_id: String,
|
|
pub remote_version: Option<String>,
|
|
pub discoverability: RemotePluginShareDiscoverability,
|
|
pub share_url: Option<String>,
|
|
pub creator_account_user_id: Option<String>,
|
|
pub creator_name: Option<String>,
|
|
pub share_principals: Option<Vec<RemotePluginSharePrincipal>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct RemotePluginShareSummary {
|
|
pub summary: RemotePluginSummary,
|
|
pub local_plugin_path: Option<AbsolutePathBuf>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct RemotePluginDetail {
|
|
pub marketplace_name: String,
|
|
pub marketplace_display_name: String,
|
|
pub summary: RemotePluginSummary,
|
|
pub description: Option<String>,
|
|
pub release_version: Option<String>,
|
|
pub bundle_download_url: Option<String>,
|
|
pub skills: Vec<RemotePluginSkill>,
|
|
pub app_ids: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct RemotePluginSkill {
|
|
pub name: String,
|
|
pub description: String,
|
|
pub short_description: Option<String>,
|
|
pub interface: Option<SkillInterface>,
|
|
pub enabled: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct RemotePluginSkillDetail {
|
|
pub contents: Option<String>,
|
|
}
|
|
|
|
pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool {
|
|
!plugin_id.is_empty()
|
|
&& plugin_id
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
|
|
}
|
|
|
|
pub fn validate_remote_plugin_id(plugin_id: &str) -> Result<(), JSONRPCErrorError> {
|
|
if !is_valid_remote_plugin_id(plugin_id) {
|
|
return Err(JSONRPCErrorError {
|
|
code: INVALID_REQUEST_ERROR_CODE,
|
|
message:
|
|
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed"
|
|
.to_string(),
|
|
data: None,
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum RemotePluginCatalogError {
|
|
#[error("chatgpt authentication required for remote plugin catalog")]
|
|
AuthRequired,
|
|
|
|
#[error(
|
|
"chatgpt authentication required for remote plugin catalog; api key auth is not supported"
|
|
)]
|
|
UnsupportedAuthMode,
|
|
|
|
#[error("failed to read auth token for remote plugin catalog: {0}")]
|
|
AuthToken(#[source] std::io::Error),
|
|
|
|
#[error("failed to send remote plugin catalog request to {url}: {source}")]
|
|
Request {
|
|
url: String,
|
|
#[source]
|
|
source: reqwest::Error,
|
|
},
|
|
|
|
#[error("remote plugin catalog request to {url} failed with status {status}: {body}")]
|
|
UnexpectedStatus {
|
|
url: String,
|
|
status: reqwest::StatusCode,
|
|
body: String,
|
|
},
|
|
|
|
#[error("failed to parse remote plugin catalog response from {url}: {source}")]
|
|
Decode {
|
|
url: String,
|
|
#[source]
|
|
source: serde_json::Error,
|
|
},
|
|
|
|
#[error("invalid remote plugin catalog base URL: {0}")]
|
|
InvalidBaseUrl(#[source] url::ParseError),
|
|
|
|
#[error("invalid remote plugin catalog base URL path")]
|
|
InvalidBaseUrlPath,
|
|
|
|
#[error("remote marketplace `{marketplace_name}` is not supported")]
|
|
UnknownMarketplace { marketplace_name: String },
|
|
|
|
#[error(
|
|
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
|
|
)]
|
|
UnexpectedPluginId { expected: String, actual: String },
|
|
|
|
#[error(
|
|
"remote plugin skill response returned unexpected skill name: expected `{expected}`, got `{actual}`"
|
|
)]
|
|
UnexpectedSkillName { expected: String, actual: String },
|
|
|
|
#[error(
|
|
"remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
|
|
)]
|
|
UnexpectedEnabledState {
|
|
plugin_id: String,
|
|
expected_enabled: bool,
|
|
actual_enabled: bool,
|
|
},
|
|
|
|
#[error("invalid plugin path `{path}`: {reason}")]
|
|
InvalidPluginPath { path: PathBuf, reason: String },
|
|
|
|
#[error("remote plugin `{remote_plugin_id}` is not available for plugin/share/checkout")]
|
|
PluginShareCheckoutNotAvailable { remote_plugin_id: 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),
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
|
|
enum RemotePluginScope {
|
|
#[serde(rename = "GLOBAL")]
|
|
Global,
|
|
#[serde(rename = "WORKSPACE")]
|
|
Workspace,
|
|
}
|
|
|
|
impl RemotePluginScope {
|
|
fn api_value(self) -> &'static str {
|
|
match self {
|
|
Self::Global => "GLOBAL",
|
|
Self::Workspace => "WORKSPACE",
|
|
}
|
|
}
|
|
|
|
fn marketplace_name(self) -> &'static str {
|
|
match self {
|
|
Self::Global => REMOTE_GLOBAL_MARKETPLACE_NAME,
|
|
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_NAME,
|
|
}
|
|
}
|
|
|
|
fn marketplace_display_name(self) -> &'static str {
|
|
match self {
|
|
Self::Global => REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME,
|
|
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
|
|
| REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME
|
|
| REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME
|
|
| REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME => Some(Self::Workspace),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginPagination {
|
|
next_page_token: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginSkillInterfaceResponse {
|
|
display_name: Option<String>,
|
|
short_description: Option<String>,
|
|
brand_color: Option<String>,
|
|
default_prompt: Option<String>,
|
|
icon_small_url: Option<String>,
|
|
icon_large_url: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginSkillResponse {
|
|
name: String,
|
|
description: String,
|
|
interface: Option<RemotePluginSkillInterfaceResponse>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginSkillDetailResponse {
|
|
plugin_id: String,
|
|
name: String,
|
|
skill_md_contents: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginReleaseInterfaceResponse {
|
|
short_description: Option<String>,
|
|
long_description: Option<String>,
|
|
developer_name: Option<String>,
|
|
category: Option<String>,
|
|
#[serde(default)]
|
|
capabilities: Vec<String>,
|
|
website_url: Option<String>,
|
|
privacy_policy_url: Option<String>,
|
|
terms_of_service_url: Option<String>,
|
|
brand_color: Option<String>,
|
|
default_prompt: Option<String>,
|
|
composer_icon_url: Option<String>,
|
|
logo_url: Option<String>,
|
|
#[serde(default)]
|
|
screenshot_urls: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginReleaseResponse {
|
|
#[serde(default)]
|
|
version: Option<String>,
|
|
display_name: String,
|
|
description: String,
|
|
#[serde(default)]
|
|
bundle_download_url: Option<String>,
|
|
#[serde(default)]
|
|
app_ids: Vec<String>,
|
|
#[serde(default)]
|
|
keywords: Vec<String>,
|
|
interface: RemotePluginReleaseInterfaceResponse,
|
|
#[serde(default)]
|
|
skills: Vec<RemotePluginSkillResponse>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginDirectoryItem {
|
|
id: String,
|
|
name: String,
|
|
scope: RemotePluginScope,
|
|
#[serde(default)]
|
|
discoverability: Option<RemotePluginShareDiscoverability>,
|
|
#[serde(default)]
|
|
creator_account_user_id: Option<String>,
|
|
#[serde(default)]
|
|
creator_name: Option<String>,
|
|
#[serde(default)]
|
|
share_url: Option<String>,
|
|
#[serde(default)]
|
|
share_principals: Option<Vec<RemotePluginDirectorySharePrincipal>>,
|
|
installation_policy: PluginInstallPolicy,
|
|
authentication_policy: PluginAuthPolicy,
|
|
#[serde(rename = "status", default)]
|
|
availability: PluginAvailability,
|
|
release: RemotePluginReleaseResponse,
|
|
}
|
|
|
|
fn remote_plugin_canonical_marketplace_name(
|
|
plugin: &RemotePluginDirectoryItem,
|
|
) -> Result<&'static str, RemotePluginCatalogError> {
|
|
match plugin.scope {
|
|
RemotePluginScope::Global => Ok(REMOTE_GLOBAL_MARKETPLACE_NAME),
|
|
RemotePluginScope::Workspace => match workspace_plugin_discoverability(plugin)? {
|
|
RemotePluginShareDiscoverability::Listed => Ok(REMOTE_WORKSPACE_MARKETPLACE_NAME),
|
|
RemotePluginShareDiscoverability::Private
|
|
| RemotePluginShareDiscoverability::Unlisted => {
|
|
Ok(REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
fn workspace_plugin_discoverability(
|
|
plugin: &RemotePluginDirectoryItem,
|
|
) -> Result<RemotePluginShareDiscoverability, RemotePluginCatalogError> {
|
|
plugin.discoverability.ok_or_else(|| {
|
|
RemotePluginCatalogError::UnexpectedResponse(format!(
|
|
"workspace plugin `{}` did not include discoverability",
|
|
plugin.id
|
|
))
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginDirectorySharePrincipal {
|
|
principal_type: RemotePluginSharePrincipalType,
|
|
principal_id: String,
|
|
role: RemotePluginSharePrincipalRole,
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginInstalledItem {
|
|
#[serde(flatten)]
|
|
plugin: RemotePluginDirectoryItem,
|
|
enabled: bool,
|
|
#[serde(default)]
|
|
disabled_skill_names: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginListResponse {
|
|
plugins: Vec<RemotePluginDirectoryItem>,
|
|
pagination: RemotePluginPagination,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginInstalledResponse {
|
|
plugins: Vec<RemotePluginInstalledItem>,
|
|
pagination: RemotePluginPagination,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
|
struct RemotePluginMutationResponse {
|
|
id: String,
|
|
enabled: bool,
|
|
}
|
|
|
|
pub async fn fetch_remote_marketplaces(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: Option<&CodexAuth>,
|
|
sources: &[RemoteMarketplaceSource],
|
|
) -> Result<Vec<RemoteMarketplace>, RemotePluginCatalogError> {
|
|
let auth = ensure_chatgpt_auth(auth)?;
|
|
let mut marketplaces = Vec::new();
|
|
let needs_workspace_installed = sources.iter().any(|source| {
|
|
matches!(
|
|
source,
|
|
RemoteMarketplaceSource::WorkspaceDirectory | RemoteMarketplaceSource::SharedWithMe
|
|
)
|
|
});
|
|
let workspace_installed_plugins = if needs_workspace_installed {
|
|
Some(fetch_installed_plugins_for_scope(config, auth, RemotePluginScope::Workspace).await?)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
for source in sources {
|
|
match source {
|
|
RemoteMarketplaceSource::Global => {
|
|
let scope = RemotePluginScope::Global;
|
|
let (directory_plugins, installed_plugins) = tokio::try_join!(
|
|
fetch_directory_plugins_for_scope(config, auth, scope),
|
|
fetch_installed_plugins_for_scope(config, auth, scope),
|
|
)?;
|
|
if let Some(marketplace) = build_remote_marketplace(
|
|
scope.marketplace_name(),
|
|
scope.marketplace_display_name(),
|
|
directory_plugins,
|
|
installed_plugins,
|
|
/*include_installed_only*/ true,
|
|
)? {
|
|
marketplaces.push(marketplace);
|
|
}
|
|
}
|
|
RemoteMarketplaceSource::WorkspaceDirectory => {
|
|
let scope = RemotePluginScope::Workspace;
|
|
let directory_plugins =
|
|
fetch_directory_plugins_for_scope(config, auth, scope).await?;
|
|
if let Some(marketplace) = build_remote_marketplace(
|
|
scope.marketplace_name(),
|
|
scope.marketplace_display_name(),
|
|
directory_plugins,
|
|
workspace_installed_plugins.clone().unwrap_or_default(),
|
|
/*include_installed_only*/ false,
|
|
)? {
|
|
marketplaces.push(marketplace);
|
|
}
|
|
}
|
|
RemoteMarketplaceSource::SharedWithMe => {
|
|
// The shared endpoint is the source of truth for plugins explicitly shared
|
|
// with the user. Installed unlisted plugins that are not returned there are
|
|
// link-installed and stay in the separate unlisted bucket.
|
|
let shared_plugins = fetch_shared_workspace_plugins(config, auth).await?;
|
|
let shared_plugin_ids = shared_plugins
|
|
.iter()
|
|
.map(|plugin| plugin.id.clone())
|
|
.collect::<BTreeSet<_>>();
|
|
let directly_shared_plugins = shared_plugins
|
|
.into_iter()
|
|
.filter_map(|plugin| match workspace_plugin_discoverability(&plugin) {
|
|
Ok(
|
|
RemotePluginShareDiscoverability::Private
|
|
| RemotePluginShareDiscoverability::Unlisted,
|
|
) => Some(Ok(plugin)),
|
|
Ok(RemotePluginShareDiscoverability::Listed) => None,
|
|
Err(err) => Some(Err(err)),
|
|
})
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
if let Some(marketplace) = build_remote_marketplace(
|
|
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME,
|
|
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME,
|
|
directly_shared_plugins,
|
|
workspace_installed_plugins.clone().unwrap_or_default(),
|
|
/*include_installed_only*/ false,
|
|
)? {
|
|
marketplaces.push(marketplace);
|
|
}
|
|
|
|
let unlisted_installed_plugins = workspace_installed_plugins
|
|
.clone()
|
|
.unwrap_or_default()
|
|
.into_iter()
|
|
.filter_map(
|
|
|plugin| match workspace_plugin_discoverability(&plugin.plugin) {
|
|
Ok(RemotePluginShareDiscoverability::Unlisted)
|
|
if !shared_plugin_ids.contains(&plugin.plugin.id) =>
|
|
{
|
|
Some(Ok(plugin))
|
|
}
|
|
Ok(RemotePluginShareDiscoverability::Unlisted) => None,
|
|
Ok(RemotePluginShareDiscoverability::Listed)
|
|
| Ok(RemotePluginShareDiscoverability::Private) => None,
|
|
Err(err) => Some(Err(err)),
|
|
},
|
|
)
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
if let Some(marketplace) = build_remote_marketplace(
|
|
REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME,
|
|
REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME,
|
|
Vec::new(),
|
|
unlisted_installed_plugins,
|
|
/*include_installed_only*/ true,
|
|
)? {
|
|
marketplaces.push(marketplace);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(marketplaces)
|
|
}
|
|
|
|
fn build_remote_marketplace(
|
|
name: &str,
|
|
display_name: &str,
|
|
directory_plugins: Vec<RemotePluginDirectoryItem>,
|
|
installed_plugins: Vec<RemotePluginInstalledItem>,
|
|
include_installed_only: bool,
|
|
) -> Result<Option<RemoteMarketplace>, RemotePluginCatalogError> {
|
|
let directory_plugins = directory_plugins
|
|
.into_iter()
|
|
.map(|plugin| (plugin.id.clone(), plugin))
|
|
.collect::<BTreeMap<_, _>>();
|
|
let installed_plugins = installed_plugins
|
|
.into_iter()
|
|
.map(|plugin| (plugin.plugin.id.clone(), plugin))
|
|
.collect::<BTreeMap<_, _>>();
|
|
let plugin_ids = directory_plugins
|
|
.keys()
|
|
.chain(
|
|
include_installed_only
|
|
.then_some(&installed_plugins)
|
|
.into_iter()
|
|
.flat_map(|plugins| plugins.keys()),
|
|
)
|
|
.cloned()
|
|
.collect::<BTreeSet<_>>();
|
|
if plugin_ids.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
let mut plugins = plugin_ids
|
|
.into_iter()
|
|
.filter_map(|plugin_id| {
|
|
let directory_plugin = directory_plugins.get(&plugin_id);
|
|
let installed_plugin = installed_plugins.get(&plugin_id);
|
|
directory_plugin
|
|
.or_else(|| installed_plugin.map(|plugin| &plugin.plugin))
|
|
.map(|plugin| (plugin, installed_plugin))
|
|
})
|
|
.map(|(plugin, installed_plugin)| build_remote_plugin_summary(plugin, installed_plugin))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
plugins.sort_by(|left, right| {
|
|
remote_plugin_display_name(left)
|
|
.to_ascii_lowercase()
|
|
.cmp(&remote_plugin_display_name(right).to_ascii_lowercase())
|
|
.then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right)))
|
|
.then_with(|| left.id.cmp(&right.id))
|
|
});
|
|
Ok(Some(RemoteMarketplace {
|
|
name: name.to_string(),
|
|
display_name: display_name.to_string(),
|
|
plugins,
|
|
}))
|
|
}
|
|
|
|
pub async fn fetch_remote_installed_plugins(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: Option<&CodexAuth>,
|
|
) -> Result<Vec<RemoteInstalledPlugin>, RemotePluginCatalogError> {
|
|
let auth = ensure_chatgpt_auth(auth)?;
|
|
let global = async {
|
|
let scope = RemotePluginScope::Global;
|
|
let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?;
|
|
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
|
|
};
|
|
let workspace = async {
|
|
let scope = RemotePluginScope::Workspace;
|
|
let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?;
|
|
Ok::<_, RemotePluginCatalogError>((scope, installed_plugins))
|
|
};
|
|
|
|
let (global, workspace) = tokio::try_join!(global, workspace)?;
|
|
let mut installed_plugins = [global, workspace]
|
|
.into_iter()
|
|
.flat_map(|(_scope, plugins)| plugins)
|
|
.map(|plugin| remote_installed_plugin_to_info(&plugin))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
installed_plugins.sort_by(|left, right| {
|
|
left.marketplace_name
|
|
.cmp(&right.marketplace_name)
|
|
.then_with(|| left.id.cmp(&right.id))
|
|
});
|
|
Ok(installed_plugins)
|
|
}
|
|
|
|
pub async fn fetch_remote_plugin_detail(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: Option<&CodexAuth>,
|
|
marketplace_name: &str,
|
|
plugin_id: &str,
|
|
) -> Result<RemotePluginDetail, RemotePluginCatalogError> {
|
|
fetch_remote_plugin_detail_with_download_url_option(
|
|
config,
|
|
auth,
|
|
marketplace_name,
|
|
plugin_id,
|
|
/*include_download_urls*/ false,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn fetch_remote_plugin_share_context(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: Option<&CodexAuth>,
|
|
plugin_id: &str,
|
|
) -> Result<Option<RemotePluginShareContext>, RemotePluginCatalogError> {
|
|
let auth = ensure_chatgpt_auth(auth)?;
|
|
let plugin = fetch_plugin_detail(
|
|
config, auth, plugin_id, /*include_download_urls*/ false,
|
|
)
|
|
.await?;
|
|
remote_plugin_share_context(&plugin)
|
|
}
|
|
|
|
pub async fn fetch_remote_plugin_detail_with_download_urls(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: Option<&CodexAuth>,
|
|
marketplace_name: &str,
|
|
plugin_id: &str,
|
|
) -> Result<RemotePluginDetail, RemotePluginCatalogError> {
|
|
fetch_remote_plugin_detail_with_download_url_option(
|
|
config,
|
|
auth,
|
|
marketplace_name,
|
|
plugin_id,
|
|
/*include_download_urls*/ true,
|
|
)
|
|
.await
|
|
}
|
|
|
|
pub async fn fetch_remote_plugin_skill_detail(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: Option<&CodexAuth>,
|
|
marketplace_name: &str,
|
|
plugin_id: &str,
|
|
skill_name: &str,
|
|
) -> Result<RemotePluginSkillDetail, 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(),
|
|
});
|
|
}
|
|
|
|
let url = remote_plugin_skill_detail_url(config, plugin_id, skill_name)?;
|
|
let client = build_reqwest_client();
|
|
let request = authenticated_request(client.get(&url), auth)?;
|
|
let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?;
|
|
if response.plugin_id != plugin_id {
|
|
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
|
expected: plugin_id.to_string(),
|
|
actual: response.plugin_id,
|
|
});
|
|
}
|
|
if response.name != skill_name {
|
|
return Err(RemotePluginCatalogError::UnexpectedSkillName {
|
|
expected: skill_name.to_string(),
|
|
actual: response.name,
|
|
});
|
|
}
|
|
|
|
Ok(RemotePluginSkillDetail {
|
|
contents: response.skill_md_contents,
|
|
})
|
|
}
|
|
|
|
async fn fetch_remote_plugin_detail_with_download_url_option(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: Option<&CodexAuth>,
|
|
_marketplace_name: &str,
|
|
plugin_id: &str,
|
|
include_download_urls: bool,
|
|
) -> Result<RemotePluginDetail, RemotePluginCatalogError> {
|
|
let auth = ensure_chatgpt_auth(auth)?;
|
|
let plugin = fetch_plugin_detail(config, auth, plugin_id, include_download_urls).await?;
|
|
let scope = plugin.scope;
|
|
let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.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, plugin_id, plugin).await
|
|
}
|
|
|
|
async fn build_remote_plugin_detail(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: &CodexAuth,
|
|
scope: RemotePluginScope,
|
|
marketplace_name: String,
|
|
plugin_id: &str,
|
|
plugin: RemotePluginDirectoryItem,
|
|
) -> Result<RemotePluginDetail, RemotePluginCatalogError> {
|
|
let installed_plugin = fetch_installed_plugins_for_scope(config, auth, scope)
|
|
.await?
|
|
.into_iter()
|
|
.find(|installed_plugin| installed_plugin.plugin.id == plugin_id);
|
|
let disabled_skill_names = installed_plugin
|
|
.as_ref()
|
|
.map(|plugin| {
|
|
plugin
|
|
.disabled_skill_names
|
|
.iter()
|
|
.cloned()
|
|
.collect::<HashSet<_>>()
|
|
})
|
|
.unwrap_or_default();
|
|
let skills = plugin
|
|
.release
|
|
.skills
|
|
.iter()
|
|
.map(|skill| RemotePluginSkill {
|
|
name: skill.name.clone(),
|
|
description: skill.description.clone(),
|
|
short_description: skill
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.short_description.clone()),
|
|
interface: remote_skill_interface_to_info(skill.interface.clone()),
|
|
enabled: !disabled_skill_names.contains(&skill.name),
|
|
})
|
|
.collect();
|
|
|
|
Ok(RemotePluginDetail {
|
|
marketplace_name,
|
|
marketplace_display_name: scope.marketplace_display_name().to_string(),
|
|
summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref())?,
|
|
description: non_empty_string(Some(&plugin.release.description)),
|
|
release_version: plugin.release.version,
|
|
bundle_download_url: plugin.release.bundle_download_url,
|
|
skills,
|
|
app_ids: plugin.release.app_ids,
|
|
})
|
|
}
|
|
|
|
pub async fn install_remote_plugin(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: Option<&CodexAuth>,
|
|
_marketplace_name: &str,
|
|
plugin_id: &str,
|
|
) -> Result<(), RemotePluginCatalogError> {
|
|
let auth = ensure_chatgpt_auth(auth)?;
|
|
// 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");
|
|
let client = build_reqwest_client();
|
|
let request = authenticated_request(client.post(&url), auth)?;
|
|
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
|
|
if response.id != plugin_id {
|
|
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
|
expected: plugin_id.to_string(),
|
|
actual: response.id,
|
|
});
|
|
}
|
|
if !response.enabled {
|
|
return Err(RemotePluginCatalogError::UnexpectedEnabledState {
|
|
plugin_id: plugin_id.to_string(),
|
|
expected_enabled: true,
|
|
actual_enabled: response.enabled,
|
|
});
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn uninstall_remote_plugin(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: Option<&CodexAuth>,
|
|
codex_home: PathBuf,
|
|
plugin_id: &str,
|
|
) -> Result<(), RemotePluginCatalogError> {
|
|
let auth = ensure_chatgpt_auth(auth)?;
|
|
let plugin = fetch_plugin_detail(
|
|
config, auth, plugin_id, /*include_download_urls*/ false,
|
|
)
|
|
.await?;
|
|
let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string();
|
|
let plugin_name = plugin.name;
|
|
|
|
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
|
let url = format!("{base_url}/plugins/{plugin_id}/uninstall");
|
|
let client = build_reqwest_client();
|
|
let request = authenticated_request(client.post(&url), auth)?;
|
|
let response: RemotePluginMutationResponse = send_and_decode(request, &url).await?;
|
|
if response.id != plugin_id {
|
|
return Err(RemotePluginCatalogError::UnexpectedPluginId {
|
|
expected: plugin_id.to_string(),
|
|
actual: response.id,
|
|
});
|
|
}
|
|
if response.enabled {
|
|
return Err(RemotePluginCatalogError::UnexpectedEnabledState {
|
|
plugin_id: plugin_id.to_string(),
|
|
expected_enabled: false,
|
|
actual_enabled: response.enabled,
|
|
});
|
|
}
|
|
|
|
let legacy_plugin_id = plugin_id.to_string();
|
|
tokio::task::spawn_blocking(move || {
|
|
remove_remote_plugin_cache(codex_home, marketplace_name, plugin_name, legacy_plugin_id)
|
|
})
|
|
.await
|
|
.map_err(|err| {
|
|
RemotePluginCatalogError::CacheRemove(format!(
|
|
"failed to join remote plugin cache removal task: {err}"
|
|
))
|
|
})?
|
|
.map_err(RemotePluginCatalogError::CacheRemove)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_remote_plugin_cache(
|
|
codex_home: PathBuf,
|
|
marketplace_name: String,
|
|
plugin_name: String,
|
|
legacy_plugin_id: String,
|
|
) -> Result<(), String> {
|
|
let store = PluginStore::try_new(codex_home.clone())
|
|
.map_err(|err| format!("failed to resolve remote plugin cache root: {err}"))?;
|
|
let plugin_id =
|
|
PluginId::new(plugin_name.clone(), marketplace_name.clone()).map_err(|err| {
|
|
format!(
|
|
"invalid remote plugin cache id for `{plugin_name}` in `{marketplace_name}`: {err}"
|
|
)
|
|
})?;
|
|
let plugin_cache_root = store.plugin_base_root(&plugin_id);
|
|
store.uninstall(&plugin_id).map_err(|err| {
|
|
format!(
|
|
"failed to remove remote plugin cache entry {}: {err}",
|
|
plugin_cache_root.display()
|
|
)
|
|
})?;
|
|
|
|
let legacy_remote_plugin_cache_root = codex_home
|
|
.join(PLUGINS_CACHE_DIR)
|
|
.join(marketplace_name)
|
|
.join(legacy_plugin_id);
|
|
if legacy_remote_plugin_cache_root != plugin_cache_root.as_path()
|
|
&& legacy_remote_plugin_cache_root.exists()
|
|
{
|
|
let result = if legacy_remote_plugin_cache_root.is_dir() {
|
|
fs::remove_dir_all(&legacy_remote_plugin_cache_root)
|
|
} else {
|
|
fs::remove_file(&legacy_remote_plugin_cache_root)
|
|
};
|
|
result.map_err(|err| {
|
|
format!(
|
|
"failed to remove remote plugin cache entry {}: {err}",
|
|
legacy_remote_plugin_cache_root.display()
|
|
)
|
|
})?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn build_remote_plugin_summary(
|
|
plugin: &RemotePluginDirectoryItem,
|
|
installed_plugin: Option<&RemotePluginInstalledItem>,
|
|
) -> Result<RemotePluginSummary, RemotePluginCatalogError> {
|
|
let marketplace_name = remote_plugin_canonical_marketplace_name(plugin)?;
|
|
let plugin_id =
|
|
PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| {
|
|
RemotePluginCatalogError::UnexpectedResponse(format!(
|
|
"invalid remote plugin config id for `{}` in `{marketplace_name}`: {err}",
|
|
plugin.name
|
|
))
|
|
})?;
|
|
Ok(RemotePluginSummary {
|
|
id: plugin_id.as_key(),
|
|
remote_plugin_id: plugin.id.clone(),
|
|
name: plugin.name.clone(),
|
|
share_context: remote_plugin_share_context(plugin)?,
|
|
installed: installed_plugin.is_some(),
|
|
enabled: installed_plugin.is_some_and(|plugin| plugin.enabled),
|
|
install_policy: plugin.installation_policy,
|
|
auth_policy: plugin.authentication_policy,
|
|
availability: plugin.availability,
|
|
interface: remote_plugin_interface_to_info(plugin),
|
|
keywords: plugin.release.keywords.clone(),
|
|
})
|
|
}
|
|
|
|
fn remote_plugin_share_context(
|
|
plugin: &RemotePluginDirectoryItem,
|
|
) -> Result<Option<RemotePluginShareContext>, RemotePluginCatalogError> {
|
|
match plugin.scope {
|
|
RemotePluginScope::Global => Ok(None),
|
|
RemotePluginScope::Workspace => {
|
|
let discoverability = workspace_plugin_discoverability(plugin)?;
|
|
Ok(Some(RemotePluginShareContext {
|
|
remote_plugin_id: plugin.id.clone(),
|
|
remote_version: plugin.release.version.clone(),
|
|
discoverability,
|
|
share_url: plugin.share_url.clone(),
|
|
creator_account_user_id: plugin.creator_account_user_id.clone(),
|
|
creator_name: plugin.creator_name.clone(),
|
|
share_principals: plugin.share_principals.as_ref().map(|share_principals| {
|
|
share_principals
|
|
.iter()
|
|
.map(|principal| RemotePluginSharePrincipal {
|
|
principal_type: principal.principal_type,
|
|
principal_id: principal.principal_id.clone(),
|
|
role: principal.role,
|
|
name: principal.name.clone(),
|
|
})
|
|
.collect()
|
|
}),
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn remote_installed_plugin_to_info(
|
|
installed_plugin: &RemotePluginInstalledItem,
|
|
) -> Result<RemoteInstalledPlugin, RemotePluginCatalogError> {
|
|
let plugin = &installed_plugin.plugin;
|
|
// Remote per-skill disabled state (`disabled_skill_names`) is intentionally
|
|
// not projected into skills/list yet; local skills.config remains the
|
|
// supported source for skill enablement.
|
|
Ok(RemoteInstalledPlugin {
|
|
marketplace_name: remote_plugin_canonical_marketplace_name(plugin)?.to_string(),
|
|
id: plugin.id.clone(),
|
|
name: plugin.name.clone(),
|
|
enabled: installed_plugin.enabled,
|
|
})
|
|
}
|
|
|
|
fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option<PluginInterface> {
|
|
let interface = &plugin.release.interface;
|
|
let display_name = non_empty_string(Some(&plugin.release.display_name));
|
|
let default_prompt = interface
|
|
.default_prompt
|
|
.as_ref()
|
|
.and_then(|prompt| normalize_remote_default_prompt(prompt));
|
|
let result = PluginInterface {
|
|
display_name,
|
|
short_description: interface.short_description.clone(),
|
|
long_description: interface.long_description.clone(),
|
|
developer_name: interface.developer_name.clone(),
|
|
category: interface.category.clone(),
|
|
capabilities: interface.capabilities.clone(),
|
|
website_url: interface.website_url.clone(),
|
|
privacy_policy_url: interface.privacy_policy_url.clone(),
|
|
terms_of_service_url: interface.terms_of_service_url.clone(),
|
|
default_prompt,
|
|
brand_color: interface.brand_color.clone(),
|
|
composer_icon: None,
|
|
composer_icon_url: interface.composer_icon_url.clone(),
|
|
logo: None,
|
|
logo_url: interface.logo_url.clone(),
|
|
screenshots: Vec::new(),
|
|
screenshot_urls: interface.screenshot_urls.clone(),
|
|
};
|
|
let has_fields = result.display_name.is_some()
|
|
|| result.short_description.is_some()
|
|
|| result.long_description.is_some()
|
|
|| result.developer_name.is_some()
|
|
|| result.category.is_some()
|
|
|| !result.capabilities.is_empty()
|
|
|| result.website_url.is_some()
|
|
|| result.privacy_policy_url.is_some()
|
|
|| result.terms_of_service_url.is_some()
|
|
|| result.default_prompt.is_some()
|
|
|| result.brand_color.is_some()
|
|
|| result.composer_icon_url.is_some()
|
|
|| result.logo_url.is_some()
|
|
|| !result.screenshot_urls.is_empty();
|
|
has_fields.then_some(result)
|
|
}
|
|
|
|
fn remote_skill_interface_to_info(
|
|
interface: Option<RemotePluginSkillInterfaceResponse>,
|
|
) -> Option<SkillInterface> {
|
|
interface.and_then(|interface| {
|
|
let result = SkillInterface {
|
|
display_name: interface.display_name,
|
|
short_description: interface.short_description,
|
|
icon_small: None,
|
|
icon_large: None,
|
|
brand_color: interface.brand_color,
|
|
default_prompt: interface.default_prompt,
|
|
};
|
|
let has_fields = result.display_name.is_some()
|
|
|| result.short_description.is_some()
|
|
|| result.brand_color.is_some()
|
|
|| result.default_prompt.is_some();
|
|
has_fields.then_some(result)
|
|
})
|
|
}
|
|
|
|
fn remote_plugin_display_name(plugin: &RemotePluginSummary) -> &str {
|
|
plugin
|
|
.interface
|
|
.as_ref()
|
|
.and_then(|interface| interface.display_name.as_deref())
|
|
.unwrap_or(&plugin.name)
|
|
}
|
|
|
|
fn non_empty_string(value: Option<&str>) -> Option<String> {
|
|
value.and_then(|value| {
|
|
let value = value.trim();
|
|
(!value.is_empty()).then(|| value.to_string())
|
|
})
|
|
}
|
|
|
|
fn normalize_remote_default_prompt(prompt: &str) -> Option<Vec<String>> {
|
|
let prompt = prompt.trim();
|
|
if prompt.is_empty() || prompt.chars().count() > MAX_REMOTE_DEFAULT_PROMPT_LEN {
|
|
return None;
|
|
}
|
|
Some(vec![prompt.to_string()])
|
|
}
|
|
|
|
async fn fetch_directory_plugins_for_scope(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: &CodexAuth,
|
|
scope: RemotePluginScope,
|
|
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
|
|
let mut plugins = Vec::new();
|
|
let mut page_token = None;
|
|
loop {
|
|
let response =
|
|
get_remote_plugin_list_page(config, auth, scope, 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 fetch_shared_workspace_plugins(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: &CodexAuth,
|
|
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
|
|
let mut plugins = Vec::new();
|
|
let mut page_token = None;
|
|
loop {
|
|
let response =
|
|
get_remote_shared_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 fetch_installed_plugins_for_scope(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: &CodexAuth,
|
|
scope: RemotePluginScope,
|
|
) -> Result<Vec<RemotePluginInstalledItem>, RemotePluginCatalogError> {
|
|
fetch_installed_plugins_for_scope_with_download_url(
|
|
config, auth, scope, /*include_download_urls*/ false,
|
|
)
|
|
.await
|
|
}
|
|
|
|
async fn fetch_installed_plugins_for_scope_with_download_url(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: &CodexAuth,
|
|
scope: RemotePluginScope,
|
|
include_download_urls: bool,
|
|
) -> Result<Vec<RemotePluginInstalledItem>, RemotePluginCatalogError> {
|
|
let mut plugins = Vec::new();
|
|
let mut page_token = None;
|
|
loop {
|
|
let response = get_remote_plugin_installed_page(
|
|
config,
|
|
auth,
|
|
scope,
|
|
page_token.as_deref(),
|
|
include_download_urls,
|
|
)
|
|
.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_remote_plugin_list_page(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: &CodexAuth,
|
|
scope: RemotePluginScope,
|
|
page_token: Option<&str>,
|
|
) -> Result<RemotePluginListResponse, RemotePluginCatalogError> {
|
|
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
|
let url = format!("{base_url}/ps/plugins/list");
|
|
let client = build_reqwest_client();
|
|
let mut request = authenticated_request(client.get(&url), auth)?;
|
|
request = request.query(&[("scope", scope.api_value())]);
|
|
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 get_remote_shared_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/shared");
|
|
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 get_remote_plugin_installed_page(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: &CodexAuth,
|
|
scope: RemotePluginScope,
|
|
page_token: Option<&str>,
|
|
include_download_urls: bool,
|
|
) -> Result<RemotePluginInstalledResponse, RemotePluginCatalogError> {
|
|
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
|
let url = format!("{base_url}/ps/plugins/installed");
|
|
let client = build_reqwest_client();
|
|
let mut request = authenticated_request(client.get(&url), auth)?;
|
|
request = request.query(&[("scope", scope.api_value())]);
|
|
if include_download_urls {
|
|
request = request.query(&[("includeDownloadUrls", true)]);
|
|
}
|
|
if let Some(page_token) = page_token {
|
|
request = request.query(&[("pageToken", page_token)]);
|
|
}
|
|
send_and_decode(request, &url).await
|
|
}
|
|
|
|
async fn fetch_plugin_detail(
|
|
config: &RemotePluginServiceConfig,
|
|
auth: &CodexAuth,
|
|
plugin_id: &str,
|
|
include_download_urls: bool,
|
|
) -> Result<RemotePluginDirectoryItem, RemotePluginCatalogError> {
|
|
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
|
let url = format!("{base_url}/ps/plugins/{plugin_id}");
|
|
let client = build_reqwest_client();
|
|
let mut request = authenticated_request(client.get(&url), auth)?;
|
|
if include_download_urls {
|
|
request = request.query(&[("includeDownloadUrls", true)]);
|
|
}
|
|
send_and_decode(request, &url).await
|
|
}
|
|
|
|
fn remote_plugin_skill_detail_url(
|
|
config: &RemotePluginServiceConfig,
|
|
plugin_id: &str,
|
|
skill_name: &str,
|
|
) -> Result<String, RemotePluginCatalogError> {
|
|
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
|
|
.map_err(RemotePluginCatalogError::InvalidBaseUrl)?;
|
|
{
|
|
let mut segments = url
|
|
.path_segments_mut()
|
|
.map_err(|()| RemotePluginCatalogError::InvalidBaseUrlPath)?;
|
|
segments.pop_if_empty();
|
|
segments.push("ps");
|
|
segments.push("plugins");
|
|
segments.push(plugin_id);
|
|
segments.push("skills");
|
|
segments.push(skill_name);
|
|
}
|
|
Ok(url.to_string())
|
|
}
|
|
|
|
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> {
|
|
let Some(auth) = auth else {
|
|
return Err(RemotePluginCatalogError::AuthRequired);
|
|
};
|
|
if !auth.uses_codex_backend() {
|
|
return Err(RemotePluginCatalogError::UnsupportedAuthMode);
|
|
}
|
|
Ok(auth)
|
|
}
|
|
|
|
fn authenticated_request(
|
|
request: RequestBuilder,
|
|
auth: &CodexAuth,
|
|
) -> Result<RequestBuilder, RemotePluginCatalogError> {
|
|
Ok(request
|
|
.timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT)
|
|
.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()))
|
|
}
|
|
|
|
async fn send_and_decode<T: for<'de> Deserialize<'de>>(
|
|
request: RequestBuilder,
|
|
url: &str,
|
|
) -> Result<T, RemotePluginCatalogError> {
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.map_err(|source| RemotePluginCatalogError::Request {
|
|
url: url.to_string(),
|
|
source,
|
|
})?;
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
if !status.is_success() {
|
|
return Err(RemotePluginCatalogError::UnexpectedStatus {
|
|
url: url.to_string(),
|
|
status,
|
|
body,
|
|
});
|
|
}
|
|
|
|
serde_json::from_str(&body).map_err(|source| RemotePluginCatalogError::Decode {
|
|
url: url.to_string(),
|
|
source,
|
|
})
|
|
}
|