feat: Support remote plugin list/read. (#18452)

Add a temporary internal remote_plugin feature flag that merges remote
marketplaces into plugin/list and routes plugin/read through the remote
APIs when needed, while keeping pure local marketplaces working as
before.

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
xl-openai
2026-04-21 18:39:07 -07:00
committed by GitHub
parent 536952eeee
commit a978e411f6
21 changed files with 2382 additions and 758 deletions

View File

@@ -3,5 +3,6 @@ pub mod manifest;
pub mod marketplace;
pub mod marketplace_upgrade;
pub mod remote;
pub mod remote_legacy;
pub mod store;
pub mod toggles;

View File

@@ -1,317 +1,656 @@
use codex_app_server_protocol::PluginAuthPolicy;
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_protocol::protocol::Product;
use reqwest::RequestBuilder;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
use std::time::Duration;
use url::Url;
const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated";
const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30);
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";
pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Workspace Plugins";
const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginServiceConfig {
pub chatgpt_base_url: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct RemotePluginStatusSummary {
#[derive(Debug, Clone, PartialEq)]
pub struct RemoteMarketplace {
pub name: String,
#[serde(default = "default_remote_marketplace_name")]
pub marketplace_name: String,
pub enabled: bool,
pub display_name: String,
pub plugins: Vec<RemotePluginSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemotePluginMutationResponse {
#[derive(Debug, Clone, PartialEq)]
pub struct RemotePluginSummary {
pub id: String,
pub name: String,
pub installed: bool,
pub enabled: bool,
pub install_policy: PluginInstallPolicy,
pub auth_policy: PluginAuthPolicy,
pub interface: Option<PluginInterface>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RemotePluginDetail {
pub marketplace_name: String,
pub marketplace_display_name: String,
pub summary: RemotePluginSummary,
pub description: 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, thiserror::Error)]
pub enum RemotePluginMutationError {
#[error("chatgpt authentication required for remote plugin mutation")]
pub enum RemotePluginCatalogError {
#[error("chatgpt authentication required for remote plugin catalog")]
AuthRequired,
#[error(
"chatgpt authentication required for remote plugin mutation; api key auth is not supported"
"chatgpt authentication required for remote plugin catalog; api key auth is not supported"
)]
UnsupportedAuthMode,
#[error("failed to read auth token for remote plugin mutation: {0}")]
#[error("failed to read auth token for remote plugin catalog: {0}")]
AuthToken(#[source] std::io::Error),
#[error("invalid chatgpt base url for remote plugin mutation: {0}")]
InvalidBaseUrl(#[source] url::ParseError),
#[error("chatgpt base url cannot be used for plugin mutation")]
InvalidBaseUrlPath,
#[error("failed to send remote plugin mutation request to {url}: {source}")]
#[error("failed to send remote plugin catalog request to {url}: {source}")]
Request {
url: String,
#[source]
source: reqwest::Error,
},
#[error("remote plugin mutation failed with status {status} from {url}: {body}")]
#[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 mutation response from {url}: {source}")]
#[error("failed to parse remote plugin catalog response from {url}: {source}")]
Decode {
url: String,
#[source]
source: serde_json::Error,
},
#[error(
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
)]
UnexpectedPluginId { expected: String, actual: String },
#[error("remote marketplace `{marketplace_name}` is not supported")]
UnknownMarketplace { marketplace_name: String },
#[error(
"remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
"remote plugin `{plugin_id}` belongs to marketplace `{actual_marketplace_name}`, not `{expected_marketplace_name}`"
)]
UnexpectedEnabledState {
MarketplaceMismatch {
plugin_id: String,
expected_enabled: bool,
actual_enabled: bool,
expected_marketplace_name: String,
actual_marketplace_name: String,
},
}
#[derive(Debug, thiserror::Error)]
pub enum RemotePluginFetchError {
#[error("chatgpt authentication required to sync remote plugins")]
AuthRequired,
#[error(
"chatgpt authentication required to sync remote plugins; api key auth is not supported"
)]
UnsupportedAuthMode,
#[error("failed to read auth token for remote plugin sync: {0}")]
AuthToken(#[source] std::io::Error),
#[error("failed to send remote plugin sync request to {url}: {source}")]
Request {
url: String,
#[source]
source: reqwest::Error,
},
#[error("remote plugin sync request to {url} failed with status {status}: {body}")]
UnexpectedStatus {
url: String,
status: reqwest::StatusCode,
body: String,
},
#[error("failed to parse remote plugin sync response from {url}: {source}")]
Decode {
url: String,
#[source]
source: serde_json::Error,
},
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
enum RemotePluginScope {
#[serde(rename = "GLOBAL")]
Global,
#[serde(rename = "WORKSPACE")]
Workspace,
}
pub async fn fetch_remote_plugin_status(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Vec<RemotePluginStatusSummary>, RemotePluginFetchError> {
let Some(auth) = auth else {
return Err(RemotePluginFetchError::AuthRequired);
};
if !auth.is_chatgpt_auth() {
return Err(RemotePluginFetchError::UnsupportedAuthMode);
impl RemotePluginScope {
fn all() -> [Self; 2] {
[Self::Global, Self::Workspace]
}
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/plugins/list");
let client = build_reqwest_client();
let token = auth
.get_token()
.map_err(RemotePluginFetchError::AuthToken)?;
let mut request = client
.get(&url)
.timeout(REMOTE_PLUGIN_FETCH_TIMEOUT)
.bearer_auth(token);
if let Some(account_id) = auth.get_account_id() {
request = request.header("chatgpt-account-id", account_id);
}
let response = request
.send()
.await
.map_err(|source| RemotePluginFetchError::Request {
url: url.clone(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
}
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
url: url.clone(),
source,
})
}
pub async fn fetch_remote_featured_plugin_ids(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
product: Option<Product>,
) -> Result<Vec<String>, RemotePluginFetchError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/plugins/featured");
let client = build_reqwest_client();
let mut request = client
.get(&url)
.query(&[(
"platform",
product.unwrap_or(Product::Codex).to_app_platform(),
)])
.timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT);
if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) {
let token = auth
.get_token()
.map_err(RemotePluginFetchError::AuthToken)?;
request = request.bearer_auth(token);
if let Some(account_id) = auth.get_account_id() {
request = request.header("chatgpt-account-id", account_id);
fn api_value(self) -> &'static str {
match self {
Self::Global => "GLOBAL",
Self::Workspace => "WORKSPACE",
}
}
let response = request
.send()
.await
.map_err(|source| RemotePluginFetchError::Request {
url: url.clone(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
fn marketplace_name(self) -> &'static str {
match self {
Self::Global => REMOTE_GLOBAL_MARKETPLACE_NAME,
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_NAME,
}
}
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
url: url.clone(),
source,
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 => Some(Self::Workspace),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginPagination {
#[serde(alias = "nextPageToken")]
next_page_token: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginSkillInterfaceResponse {
#[serde(alias = "displayName")]
display_name: Option<String>,
#[serde(alias = "shortDescription")]
short_description: Option<String>,
#[serde(alias = "brandColor")]
brand_color: Option<String>,
#[serde(alias = "defaultPrompt")]
default_prompt: Option<String>,
#[serde(alias = "iconSmallUrl")]
icon_small_url: Option<String>,
#[serde(alias = "iconLargeUrl")]
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 RemotePluginReleaseInterfaceResponse {
#[serde(alias = "shortDescription")]
short_description: Option<String>,
#[serde(alias = "longDescription")]
long_description: Option<String>,
#[serde(alias = "developerName")]
developer_name: Option<String>,
category: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
#[serde(alias = "websiteUrl")]
website_url: Option<String>,
#[serde(alias = "privacyPolicyUrl")]
privacy_policy_url: Option<String>,
#[serde(alias = "termsOfServiceUrl")]
terms_of_service_url: Option<String>,
#[serde(alias = "brandColor")]
brand_color: Option<String>,
#[serde(alias = "defaultPrompt")]
default_prompt: Option<String>,
#[serde(alias = "composerIconUrl")]
composer_icon_url: Option<String>,
#[serde(alias = "logoUrl")]
logo_url: Option<String>,
#[serde(default)]
#[serde(alias = "screenshotUrls")]
screenshot_urls: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginReleaseResponse {
#[serde(alias = "displayName")]
display_name: String,
description: String,
#[serde(default)]
#[serde(alias = "appIds")]
app_ids: Vec<String>,
interface: RemotePluginReleaseInterfaceResponse,
#[serde(default)]
skills: Vec<RemotePluginSkillResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginDirectoryItem {
id: String,
name: String,
scope: RemotePluginScope,
#[serde(alias = "installationPolicy")]
installation_policy: PluginInstallPolicy,
#[serde(alias = "authenticationPolicy")]
authentication_policy: PluginAuthPolicy,
release: RemotePluginReleaseResponse,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginInstalledItem {
#[serde(flatten)]
plugin: RemotePluginDirectoryItem,
enabled: bool,
#[serde(default)]
#[serde(alias = "disabledSkillNames")]
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,
}
pub async fn fetch_remote_marketplaces(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Vec<RemoteMarketplace>, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let mut directory_by_scope =
BTreeMap::<RemotePluginScope, BTreeMap<String, RemotePluginDirectoryItem>>::new();
let mut installed_by_scope =
BTreeMap::<RemotePluginScope, BTreeMap<String, RemotePluginInstalledItem>>::new();
let global = async {
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),
)?;
Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins))
};
let workspace = async {
let scope = RemotePluginScope::Workspace;
let (directory_plugins, installed_plugins) = tokio::try_join!(
fetch_directory_plugins_for_scope(config, auth, scope),
fetch_installed_plugins_for_scope(config, auth, scope),
)?;
Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins))
};
let (global, workspace) = tokio::try_join!(global, workspace)?;
for (scope, directory_plugins, installed_plugins) in [global, workspace] {
if !directory_plugins.is_empty() {
directory_by_scope.insert(
scope,
directory_plugins
.into_iter()
.map(|plugin| (plugin.id.clone(), plugin))
.collect(),
);
}
if !installed_plugins.is_empty() {
installed_by_scope.insert(
scope,
installed_plugins
.into_iter()
.map(|plugin| (plugin.plugin.id.clone(), plugin))
.collect(),
);
}
}
let mut marketplaces = Vec::new();
for scope in RemotePluginScope::all() {
let directory_plugins = directory_by_scope.get(&scope);
let installed_plugins = installed_by_scope.get(&scope);
let plugin_ids = directory_plugins
.into_iter()
.flat_map(|plugins| plugins.keys())
.chain(
installed_plugins
.into_iter()
.flat_map(|plugins| plugins.keys()),
)
.cloned()
.collect::<BTreeSet<_>>();
if plugin_ids.is_empty() {
continue;
}
let mut plugins = plugin_ids
.into_iter()
.filter_map(|plugin_id| {
let directory_plugin =
directory_plugins.and_then(|plugins| plugins.get(&plugin_id));
let installed_plugin =
installed_plugins.and_then(|plugins| plugins.get(&plugin_id));
directory_plugin
.or_else(|| installed_plugin.map(|plugin| &plugin.plugin))
.map(|plugin| build_remote_plugin_summary(plugin, installed_plugin))
})
.collect::<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))
});
marketplaces.push(RemoteMarketplace {
name: scope.marketplace_name().to_string(),
display_name: scope.marketplace_display_name().to_string(),
plugins,
});
}
Ok(marketplaces)
}
pub async fn fetch_remote_plugin_detail(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
marketplace_name: &str,
plugin_id: &str,
) -> 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).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 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_name.to_string(),
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)),
skills,
app_ids: plugin.release.app_ids,
})
}
pub async fn enable_remote_plugin(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_id: &str,
) -> Result<(), RemotePluginMutationError> {
post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?;
Ok(())
fn build_remote_plugin_summary(
plugin: &RemotePluginDirectoryItem,
installed_plugin: Option<&RemotePluginInstalledItem>,
) -> RemotePluginSummary {
RemotePluginSummary {
id: plugin.id.clone(),
name: plugin.name.clone(),
installed: installed_plugin.is_some(),
enabled: installed_plugin.is_some_and(|plugin| plugin.enabled),
install_policy: plugin.installation_policy,
auth_policy: plugin.authentication_policy,
interface: remote_plugin_interface_to_info(plugin),
}
}
pub async fn uninstall_remote_plugin(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_id: &str,
) -> Result<(), RemotePluginMutationError> {
post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?;
Ok(())
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 ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginMutationError> {
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_installed_plugins_for_scope(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
) -> 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()).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())]);
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>,
) -> 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 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,
) -> 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 request = authenticated_request(client.get(&url), auth)?;
send_and_decode(request, &url).await
}
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> {
let Some(auth) = auth else {
return Err(RemotePluginMutationError::AuthRequired);
return Err(RemotePluginCatalogError::AuthRequired);
};
if !auth.is_chatgpt_auth() {
return Err(RemotePluginMutationError::UnsupportedAuthMode);
return Err(RemotePluginCatalogError::UnsupportedAuthMode);
}
Ok(auth)
}
fn default_remote_marketplace_name() -> String {
DEFAULT_REMOTE_MARKETPLACE_NAME.to_string()
}
async fn post_remote_plugin_mutation(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_id: &str,
action: &str,
) -> Result<RemotePluginMutationResponse, RemotePluginMutationError> {
let auth = ensure_chatgpt_auth(auth)?;
let url = remote_plugin_mutation_url(config, plugin_id, action)?;
let client = build_reqwest_client();
fn authenticated_request(
request: RequestBuilder,
auth: &CodexAuth,
) -> Result<RequestBuilder, RemotePluginCatalogError> {
let token = auth
.get_token()
.map_err(RemotePluginMutationError::AuthToken)?;
let mut request = client
.post(url.clone())
.timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT)
.map_err(RemotePluginCatalogError::AuthToken)?;
let mut request = request
.timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT)
.bearer_auth(token);
if let Some(account_id) = auth.get_account_id() {
request = request.header("chatgpt-account-id", account_id);
}
Ok(request)
}
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| RemotePluginMutationError::Request {
url: url.clone(),
.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(RemotePluginMutationError::UnexpectedStatus { url, status, body });
}
let parsed: RemotePluginMutationResponse =
serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode {
url: url.clone(),
source,
})?;
let expected_enabled = action == "enable";
if parsed.id != plugin_id {
return Err(RemotePluginMutationError::UnexpectedPluginId {
expected: plugin_id.to_string(),
actual: parsed.id,
});
}
if parsed.enabled != expected_enabled {
return Err(RemotePluginMutationError::UnexpectedEnabledState {
plugin_id: plugin_id.to_string(),
expected_enabled,
actual_enabled: parsed.enabled,
return Err(RemotePluginCatalogError::UnexpectedStatus {
url: url.to_string(),
status,
body,
});
}
Ok(parsed)
}
fn remote_plugin_mutation_url(
config: &RemotePluginServiceConfig,
plugin_id: &str,
action: &str,
) -> Result<String, RemotePluginMutationError> {
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
.map_err(RemotePluginMutationError::InvalidBaseUrl)?;
{
let mut segments = url
.path_segments_mut()
.map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?;
segments.pop_if_empty();
segments.push("plugins");
segments.push(plugin_id);
segments.push(action);
}
Ok(url.to_string())
serde_json::from_str(&body).map_err(|source| RemotePluginCatalogError::Decode {
url: url.to_string(),
source,
})
}

View File

@@ -0,0 +1,313 @@
use crate::remote::RemotePluginServiceConfig;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use codex_protocol::protocol::Product;
use serde::Deserialize;
use std::time::Duration;
use url::Url;
const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated";
const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct RemotePluginStatusSummary {
pub name: String,
#[serde(default = "default_remote_marketplace_name")]
pub marketplace_name: String,
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemotePluginMutationResponse {
pub id: String,
pub enabled: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum RemotePluginMutationError {
#[error("chatgpt authentication required for remote plugin mutation")]
AuthRequired,
#[error(
"chatgpt authentication required for remote plugin mutation; api key auth is not supported"
)]
UnsupportedAuthMode,
#[error("failed to read auth token for remote plugin mutation: {0}")]
AuthToken(#[source] std::io::Error),
#[error("invalid chatgpt base url for remote plugin mutation: {0}")]
InvalidBaseUrl(#[source] url::ParseError),
#[error("chatgpt base url cannot be used for plugin mutation")]
InvalidBaseUrlPath,
#[error("failed to send remote plugin mutation request to {url}: {source}")]
Request {
url: String,
#[source]
source: reqwest::Error,
},
#[error("remote plugin mutation failed with status {status} from {url}: {body}")]
UnexpectedStatus {
url: String,
status: reqwest::StatusCode,
body: String,
},
#[error("failed to parse remote plugin mutation response from {url}: {source}")]
Decode {
url: String,
#[source]
source: serde_json::Error,
},
#[error(
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
)]
UnexpectedPluginId { 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,
},
}
#[derive(Debug, thiserror::Error)]
pub enum RemotePluginFetchError {
#[error("chatgpt authentication required to sync remote plugins")]
AuthRequired,
#[error(
"chatgpt authentication required to sync remote plugins; api key auth is not supported"
)]
UnsupportedAuthMode,
#[error("failed to read auth token for remote plugin sync: {0}")]
AuthToken(#[source] std::io::Error),
#[error("failed to send remote plugin sync request to {url}: {source}")]
Request {
url: String,
#[source]
source: reqwest::Error,
},
#[error("remote plugin sync request to {url} failed with status {status}: {body}")]
UnexpectedStatus {
url: String,
status: reqwest::StatusCode,
body: String,
},
#[error("failed to parse remote plugin sync response from {url}: {source}")]
Decode {
url: String,
#[source]
source: serde_json::Error,
},
}
pub async fn fetch_remote_plugin_status(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Vec<RemotePluginStatusSummary>, RemotePluginFetchError> {
let Some(auth) = auth else {
return Err(RemotePluginFetchError::AuthRequired);
};
if !auth.is_chatgpt_auth() {
return Err(RemotePluginFetchError::UnsupportedAuthMode);
}
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/plugins/list");
let client = build_reqwest_client();
let token = auth
.get_token()
.map_err(RemotePluginFetchError::AuthToken)?;
let mut request = client
.get(&url)
.timeout(REMOTE_PLUGIN_FETCH_TIMEOUT)
.bearer_auth(token);
if let Some(account_id) = auth.get_account_id() {
request = request.header("chatgpt-account-id", account_id);
}
let response = request
.send()
.await
.map_err(|source| RemotePluginFetchError::Request {
url: url.clone(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
}
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
url: url.clone(),
source,
})
}
pub async fn fetch_remote_featured_plugin_ids(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
product: Option<Product>,
) -> Result<Vec<String>, RemotePluginFetchError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/plugins/featured");
let client = build_reqwest_client();
let mut request = client
.get(&url)
.query(&[(
"platform",
product.unwrap_or(Product::Codex).to_app_platform(),
)])
.timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT);
if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) {
let token = auth
.get_token()
.map_err(RemotePluginFetchError::AuthToken)?;
request = request.bearer_auth(token);
if let Some(account_id) = auth.get_account_id() {
request = request.header("chatgpt-account-id", account_id);
}
}
let response = request
.send()
.await
.map_err(|source| RemotePluginFetchError::Request {
url: url.clone(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
}
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
url: url.clone(),
source,
})
}
pub async fn enable_remote_plugin(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_id: &str,
) -> Result<(), RemotePluginMutationError> {
post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?;
Ok(())
}
pub async fn uninstall_remote_plugin(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_id: &str,
) -> Result<(), RemotePluginMutationError> {
post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?;
Ok(())
}
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginMutationError> {
let Some(auth) = auth else {
return Err(RemotePluginMutationError::AuthRequired);
};
if !auth.is_chatgpt_auth() {
return Err(RemotePluginMutationError::UnsupportedAuthMode);
}
Ok(auth)
}
fn default_remote_marketplace_name() -> String {
DEFAULT_REMOTE_MARKETPLACE_NAME.to_string()
}
async fn post_remote_plugin_mutation(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_id: &str,
action: &str,
) -> Result<RemotePluginMutationResponse, RemotePluginMutationError> {
let auth = ensure_chatgpt_auth(auth)?;
let url = remote_plugin_mutation_url(config, plugin_id, action)?;
let client = build_reqwest_client();
let token = auth
.get_token()
.map_err(RemotePluginMutationError::AuthToken)?;
let mut request = client
.post(url.clone())
.timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT)
.bearer_auth(token);
if let Some(account_id) = auth.get_account_id() {
request = request.header("chatgpt-account-id", account_id);
}
let response = request
.send()
.await
.map_err(|source| RemotePluginMutationError::Request {
url: url.clone(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(RemotePluginMutationError::UnexpectedStatus { url, status, body });
}
let parsed: RemotePluginMutationResponse =
serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode {
url: url.clone(),
source,
})?;
let expected_enabled = action == "enable";
if parsed.id != plugin_id {
return Err(RemotePluginMutationError::UnexpectedPluginId {
expected: plugin_id.to_string(),
actual: parsed.id,
});
}
if parsed.enabled != expected_enabled {
return Err(RemotePluginMutationError::UnexpectedEnabledState {
plugin_id: plugin_id.to_string(),
expected_enabled,
actual_enabled: parsed.enabled,
});
}
Ok(parsed)
}
fn remote_plugin_mutation_url(
config: &RemotePluginServiceConfig,
plugin_id: &str,
action: &str,
) -> Result<String, RemotePluginMutationError> {
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
.map_err(RemotePluginMutationError::InvalidBaseUrl)?;
{
let mut segments = url
.path_segments_mut()
.map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?;
segments.pop_if_empty();
segments.push("plugins");
segments.push(plugin_id);
segments.push(action);
}
Ok(url.to_string())
}