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
Unverified
parent 536952eeee
commit a978e411f6
21 changed files with 2382 additions and 758 deletions
+1
View File
@@ -1820,6 +1820,7 @@ dependencies = [
"codex-model-provider-info",
"codex-models-manager",
"codex-otel",
"codex-plugin",
"codex-protocol",
"codex-rmcp-client",
"codex-rollout",
@@ -10965,7 +10965,14 @@
"type": "string"
},
"marketplacePath": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"mcpServers": {
"items": {
@@ -10986,7 +10993,6 @@
"required": [
"apps",
"marketplaceName",
"marketplacePath",
"mcpServers",
"skills",
"summary"
@@ -13356,7 +13362,14 @@
"type": "string"
},
"path": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"shortDescription": {
"type": [
@@ -13368,8 +13381,7 @@
"required": [
"description",
"enabled",
"name",
"path"
"name"
],
"type": "object"
},
@@ -7727,7 +7727,14 @@
"type": "string"
},
"marketplacePath": {
"$ref": "#/definitions/AbsolutePathBuf"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"mcpServers": {
"items": {
@@ -7748,7 +7755,6 @@
"required": [
"apps",
"marketplaceName",
"marketplacePath",
"mcpServers",
"skills",
"summary"
@@ -11250,7 +11256,14 @@
"type": "string"
},
"path": {
"$ref": "#/definitions/AbsolutePathBuf"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"shortDescription": {
"type": [
@@ -11262,8 +11275,7 @@
"required": [
"description",
"enabled",
"name",
"path"
"name"
],
"type": "object"
},
@@ -62,7 +62,14 @@
"type": "string"
},
"marketplacePath": {
"$ref": "#/definitions/AbsolutePathBuf"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"mcpServers": {
"items": {
@@ -83,7 +90,6 @@
"required": [
"apps",
"marketplaceName",
"marketplacePath",
"mcpServers",
"skills",
"summary"
@@ -423,7 +429,14 @@
"type": "string"
},
"path": {
"$ref": "#/definitions/AbsolutePathBuf"
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
]
},
"shortDescription": {
"type": [
@@ -435,8 +448,7 @@
"required": [
"description",
"enabled",
"name",
"path"
"name"
],
"type": "object"
}
@@ -6,4 +6,4 @@ import type { AppSummary } from "./AppSummary";
import type { PluginSummary } from "./PluginSummary";
import type { SkillSummary } from "./SkillSummary";
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, apps: Array<AppSummary>, mcpServers: Array<string>, };
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, apps: Array<AppSummary>, mcpServers: Array<string>, };
@@ -4,4 +4,4 @@
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { SkillInterface } from "./SkillInterface";
export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: AbsolutePathBuf, enabled: boolean, };
export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: AbsolutePathBuf | null, enabled: boolean, };
@@ -4018,7 +4018,7 @@ pub struct PluginSummary {
#[ts(export_to = "v2/")]
pub struct PluginDetail {
pub marketplace_name: String,
pub marketplace_path: AbsolutePathBuf,
pub marketplace_path: Option<AbsolutePathBuf>,
pub summary: PluginSummary,
pub description: Option<String>,
pub skills: Vec<SkillSummary>,
@@ -4034,7 +4034,7 @@ pub struct SkillSummary {
pub description: String,
pub short_description: Option<String>,
pub interface: Option<SkillInterface>,
pub path: AbsolutePathBuf,
pub path: Option<AbsolutePathBuf>,
pub enabled: bool,
}
+1
View File
@@ -40,6 +40,7 @@ codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-git-utils = { workspace = true }
codex-otel = { workspace = true }
codex-plugin = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-cli = { workspace = true }
codex-utils-pty = { workspace = true }
@@ -32,6 +32,7 @@ use codex_app_server_protocol::AccountUpdatedNotification;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::AppsListParams;
use codex_app_server_protocol::AppsListResponse;
use codex_app_server_protocol::AskForApproval;
@@ -260,6 +261,11 @@ use codex_core_plugins::loader::load_plugin_mcp_servers;
use codex_core_plugins::manifest::PluginManifestInterface;
use codex_core_plugins::marketplace::MarketplaceError;
use codex_core_plugins::marketplace::MarketplacePluginSource;
use codex_core_plugins::remote::RemoteMarketplace;
use codex_core_plugins::remote::RemotePluginCatalogError;
use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::LOCAL_FS;
use codex_features::FEATURES;
@@ -374,6 +380,7 @@ use codex_app_server_protocol::ServerRequest;
mod apps_list_helpers;
mod plugin_app_helpers;
mod plugin_mcp_oauth;
mod plugins;
mod token_usage_replay;
use crate::filters::compute_source_filters;
@@ -6523,7 +6530,6 @@ impl CodexMessageProcessor {
.send_response(request_id, SkillsListResponse { data })
.await;
}
async fn marketplace_remove(
&self,
request_id: ConnectionRequestId,
@@ -6557,120 +6563,6 @@ impl CodexMessageProcessor {
}
}
}
async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) {
let plugins_manager = self.thread_manager.plugins_manager();
let PluginListParams { cwds } = params;
let roots = cwds.unwrap_or_default();
plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots);
let config = match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(config) => config,
Err(err) => {
self.outgoing.send_error(request_id, err).await;
return;
}
};
let auth = self.auth_manager.auth().await;
let config_for_marketplace_listing = config.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
let (data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || {
let outcome = plugins_manager_for_marketplace_listing
.list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?;
Ok::<
(
Vec<PluginMarketplaceEntry>,
Vec<codex_app_server_protocol::MarketplaceLoadErrorInfo>,
),
MarketplaceError,
>((
outcome
.marketplaces
.into_iter()
.map(|marketplace| PluginMarketplaceEntry {
name: marketplace.name,
path: Some(marketplace.path),
interface: marketplace.interface.map(|interface| MarketplaceInterface {
display_name: interface.display_name,
}),
plugins: marketplace
.plugins
.into_iter()
.map(|plugin| PluginSummary {
id: plugin.id,
installed: plugin.installed,
enabled: plugin.enabled,
name: plugin.name,
source: marketplace_plugin_source_to_info(plugin.source),
install_policy: plugin.policy.installation.into(),
auth_policy: plugin.policy.authentication.into(),
interface: plugin.interface.map(local_plugin_interface_to_info),
})
.collect(),
})
.collect(),
outcome
.errors
.into_iter()
.map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo {
marketplace_path: err.path,
message: err.message,
})
.collect(),
))
})
.await
{
Ok(Ok(outcome)) => outcome,
Ok(Err(err)) => {
self.send_marketplace_error(request_id, err, "list marketplace plugins")
.await;
return;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to list marketplace plugins: {err}"),
)
.await;
return;
}
};
let featured_plugin_ids = if data
.iter()
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
{
match plugins_manager
.featured_plugin_ids_for_config(&config, auth.as_ref())
.await
{
Ok(featured_plugin_ids) => featured_plugin_ids,
Err(err) => {
warn!(
error = %err,
"plugin/list featured plugin fetch failed; returning empty featured ids"
);
Vec::new()
}
}
} else {
Vec::new()
};
self.outgoing
.send_response(
request_id,
PluginListResponse {
marketplaces: data,
marketplace_load_errors,
featured_plugin_ids,
},
)
.await;
}
async fn marketplace_add(&self, request_id: ConnectionRequestId, params: MarketplaceAddParams) {
let result = add_marketplace_to_codex_home(
self.config.codex_home.to_path_buf(),
@@ -6704,111 +6596,6 @@ impl CodexMessageProcessor {
}
}
async fn plugin_read(&self, request_id: ConnectionRequestId, params: PluginReadParams) {
let plugins_manager = self.thread_manager.plugins_manager();
let PluginReadParams {
marketplace_path,
remote_marketplace_name,
plugin_name,
} = params;
let marketplace_path = match (marketplace_path, remote_marketplace_name) {
(Some(marketplace_path), None) => marketplace_path,
(None, Some(remote_marketplace_name)) => {
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"remote plugin read is not supported yet for marketplace {remote_marketplace_name}"
),
data: None,
},
)
.await;
return;
}
(Some(_), Some(_)) | (None, None) => {
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName".to_string(),
data: None,
},
)
.await;
return;
}
};
let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf);
let config = match self.load_latest_config(config_cwd).await {
Ok(config) => config,
Err(err) => {
self.outgoing.send_error(request_id, err).await;
return;
}
};
let request = PluginReadRequest {
plugin_name,
marketplace_path,
};
let outcome = match plugins_manager
.read_plugin_for_config(&config, &request)
.await
{
Ok(outcome) => outcome,
Err(err) => {
self.send_marketplace_error(request_id, err, "read plugin details")
.await;
return;
}
};
let environment_manager = self.thread_manager.environment_manager();
let app_summaries = plugin_app_helpers::load_plugin_app_summaries(
&config,
&outcome.plugin.apps,
&environment_manager,
)
.await;
let visible_skills = outcome
.plugin
.skills
.iter()
.filter(|skill| {
skill.matches_product_restriction_for_product(
self.thread_manager.session_source().restriction_product(),
)
})
.cloned()
.collect::<Vec<_>>();
let plugin = PluginDetail {
marketplace_name: outcome.marketplace_name,
marketplace_path: outcome.marketplace_path,
summary: PluginSummary {
id: outcome.plugin.id,
name: outcome.plugin.name,
source: marketplace_plugin_source_to_info(outcome.plugin.source),
installed: outcome.plugin.installed,
enabled: outcome.plugin.enabled,
install_policy: outcome.plugin.policy.installation.into(),
auth_policy: outcome.plugin.policy.authentication.into(),
interface: outcome.plugin.interface.map(local_plugin_interface_to_info),
},
description: outcome.plugin.description,
skills: plugin_skills_to_info(&visible_skills, &outcome.plugin.disabled_skill_paths),
apps: app_summaries,
mcp_servers: outcome.plugin.mcp_server_names,
};
self.outgoing
.send_response(request_id, PluginReadResponse { plugin })
.await;
}
async fn skills_config_write(
&self,
request_id: ConnectionRequestId,
@@ -6867,260 +6654,6 @@ impl CodexMessageProcessor {
}
}
async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) {
let PluginInstallParams {
marketplace_path,
remote_marketplace_name,
plugin_name,
} = params;
let marketplace_path = match (marketplace_path, remote_marketplace_name) {
(Some(marketplace_path), None) => marketplace_path,
(None, Some(remote_marketplace_name)) => {
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"remote plugin install is not supported yet for marketplace {remote_marketplace_name}"
),
data: None,
},
)
.await;
return;
}
(Some(_), Some(_)) | (None, None) => {
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName".to_string(),
data: None,
},
)
.await;
return;
}
};
let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf);
let plugins_manager = self.thread_manager.plugins_manager();
let request = PluginInstallRequest {
plugin_name,
marketplace_path,
};
let install_result = plugins_manager.install_plugin(request).await;
match install_result {
Ok(result) => {
let config = match self.load_latest_config(config_cwd).await {
Ok(config) => config,
Err(err) => {
warn!(
"failed to reload config after plugin install, using current config: {err:?}"
);
self.config.as_ref().clone()
}
};
self.clear_plugin_related_caches();
let plugin_mcp_servers =
load_plugin_mcp_servers(result.installed_path.as_path()).await;
if !plugin_mcp_servers.is_empty() {
if let Err(err) = self.queue_mcp_server_refresh_for_config(&config).await {
warn!(
plugin = result.plugin_id.as_key(),
"failed to queue MCP refresh after plugin install: {err:?}"
);
}
self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers)
.await;
}
let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await;
let auth = self.auth_manager.auth().await;
let apps_needing_auth = if plugin_apps.is_empty()
|| !config.features.apps_enabled_for_auth(
auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth),
) {
Vec::new()
} else {
let environment_manager = self.thread_manager.environment_manager();
let (all_connectors_result, accessible_connectors_result) = tokio::join!(
connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true),
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config, /*force_refetch*/ true, &environment_manager
),
);
let all_connectors = match all_connectors_result {
Ok(connectors) => connectors,
Err(err) => {
warn!(
plugin = result.plugin_id.as_key(),
"failed to load app metadata after plugin install: {err:#}"
);
connectors::list_cached_all_connectors(&config)
.await
.unwrap_or_default()
}
};
let all_connectors =
connectors::connectors_for_plugin_apps(all_connectors, &plugin_apps);
let (accessible_connectors, codex_apps_ready) =
match accessible_connectors_result {
Ok(status) => (status.connectors, status.codex_apps_ready),
Err(err) => {
warn!(
plugin = result.plugin_id.as_key(),
"failed to load accessible apps after plugin install: {err:#}"
);
(
connectors::list_cached_accessible_connectors_from_mcp_tools(
&config,
)
.await
.unwrap_or_default(),
false,
)
}
};
if !codex_apps_ready {
warn!(
plugin = result.plugin_id.as_key(),
"codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check"
);
}
plugin_app_helpers::plugin_apps_needing_auth(
&all_connectors,
&accessible_connectors,
&plugin_apps,
codex_apps_ready,
)
};
self.outgoing
.send_response(
request_id,
PluginInstallResponse {
auth_policy: result.auth_policy.into(),
apps_needing_auth,
},
)
.await;
}
Err(err) => {
if err.is_invalid_request() {
self.send_invalid_request_error(request_id, err.to_string())
.await;
return;
}
match err {
CorePluginInstallError::Marketplace(err) => {
self.send_marketplace_error(request_id, err, "install plugin")
.await;
}
CorePluginInstallError::Config(err) => {
self.send_internal_error(
request_id,
format!("failed to persist installed plugin config: {err}"),
)
.await;
}
CorePluginInstallError::Remote(err) => {
self.send_internal_error(
request_id,
format!("failed to enable remote plugin: {err}"),
)
.await;
}
CorePluginInstallError::Join(err) => {
self.send_internal_error(
request_id,
format!("failed to install plugin: {err}"),
)
.await;
}
CorePluginInstallError::Store(err) => {
self.send_internal_error(
request_id,
format!("failed to install plugin: {err}"),
)
.await;
}
}
}
}
}
async fn plugin_uninstall(
&self,
request_id: ConnectionRequestId,
params: PluginUninstallParams,
) {
let PluginUninstallParams { plugin_id } = params;
let plugins_manager = self.thread_manager.plugins_manager();
let uninstall_result = plugins_manager.uninstall_plugin(plugin_id).await;
match uninstall_result {
Ok(()) => {
self.clear_plugin_related_caches();
self.outgoing
.send_response(request_id, PluginUninstallResponse {})
.await;
}
Err(err) => {
if err.is_invalid_request() {
self.send_invalid_request_error(request_id, err.to_string())
.await;
return;
}
match err {
CorePluginUninstallError::Config(err) => {
self.send_internal_error(
request_id,
format!("failed to clear plugin config: {err}"),
)
.await;
}
CorePluginUninstallError::Remote(err) => {
self.send_internal_error(
request_id,
format!("failed to uninstall remote plugin: {err}"),
)
.await;
}
CorePluginUninstallError::Join(err) => {
self.send_internal_error(
request_id,
format!("failed to uninstall plugin: {err}"),
)
.await;
}
CorePluginUninstallError::Store(err) => {
self.send_internal_error(
request_id,
format!("failed to uninstall plugin: {err}"),
)
.await;
}
CorePluginUninstallError::InvalidPluginId(_) => {
unreachable!("invalid plugin ids are handled above");
}
}
}
}
}
async fn turn_start(
&self,
request_id: ConnectionRequestId,
@@ -9245,7 +8778,7 @@ fn plugin_skills_to_info(
default_prompt: interface.default_prompt,
}
}),
path: skill.path_to_skills_md.clone(),
path: Some(skill.path_to_skills_md.clone()),
enabled: !disabled_skill_paths.contains(&skill.path_to_skills_md),
})
.collect()
@@ -0,0 +1,692 @@
use super::*;
use codex_plugin::validate_plugin_segment;
impl CodexMessageProcessor {
pub(super) async fn plugin_list(
&self,
request_id: ConnectionRequestId,
params: PluginListParams,
) {
let plugins_manager = self.thread_manager.plugins_manager();
let PluginListParams { cwds } = params;
let roots = cwds.unwrap_or_default();
let config = match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(config) => config,
Err(err) => {
self.outgoing.send_error(request_id, err).await;
return;
}
};
if !config.features.enabled(Feature::Plugins) {
self.outgoing
.send_response(
request_id,
PluginListResponse {
marketplaces: Vec::new(),
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
},
)
.await;
return;
}
plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots);
let auth = self.auth_manager.auth().await;
let config_for_marketplace_listing = config.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
let (mut data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || {
let outcome = plugins_manager_for_marketplace_listing
.list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?;
Ok::<
(
Vec<PluginMarketplaceEntry>,
Vec<codex_app_server_protocol::MarketplaceLoadErrorInfo>,
),
MarketplaceError,
>((
outcome
.marketplaces
.into_iter()
.map(|marketplace| PluginMarketplaceEntry {
name: marketplace.name,
path: Some(marketplace.path),
interface: marketplace.interface.map(|interface| MarketplaceInterface {
display_name: interface.display_name,
}),
plugins: marketplace
.plugins
.into_iter()
.map(|plugin| PluginSummary {
id: plugin.id,
installed: plugin.installed,
enabled: plugin.enabled,
name: plugin.name,
source: marketplace_plugin_source_to_info(plugin.source),
install_policy: plugin.policy.installation.into(),
auth_policy: plugin.policy.authentication.into(),
interface: plugin.interface.map(local_plugin_interface_to_info),
})
.collect(),
})
.collect(),
outcome
.errors
.into_iter()
.map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo {
marketplace_path: err.path,
message: err.message,
})
.collect(),
))
})
.await
{
Ok(Ok(outcome)) => outcome,
Ok(Err(err)) => {
self.send_marketplace_error(request_id, err, "list marketplace plugins")
.await;
return;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to list marketplace plugins: {err}"),
)
.await;
return;
}
};
if config.features.enabled(Feature::RemotePlugin) {
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
match codex_core_plugins::remote::fetch_remote_marketplaces(
&remote_plugin_service_config,
auth.as_ref(),
)
.await
{
Ok(remote_marketplaces) => {
for remote_marketplace in remote_marketplaces
.into_iter()
.map(remote_marketplace_to_info)
{
if let Some(existing) = data
.iter_mut()
.find(|marketplace| marketplace.name == remote_marketplace.name)
{
*existing = remote_marketplace;
} else {
data.push(remote_marketplace);
}
}
}
Err(
RemotePluginCatalogError::AuthRequired
| RemotePluginCatalogError::UnsupportedAuthMode,
) => {}
Err(err) => {
warn!(
error = %err,
"plugin/list remote plugin catalog fetch failed; returning local marketplaces only"
);
}
}
}
let featured_plugin_ids = if data
.iter()
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
{
match plugins_manager
.featured_plugin_ids_for_config(&config, auth.as_ref())
.await
{
Ok(featured_plugin_ids) => featured_plugin_ids,
Err(err) => {
warn!(
error = %err,
"plugin/list featured plugin fetch failed; returning empty featured ids"
);
Vec::new()
}
}
} else {
Vec::new()
};
self.outgoing
.send_response(
request_id,
PluginListResponse {
marketplaces: data,
marketplace_load_errors,
featured_plugin_ids,
},
)
.await;
}
pub(super) async fn plugin_read(
&self,
request_id: ConnectionRequestId,
params: PluginReadParams,
) {
let plugins_manager = self.thread_manager.plugins_manager();
let PluginReadParams {
marketplace_path,
remote_marketplace_name,
plugin_name,
} = params;
let read_source = match (marketplace_path, remote_marketplace_name) {
(Some(marketplace_path), None) => Ok(marketplace_path),
(None, Some(remote_marketplace_name)) => Err(remote_marketplace_name),
(Some(_), Some(_)) | (None, None) => {
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName".to_string(),
data: None,
},
)
.await;
return;
}
};
let config_cwd = read_source.as_ref().ok().and_then(|marketplace_path| {
marketplace_path.as_path().parent().map(Path::to_path_buf)
});
let config = match self.load_latest_config(config_cwd).await {
Ok(config) => config,
Err(err) => {
self.outgoing.send_error(request_id, err).await;
return;
}
};
let plugin = match read_source {
Ok(marketplace_path) => {
let request = PluginReadRequest {
plugin_name,
marketplace_path,
};
let outcome = match plugins_manager
.read_plugin_for_config(&config, &request)
.await
{
Ok(outcome) => outcome,
Err(err) => {
self.send_marketplace_error(request_id, err, "read plugin details")
.await;
return;
}
};
let environment_manager = self.thread_manager.environment_manager();
let app_summaries = plugin_app_helpers::load_plugin_app_summaries(
&config,
&outcome.plugin.apps,
&environment_manager,
)
.await;
let visible_skills = outcome
.plugin
.skills
.iter()
.filter(|skill| {
skill.matches_product_restriction_for_product(
self.thread_manager.session_source().restriction_product(),
)
})
.cloned()
.collect::<Vec<_>>();
PluginDetail {
marketplace_name: outcome.marketplace_name,
marketplace_path: outcome.marketplace_path,
summary: PluginSummary {
id: outcome.plugin.id,
name: outcome.plugin.name,
source: marketplace_plugin_source_to_info(outcome.plugin.source),
installed: outcome.plugin.installed,
enabled: outcome.plugin.enabled,
install_policy: outcome.plugin.policy.installation.into(),
auth_policy: outcome.plugin.policy.authentication.into(),
interface: outcome.plugin.interface.map(local_plugin_interface_to_info),
},
description: outcome.plugin.description,
skills: plugin_skills_to_info(
&visible_skills,
&outcome.plugin.disabled_skill_paths,
),
apps: app_summaries,
mcp_servers: outcome.plugin.mcp_server_names,
}
}
Err(remote_marketplace_name) => {
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"remote plugin read is not enabled for marketplace {remote_marketplace_name}"
),
data: None,
},
)
.await;
return;
}
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
if let Err(err) = validate_plugin_segment(&plugin_name, "plugin name") {
self.send_invalid_request_error(
request_id,
format!("invalid remote plugin id: {err}"),
)
.await;
return;
}
let remote_plugin_id = format!("{plugin_name}@{remote_marketplace_name}");
let remote_detail = match codex_core_plugins::remote::fetch_remote_plugin_detail(
&remote_plugin_service_config,
auth.as_ref(),
&remote_marketplace_name,
&remote_plugin_id,
)
.await
{
Ok(remote_detail) => remote_detail,
Err(err) => {
self.outgoing
.send_error(
request_id,
remote_plugin_catalog_error_to_jsonrpc(
err,
"read remote plugin details",
),
)
.await;
return;
}
};
let plugin_apps = remote_detail
.app_ids
.iter()
.cloned()
.map(codex_core::plugins::AppConnectorId)
.collect::<Vec<_>>();
let environment_manager = self.thread_manager.environment_manager();
let app_summaries = plugin_app_helpers::load_plugin_app_summaries(
&config,
&plugin_apps,
&environment_manager,
)
.await;
remote_plugin_detail_to_info(remote_detail, app_summaries)
}
};
self.outgoing
.send_response(request_id, PluginReadResponse { plugin })
.await;
}
pub(super) async fn plugin_install(
&self,
request_id: ConnectionRequestId,
params: PluginInstallParams,
) {
let PluginInstallParams {
marketplace_path,
remote_marketplace_name,
plugin_name,
} = params;
let marketplace_path = match (marketplace_path, remote_marketplace_name) {
(Some(marketplace_path), None) => marketplace_path,
(None, Some(remote_marketplace_name)) => {
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"remote plugin install is not supported yet for marketplace {remote_marketplace_name}"
),
data: None,
},
)
.await;
return;
}
(Some(_), Some(_)) | (None, None) => {
self.outgoing
.send_error(
request_id,
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName".to_string(),
data: None,
},
)
.await;
return;
}
};
let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf);
let plugins_manager = self.thread_manager.plugins_manager();
let request = PluginInstallRequest {
plugin_name,
marketplace_path,
};
let install_result = plugins_manager.install_plugin(request).await;
match install_result {
Ok(result) => {
let config = match self.load_latest_config(config_cwd).await {
Ok(config) => config,
Err(err) => {
warn!(
"failed to reload config after plugin install, using current config: {err:?}"
);
self.config.as_ref().clone()
}
};
self.clear_plugin_related_caches();
let plugin_mcp_servers =
load_plugin_mcp_servers(result.installed_path.as_path()).await;
if !plugin_mcp_servers.is_empty() {
if let Err(err) = self.queue_mcp_server_refresh_for_config(&config).await {
warn!(
plugin = result.plugin_id.as_key(),
"failed to queue MCP refresh after plugin install: {err:?}"
);
}
self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers)
.await;
}
let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await;
let auth = self.auth_manager.auth().await;
let apps_needing_auth = if plugin_apps.is_empty()
|| !config.features.apps_enabled_for_auth(
auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth),
) {
Vec::new()
} else {
let environment_manager = self.thread_manager.environment_manager();
let (all_connectors_result, accessible_connectors_result) = tokio::join!(
connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true),
connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager(
&config, /*force_refetch*/ true, &environment_manager
),
);
let all_connectors = match all_connectors_result {
Ok(connectors) => connectors,
Err(err) => {
warn!(
plugin = result.plugin_id.as_key(),
"failed to load app metadata after plugin install: {err:#}"
);
connectors::list_cached_all_connectors(&config)
.await
.unwrap_or_default()
}
};
let all_connectors =
connectors::connectors_for_plugin_apps(all_connectors, &plugin_apps);
let (accessible_connectors, codex_apps_ready) =
match accessible_connectors_result {
Ok(status) => (status.connectors, status.codex_apps_ready),
Err(err) => {
warn!(
plugin = result.plugin_id.as_key(),
"failed to load accessible apps after plugin install: {err:#}"
);
(
connectors::list_cached_accessible_connectors_from_mcp_tools(
&config,
)
.await
.unwrap_or_default(),
false,
)
}
};
if !codex_apps_ready {
warn!(
plugin = result.plugin_id.as_key(),
"codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check"
);
}
plugin_app_helpers::plugin_apps_needing_auth(
&all_connectors,
&accessible_connectors,
&plugin_apps,
codex_apps_ready,
)
};
self.outgoing
.send_response(
request_id,
PluginInstallResponse {
auth_policy: result.auth_policy.into(),
apps_needing_auth,
},
)
.await;
}
Err(err) => {
if err.is_invalid_request() {
self.send_invalid_request_error(request_id, err.to_string())
.await;
return;
}
match err {
CorePluginInstallError::Marketplace(err) => {
self.send_marketplace_error(request_id, err, "install plugin")
.await;
}
CorePluginInstallError::Config(err) => {
self.send_internal_error(
request_id,
format!("failed to persist installed plugin config: {err}"),
)
.await;
}
CorePluginInstallError::Remote(err) => {
self.send_internal_error(
request_id,
format!("failed to enable remote plugin: {err}"),
)
.await;
}
CorePluginInstallError::Join(err) => {
self.send_internal_error(
request_id,
format!("failed to install plugin: {err}"),
)
.await;
}
CorePluginInstallError::Store(err) => {
self.send_internal_error(
request_id,
format!("failed to install plugin: {err}"),
)
.await;
}
}
}
}
}
pub(super) async fn plugin_uninstall(
&self,
request_id: ConnectionRequestId,
params: PluginUninstallParams,
) {
let PluginUninstallParams { plugin_id } = params;
let plugins_manager = self.thread_manager.plugins_manager();
let uninstall_result = plugins_manager.uninstall_plugin(plugin_id).await;
match uninstall_result {
Ok(()) => {
self.clear_plugin_related_caches();
self.outgoing
.send_response(request_id, PluginUninstallResponse {})
.await;
}
Err(err) => {
if err.is_invalid_request() {
self.send_invalid_request_error(request_id, err.to_string())
.await;
return;
}
match err {
CorePluginUninstallError::Config(err) => {
self.send_internal_error(
request_id,
format!("failed to clear plugin config: {err}"),
)
.await;
}
CorePluginUninstallError::Remote(err) => {
self.send_internal_error(
request_id,
format!("failed to uninstall remote plugin: {err}"),
)
.await;
}
CorePluginUninstallError::Join(err) => {
self.send_internal_error(
request_id,
format!("failed to uninstall plugin: {err}"),
)
.await;
}
CorePluginUninstallError::Store(err) => {
self.send_internal_error(
request_id,
format!("failed to uninstall plugin: {err}"),
)
.await;
}
CorePluginUninstallError::InvalidPluginId(_) => {
unreachable!("invalid plugin ids are handled above");
}
}
}
}
}
}
fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry {
PluginMarketplaceEntry {
name: marketplace.name,
path: None,
interface: Some(MarketplaceInterface {
display_name: Some(marketplace.display_name),
}),
plugins: marketplace
.plugins
.into_iter()
.map(remote_plugin_summary_to_info)
.collect(),
}
}
fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginSummary {
PluginSummary {
id: summary.id,
name: summary.name,
source: PluginSource::Remote,
installed: summary.installed,
enabled: summary.enabled,
install_policy: summary.install_policy,
auth_policy: summary.auth_policy,
interface: summary.interface,
}
}
fn remote_plugin_detail_to_info(
detail: RemoteCatalogPluginDetail,
apps: Vec<AppSummary>,
) -> PluginDetail {
PluginDetail {
marketplace_name: detail.marketplace_name,
marketplace_path: None,
summary: remote_plugin_summary_to_info(detail.summary),
description: detail.description,
skills: detail
.skills
.into_iter()
.map(|skill| SkillSummary {
name: skill.name,
description: skill.description,
short_description: skill.short_description,
interface: skill.interface,
path: None,
enabled: skill.enabled,
})
.collect(),
apps,
mcp_servers: Vec::new(),
}
}
fn remote_plugin_catalog_error_to_jsonrpc(
err: RemotePluginCatalogError,
context: &str,
) -> JSONRPCErrorError {
match err {
RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode => {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
}
}
RemotePluginCatalogError::UnknownMarketplace { .. }
| RemotePluginCatalogError::MarketplaceMismatch { .. } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
},
RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
}
}
RemotePluginCatalogError::AuthToken(_)
| RemotePluginCatalogError::Request { .. }
| RemotePluginCatalogError::UnexpectedStatus { .. }
| RemotePluginCatalogError::Decode { .. } => JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
},
}
}
@@ -943,6 +943,336 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let global_directory_body = r#"{
"plugins": [
{
"id": "linear@chatgpt-global",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"interface": {
"short_description": "Plan and track work",
"capabilities": ["Read", "Write"],
"logo_url": "https://example.com/linear.png",
"screenshot_urls": ["https://example.com/linear-shot.png"]
},
"skills": []
}
}
],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
let empty_page_body = r#"{
"plugins": [],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
let global_installed_body = r#"{
"plugins": [
{
"id": "linear@chatgpt-global",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"interface": {
"short_description": "Plan and track work",
"capabilities": ["Read", "Write"],
"logo_url": "https://example.com/linear.png",
"screenshot_urls": ["https://example.com/linear-shot.png"]
},
"skills": []
},
"enabled": true,
"disabled_skill_names": []
}
],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", "GLOBAL"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(global_directory_body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", "WORKSPACE"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "GLOBAL"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(global_installed_body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "WORKSPACE"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body))
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let remote_marketplace = response
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "chatgpt-global")
.expect("expected ChatGPT remote marketplace");
assert_eq!(remote_marketplace.path, None);
assert_eq!(
remote_marketplace
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("ChatGPT Plugins")
);
assert_eq!(remote_marketplace.plugins.len(), 1);
assert_eq!(remote_marketplace.plugins[0].id, "linear@chatgpt-global");
assert_eq!(remote_marketplace.plugins[0].name, "linear");
assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote);
assert_eq!(remote_marketplace.plugins[0].installed, true);
assert_eq!(remote_marketplace.plugins[0].enabled, true);
assert_eq!(
remote_marketplace.plugins[0]
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("Linear")
);
assert_eq!(response.featured_plugin_ids, Vec::<String>::new());
Ok(())
}
#[tokio::test]
async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let local_plugin_root = codex_home
.path()
.join(".agents/plugins/plugins/local-linear/.codex-plugin");
std::fs::create_dir_all(&local_plugin_root)?;
std::fs::write(
codex_home.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "chatgpt-global",
"plugins": [
{
"name": "local-linear",
"source": {
"source": "local",
"path": "./plugins/local-linear"
}
}
]
}"#,
)?;
std::fs::write(
local_plugin_root.join("plugin.json"),
r#"{"name":"local-linear"}"#,
)?;
let global_directory_body = r#"{
"plugins": [
{
"id": "linear@chatgpt-global",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"interface": {},
"skills": []
}
}
],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
let empty_page_body = r#"{
"plugins": [],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
for (path_suffix, scope, body) in [
(
"/backend-api/ps/plugins/list",
"GLOBAL",
global_directory_body,
),
("/backend-api/ps/plugins/list", "WORKSPACE", empty_page_body),
(
"/backend-api/ps/plugins/installed",
"GLOBAL",
empty_page_body,
),
(
"/backend-api/ps/plugins/installed",
"WORKSPACE",
empty_page_body,
),
] {
Mock::given(method("GET"))
.and(path(path_suffix))
.and(query_param("scope", scope))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(body))
.mount(&server)
.await;
}
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let matching_marketplaces = response
.marketplaces
.iter()
.filter(|marketplace| marketplace.name == "chatgpt-global")
.collect::<Vec<_>>();
assert_eq!(matching_marketplaces.len(), 1);
assert_eq!(matching_marketplaces[0].path, None);
assert_eq!(matching_marketplaces[0].plugins.len(), 1);
assert_eq!(
matching_marketplaces[0].plugins[0].source,
PluginSource::Remote
);
assert_eq!(matching_marketplaces[0].plugins[0].name, "linear");
Ok(())
}
#[tokio::test]
async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
chatgpt_base_url = "{}/backend-api/"
[features]
plugins = false
remote_plugin = true
"#,
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_list_request(PluginListParams { cwds: None })
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert!(response.marketplaces.is_empty());
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
Ok(())
}
#[tokio::test]
async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -1108,6 +1438,24 @@ enabled = true
)
}
fn write_remote_plugin_catalog_config(
codex_home: &std::path::Path,
base_url: &str,
) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"
chatgpt_base_url = "{base_url}"
[features]
plugins = true
remote_plugin = true
"#
),
)
}
fn write_openai_curated_marketplace(
codex_home: &std::path::Path,
plugin_names: &[&str],
@@ -22,6 +22,7 @@ use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -42,6 +43,13 @@ use tempfile::TempDir;
use tokio::net::TcpListener;
use tokio::task::JoinHandle;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
@@ -106,7 +114,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> {
}
#[tokio::test]
async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported() -> Result<()> {
async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
@@ -114,7 +122,7 @@ async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported()
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("openai-curated".to_string()),
remote_marketplace_name: Some("chatgpt-global".to_string()),
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -129,9 +137,336 @@ async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported()
assert!(
err.error
.message
.contains("remote plugin read is not supported yet")
.contains("remote plugin read is not enabled")
);
assert!(err.error.message.contains("openai-curated"));
assert!(err.error.message.contains("chatgpt-global"));
Ok(())
}
#[tokio::test]
async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let detail_body = r#"{
"id": "linear@chatgpt-global",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"interface": {
"short_description": "Plan and track work",
"capabilities": ["Read", "Write"],
"logo_url": "https://example.com/linear.png",
"screenshot_urls": ["https://example.com/linear-shot.png"]
},
"skills": [
{
"name": "plan-work",
"description": "Plan work from Linear issues",
"plugin_release_skill_id": "skill-1",
"interface": {
"display_name": "Plan Work",
"short_description": "Create a plan from issues"
}
}
]
}
}"#;
let installed_body = r#"{
"plugins": [
{
"id": "linear@chatgpt-global",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"interface": {
"short_description": "Plan and track work",
"capabilities": ["Read", "Write"],
"logo_url": "https://example.com/linear.png",
"screenshot_urls": ["https://example.com/linear-shot.png"]
},
"skills": [
{
"name": "plan-work",
"description": "Plan work from Linear issues",
"plugin_release_skill_id": "skill-1",
"interface": {
"display_name": "Plan Work",
"short_description": "Create a plan from issues"
}
}
]
},
"enabled": false,
"disabled_skill_names": ["plan-work"]
}
],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/linear@chatgpt-global"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(detail_body))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/installed"))
.and(query_param("scope", "GLOBAL"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(installed_body))
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
plugin_name: "linear".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.marketplace_name, "chatgpt-global");
assert_eq!(response.plugin.marketplace_path, None);
assert_eq!(response.plugin.summary.source, PluginSource::Remote);
assert_eq!(response.plugin.summary.id, "linear@chatgpt-global");
assert_eq!(response.plugin.summary.name, "linear");
assert_eq!(response.plugin.summary.installed, true);
assert_eq!(response.plugin.summary.enabled, false);
assert_eq!(
response.plugin.description.as_deref(),
Some("Track work in Linear")
);
assert_eq!(response.plugin.skills.len(), 1);
assert_eq!(response.plugin.skills[0].name, "plan-work");
assert_eq!(response.plugin.skills[0].path, None);
assert_eq!(response.plugin.skills[0].enabled, false);
assert_eq!(response.plugin.apps.len(), 0);
Ok(())
}
#[tokio::test]
async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
codex_home.path(),
&format!("{}/backend-api/", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/missing@chatgpt-global"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(404).set_body_string(r#"{"detail":"not found"}"#))
.mount(&server)
.await;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
plugin_name: "missing".to_string(),
})
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(
err.error
.message
.contains("read remote plugin details: remote plugin catalog request")
);
Ok(())
}
#[tokio::test]
async fn plugin_read_rejects_remote_marketplace_when_plugins_are_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
r#"
chatgpt_base_url = "{}/backend-api/"
[features]
plugins = false
remote_plugin = true
"#,
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
plugin_name: "linear".to_string(),
})
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(
err.error
.message
.contains("remote plugin read is not enabled")
);
Ok(())
}
#[tokio::test]
async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {
let codex_home = TempDir::new()?;
write_remote_plugin_catalog_config(codex_home.path(), "https://example.invalid/backend-api/")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
plugin_name: "linear/../../oops".to_string(),
})
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("invalid remote plugin id"));
assert!(err.error.message.contains("invalid plugin name"));
Ok(())
}
#[tokio::test]
async fn plugin_read_returns_canonical_openai_curated_marketplace_name() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
write_plugin_marketplace(
repo_root.path(),
"openai-curated",
"demo-plugin",
"./demo-plugin",
)?;
std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?;
std::fs::write(
repo_root
.path()
.join("demo-plugin/.codex-plugin/plugin.json"),
r#"{
"name": "demo-plugin",
"description": "OpenAI curated plugin"
}"#,
)?;
std::fs::write(
codex_home.path().join("config.toml"),
r#"[features]
plugins = true
[plugins."demo-plugin@openai-curated"]
enabled = true
"#,
)?;
write_installed_plugin(&codex_home, "openai-curated", "demo-plugin")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: Some(marketplace_path.clone()),
remote_marketplace_name: None,
plugin_name: "demo-plugin".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.marketplace_name, "openai-curated");
assert_eq!(response.plugin.marketplace_path, Some(marketplace_path));
assert_eq!(response.plugin.summary.id, "demo-plugin@openai-curated");
assert_eq!(response.plugin.summary.name, "demo-plugin");
Ok(())
}
@@ -283,7 +618,7 @@ enabled = true
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.marketplace_name, "codex-curated");
assert_eq!(response.plugin.marketplace_path, marketplace_path);
assert_eq!(response.plugin.marketplace_path, Some(marketplace_path));
assert_eq!(response.plugin.summary.id, "demo-plugin@codex-curated");
assert_eq!(response.plugin.summary.name, "demo-plugin");
assert_eq!(
@@ -858,6 +1193,24 @@ connectors = true
)
}
fn write_remote_plugin_catalog_config(
codex_home: &std::path::Path,
base_url: &str,
) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"
chatgpt_base_url = "{base_url}"
[features]
plugins = true
remote_plugin = true
"#
),
)
}
fn write_plugin_marketplace(
repo_root: &std::path::Path,
marketplace_name: &str,
+1
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;
+562 -223
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,
})
}
+313
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())
}
+6
View File
@@ -452,6 +452,9 @@
"remote_models": {
"type": "boolean"
},
"remote_plugin": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
@@ -2452,6 +2455,9 @@
"remote_models": {
"type": "boolean"
},
"remote_plugin": {
"type": "boolean"
},
"request_permissions": {
"type": "boolean"
},
+16 -26
View File
@@ -41,9 +41,9 @@ use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
use codex_core_plugins::marketplace_upgrade::configured_git_marketplace_names;
use codex_core_plugins::marketplace_upgrade::upgrade_configured_git_marketplaces;
use codex_core_plugins::remote::RemotePluginFetchError;
use codex_core_plugins::remote::RemotePluginMutationError;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_core_plugins::remote_legacy::RemotePluginFetchError;
use codex_core_plugins::remote_legacy::RemotePluginMutationError;
use codex_core_plugins::store::PluginInstallResult as StorePluginInstallResult;
use codex_core_plugins::store::PluginStore;
use codex_core_plugins::store::PluginStoreError;
@@ -71,7 +71,6 @@ use tracing::info;
use tracing::warn;
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
pub const OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated";
pub const OPENAI_BUNDLED_MARKETPLACE_NAME: &str = "openai-bundled";
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration =
@@ -167,7 +166,7 @@ pub struct PluginInstallOutcome {
#[derive(Debug, Clone, PartialEq)]
pub struct PluginReadOutcome {
pub marketplace_name: String,
pub marketplace_path: AbsolutePathBuf,
pub marketplace_path: Option<AbsolutePathBuf>,
pub plugin: PluginDetail,
}
@@ -526,12 +525,13 @@ impl PluginsManager {
if let Some(featured_plugin_ids) = self.cached_featured_plugin_ids(&cache_key) {
return Ok(featured_plugin_ids);
}
let featured_plugin_ids = codex_core_plugins::remote::fetch_remote_featured_plugin_ids(
&remote_plugin_service_config(config),
auth,
self.restriction_product,
)
.await?;
let featured_plugin_ids =
codex_core_plugins::remote_legacy::fetch_remote_featured_plugin_ids(
&remote_plugin_service_config(config),
auth,
self.restriction_product,
)
.await?;
self.write_featured_plugin_ids_cache(cache_key, &featured_plugin_ids);
Ok(featured_plugin_ids)
}
@@ -561,7 +561,7 @@ impl PluginsManager {
)?;
let plugin_id = resolved.plugin_id.as_key();
// This only forwards the backend mutation before the local install flow.
codex_core_plugins::remote::enable_remote_plugin(
codex_core_plugins::remote_legacy::enable_remote_plugin(
&remote_plugin_service_config(config),
auth,
&plugin_id,
@@ -652,7 +652,7 @@ impl PluginsManager {
let plugin_id = PluginId::parse(&plugin_id)?;
let plugin_key = plugin_id.as_key();
// This only forwards the backend mutation before the local uninstall flow.
codex_core_plugins::remote::uninstall_remote_plugin(
codex_core_plugins::remote_legacy::uninstall_remote_plugin(
&remote_plugin_service_config(config),
auth,
&plugin_key,
@@ -709,7 +709,7 @@ impl PluginsManager {
}
info!("starting remote plugin sync");
let remote_plugins = codex_core_plugins::remote::fetch_remote_plugin_status(
let remote_plugins = codex_core_plugins::remote_legacy::fetch_remote_plugin_status(
&remote_plugin_service_config(config),
auth,
)
@@ -968,13 +968,7 @@ impl PluginsManager {
(!plugins.is_empty()).then_some(ConfiguredMarketplace {
name: marketplace.name,
path: marketplace.path,
interface: if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
Some(MarketplaceInterface {
display_name: Some(OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string()),
})
} else {
marketplace.interface
},
interface: marketplace.interface,
plugins,
})
})
@@ -1023,12 +1017,8 @@ impl PluginsManager {
.await?;
Ok(PluginReadOutcome {
marketplace_name: if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string()
} else {
marketplace_name
},
marketplace_path: request.marketplace_path.clone(),
marketplace_name,
marketplace_path: Some(request.marketplace_path.clone()),
plugin,
})
}
+1 -6
View File
@@ -1781,9 +1781,6 @@ plugins = true
curated_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "openai-curated",
"interface": {
"displayName": "ChatGPT Official"
},
"plugins": [
{
"name": "linear",
@@ -1819,9 +1816,7 @@ plugins = true
name: "openai-curated".to_string(),
path: AbsolutePathBuf::try_from(curated_root.join(".agents/plugins/marketplace.json"))
.unwrap(),
interface: Some(MarketplaceInterface {
display_name: Some(OPENAI_CURATED_MARKETPLACE_DISPLAY_NAME.to_string()),
}),
interface: None,
plugins: vec![ConfiguredMarketplacePlugin {
id: "linear@openai-curated".to_string(),
name: "linear".to_string(),
+8
View File
@@ -156,6 +156,8 @@ pub enum Feature {
ToolSuggest,
/// Enable plugins.
Plugins,
/// Temporary internal-only flag for PS-backed remote plugin catalog development.
RemotePlugin,
/// Show the startup prompt for migrating external agent config into Codex.
ExternalMigration,
/// Allow the model to invoke the built-in image generation tool.
@@ -846,6 +848,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::RemotePlugin,
key: "remote_plugin",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ExternalMigration,
key: "external_migration",
+8 -2
View File
@@ -1013,9 +1013,8 @@ impl ChatWidget {
is_disabled: true,
..Default::default()
});
} else {
} else if let Some(marketplace_path) = plugin.marketplace_path.clone() {
let install_cwd = self.config.cwd.to_path_buf();
let marketplace_path = plugin.marketplace_path.clone();
let plugin_name = plugin.summary.name.clone();
let plugin_display_name = display_name;
items.push(SelectionItem {
@@ -1035,6 +1034,13 @@ impl ChatWidget {
})],
..Default::default()
});
} else {
items.push(SelectionItem {
name: "Install plugin".to_string(),
description: Some("Installing remote plugins is not supported yet.".to_string()),
is_disabled: true,
..Default::default()
});
}
items.push(SelectionItem {
+4 -2
View File
@@ -958,7 +958,7 @@ pub(super) fn plugins_test_detail(
) -> PluginDetail {
PluginDetail {
marketplace_name: "ChatGPT Marketplace".to_string(),
marketplace_path: plugins_test_absolute_path("marketplaces/chatgpt"),
marketplace_path: Some(plugins_test_absolute_path("marketplaces/chatgpt")),
summary,
description: description.map(str::to_string),
skills: skills
@@ -968,7 +968,9 @@ pub(super) fn plugins_test_detail(
description: format!("{name} description"),
short_description: None,
interface: None,
path: plugins_test_absolute_path(&format!("skills/{name}/SKILL.md")),
path: Some(plugins_test_absolute_path(&format!(
"skills/{name}/SKILL.md"
))),
enabled: true,
})
.collect(),