Files
codex/codex-rs/app-server/src/request_processors/plugins.rs
T
jif 0318381762 Replace SkillsManager with SkillsService (#28705)
## Why

Host skill discovery was still exposed as a manager even though it is a
process-owned service shared by sessions, the app-server catalog, and
file-watcher invalidation. The skills extension also consumed an ad hoc
loaded-skills wrapper instead of a named immutable snapshot.

## What changed

- replace `SkillsManager` with concrete `SkillsService`
- make the service cache and return immutable `HostSkillsSnapshot`
values
- migrate the skills extension host provider to the snapshot boundary
- migrate app-server catalog, watcher, and invalidation paths to the
service

This keeps the service limited to host discovery, caching, roots, and
invalidation. Catalog rendering and invocation remain extension
responsibilities for the next stacked change.
2026-06-17 17:01:06 +02:00

2186 lines
84 KiB
Rust

use super::*;
use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginSharePrincipalRole;
use codex_app_server_protocol::PluginShareTargetRole;
use codex_config::types::McpServerConfig;
use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::PluginListBackgroundTaskOptions;
use codex_core_plugins::is_openai_curated_marketplace_name;
use codex_core_plugins::remote::REMOTE_CREATED_BY_ME_MARKETPLACE_NAME;
use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME;
use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME;
use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME;
use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME;
use codex_core_plugins::remote::RemoteAppTemplateUnavailableReason;
use codex_core_plugins::remote::is_valid_remote_plugin_id;
use codex_core_plugins::remote::validate_remote_plugin_id;
use codex_mcp::McpOAuthLoginSupport;
use codex_mcp::oauth_login_support;
use codex_mcp::should_retry_without_scopes;
use codex_rmcp_client::perform_oauth_login_silent;
#[derive(Clone)]
pub(crate) struct PluginRequestProcessor {
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
analytics_events_client: AnalyticsEventsClient,
config_manager: ConfigManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
}
fn plugin_skills_to_info(
skills: &[codex_core::skills::SkillMetadata],
disabled_skill_paths: &HashSet<AbsolutePathBuf>,
) -> Vec<SkillSummary> {
skills
.iter()
.map(|skill| SkillSummary {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill.short_description.clone(),
interface: skill.interface.clone().map(|interface| {
codex_app_server_protocol::SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: interface.icon_small,
icon_large: interface.icon_large,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
}
}),
path: Some(skill.path_to_skills_md.clone()),
enabled: !disabled_skill_paths.contains(&skill.path_to_skills_md),
})
.collect()
}
fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface {
PluginInterface {
display_name: interface.display_name,
short_description: interface.short_description,
long_description: interface.long_description,
developer_name: interface.developer_name,
category: interface.category,
capabilities: interface.capabilities,
website_url: interface.website_url,
privacy_policy_url: interface.privacy_policy_url,
terms_of_service_url: interface.terms_of_service_url,
default_prompt: interface.default_prompt,
brand_color: interface.brand_color,
composer_icon: interface.composer_icon,
composer_icon_url: None,
logo: interface.logo,
logo_url: None,
screenshots: interface.screenshots,
screenshot_urls: Vec::new(),
}
}
fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginSource {
match source {
MarketplacePluginSource::Local { path } => PluginSource::Local { path },
MarketplacePluginSource::Git {
url,
path,
ref_name,
sha,
} => PluginSource::Git {
url,
path,
ref_name,
sha,
},
}
}
fn load_shared_plugin_ids_by_local_path(
config: &Config,
) -> Result<std::collections::BTreeMap<AbsolutePathBuf, String>, JSONRPCErrorError> {
codex_core_plugins::remote::load_plugin_share_remote_ids_by_local_path(
config.codex_home.as_path(),
)
.map_err(|err| {
internal_error(format!(
"failed to load plugin share local path mapping: {err}"
))
})
}
fn share_context_for_source(
source: &MarketplacePluginSource,
shared_plugin_ids_by_local_path: &std::collections::BTreeMap<AbsolutePathBuf, String>,
) -> Option<PluginShareContext> {
match source {
MarketplacePluginSource::Local { path } => shared_plugin_ids_by_local_path
.get(path)
.cloned()
.map(|remote_plugin_id| PluginShareContext {
remote_plugin_id,
remote_version: None,
discoverability: None,
share_url: None,
creator_account_user_id: None,
creator_name: None,
share_principals: None,
}),
MarketplacePluginSource::Git { .. } => None,
}
}
fn convert_configured_marketplace_plugin_to_plugin_summary(
plugin: codex_core_plugins::ConfiguredMarketplacePlugin,
shared_plugin_ids_by_local_path: &std::collections::BTreeMap<AbsolutePathBuf, String>,
) -> PluginSummary {
let share_context = share_context_for_source(&plugin.source, shared_plugin_ids_by_local_path);
PluginSummary {
id: plugin.id,
remote_plugin_id: None,
local_version: plugin.local_version,
installed: plugin.installed,
enabled: plugin.enabled,
name: plugin.name,
share_context,
source: marketplace_plugin_source_to_info(plugin.source),
install_policy: plugin.policy.installation.into(),
auth_policy: plugin.policy.authentication.into(),
availability: PluginAvailability::Available,
interface: plugin.interface.map(local_plugin_interface_to_info),
keywords: plugin.keywords,
}
}
fn remote_installed_plugin_visible_marketplaces(config: &Config) -> Vec<&'static str> {
let mut marketplaces = Vec::new();
if config.features.enabled(Feature::RemotePlugin) {
marketplaces.push(REMOTE_GLOBAL_MARKETPLACE_NAME);
marketplaces.push(REMOTE_CREATED_BY_ME_MARKETPLACE_NAME);
}
marketplaces.push(REMOTE_WORKSPACE_MARKETPLACE_NAME);
if config.features.enabled(Feature::PluginSharing) {
marketplaces.push(REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME);
marketplaces.push(REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME);
marketplaces.push(REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME);
}
marketplaces
}
fn filter_openai_curated_installed_conflicts(
marketplaces: &mut Vec<PluginMarketplaceEntry>,
prefer_remote_curated_conflicts: bool,
) {
let local_installed_plugin_names = marketplaces
.iter()
.filter(|marketplace| is_openai_curated_marketplace_name(&marketplace.name))
.flat_map(|marketplace| installed_plugin_names(&marketplace.plugins))
.collect::<HashSet<_>>();
let remote_installed_plugin_names = marketplaces
.iter()
.find(|marketplace| marketplace.name == REMOTE_GLOBAL_MARKETPLACE_NAME)
.map(|marketplace| installed_plugin_names(&marketplace.plugins))
.unwrap_or_default();
let conflicting_plugin_names = local_installed_plugin_names
.intersection(&remote_installed_plugin_names)
.cloned()
.collect::<HashSet<_>>();
if conflicting_plugin_names.is_empty() {
return;
}
for marketplace in marketplaces.iter_mut() {
if prefer_remote_curated_conflicts {
if !is_openai_curated_marketplace_name(&marketplace.name) {
continue;
}
} else if marketplace.name != REMOTE_GLOBAL_MARKETPLACE_NAME {
continue;
}
marketplace
.plugins
.retain(|plugin| !plugin.installed || !conflicting_plugin_names.contains(&plugin.name));
}
marketplaces.retain(|marketplace| !marketplace.plugins.is_empty());
}
fn installed_plugin_names(plugins: &[PluginSummary]) -> HashSet<String> {
plugins
.iter()
.filter(|plugin| plugin.installed)
.map(|plugin| plugin.name.clone())
.collect()
}
fn remote_plugin_share_discoverability(
discoverability: PluginShareDiscoverability,
) -> codex_core_plugins::remote::RemotePluginShareDiscoverability {
match discoverability {
PluginShareDiscoverability::Listed => {
codex_core_plugins::remote::RemotePluginShareDiscoverability::Listed
}
PluginShareDiscoverability::Unlisted => {
codex_core_plugins::remote::RemotePluginShareDiscoverability::Unlisted
}
PluginShareDiscoverability::Private => {
codex_core_plugins::remote::RemotePluginShareDiscoverability::Private
}
}
}
fn remote_plugin_share_update_discoverability(
discoverability: PluginShareUpdateDiscoverability,
) -> codex_core_plugins::remote::RemotePluginShareUpdateDiscoverability {
match discoverability {
PluginShareUpdateDiscoverability::Unlisted => {
codex_core_plugins::remote::RemotePluginShareUpdateDiscoverability::Unlisted
}
PluginShareUpdateDiscoverability::Private => {
codex_core_plugins::remote::RemotePluginShareUpdateDiscoverability::Private
}
}
}
fn validate_client_plugin_share_targets(
targets: &[PluginShareTarget],
) -> Result<(), JSONRPCErrorError> {
if targets
.iter()
.any(|target| target.principal_type == PluginSharePrincipalType::Workspace)
{
return Err(invalid_request(
"shareTargets cannot include workspace principals; use discoverability UNLISTED for workspace link access",
));
}
Ok(())
}
fn remote_plugin_share_target_role(
role: PluginShareTargetRole,
) -> codex_core_plugins::remote::RemotePluginShareTargetRole {
match role {
PluginShareTargetRole::Reader => {
codex_core_plugins::remote::RemotePluginShareTargetRole::Reader
}
PluginShareTargetRole::Editor => {
codex_core_plugins::remote::RemotePluginShareTargetRole::Editor
}
}
}
fn plugin_share_principal_role_from_remote(
role: codex_core_plugins::remote::RemotePluginSharePrincipalRole,
) -> PluginSharePrincipalRole {
match role {
codex_core_plugins::remote::RemotePluginSharePrincipalRole::Reader => {
PluginSharePrincipalRole::Reader
}
codex_core_plugins::remote::RemotePluginSharePrincipalRole::Editor => {
PluginSharePrincipalRole::Editor
}
codex_core_plugins::remote::RemotePluginSharePrincipalRole::Owner => {
PluginSharePrincipalRole::Owner
}
}
}
fn remote_plugin_share_targets(
targets: Vec<PluginShareTarget>,
) -> Vec<codex_core_plugins::remote::RemotePluginShareTarget> {
targets
.into_iter()
.map(
|target| codex_core_plugins::remote::RemotePluginShareTarget {
principal_type: match target.principal_type {
PluginSharePrincipalType::User => {
codex_core_plugins::remote::RemotePluginSharePrincipalType::User
}
PluginSharePrincipalType::Group => {
codex_core_plugins::remote::RemotePluginSharePrincipalType::Group
}
PluginSharePrincipalType::Workspace => {
codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace
}
},
principal_id: target.principal_id,
role: remote_plugin_share_target_role(target.role),
},
)
.collect()
}
fn plugin_share_principal_from_remote(
principal: codex_core_plugins::remote::RemotePluginSharePrincipal,
) -> PluginSharePrincipal {
PluginSharePrincipal {
principal_type: match principal.principal_type {
codex_core_plugins::remote::RemotePluginSharePrincipalType::User => {
PluginSharePrincipalType::User
}
codex_core_plugins::remote::RemotePluginSharePrincipalType::Group => {
PluginSharePrincipalType::Group
}
codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace => {
PluginSharePrincipalType::Workspace
}
},
principal_id: principal.principal_id,
role: plugin_share_principal_role_from_remote(principal.role),
name: principal.name,
}
}
impl PluginRequestProcessor {
pub(crate) fn new(
auth_manager: Arc<AuthManager>,
thread_manager: Arc<ThreadManager>,
outgoing: Arc<OutgoingMessageSender>,
analytics_events_client: AnalyticsEventsClient,
config_manager: ConfigManager,
workspace_settings_cache: Arc<workspace_settings::WorkspaceSettingsCache>,
) -> Self {
Self {
auth_manager,
thread_manager,
outgoing,
analytics_events_client,
config_manager,
workspace_settings_cache,
}
}
pub(crate) async fn plugin_list(
&self,
params: PluginListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_list_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_installed(
&self,
params: PluginInstalledParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_installed_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_read(
&self,
params: PluginReadParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_read_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_skill_read(
&self,
params: PluginSkillReadParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_skill_read_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_save(
&self,
params: PluginShareSaveParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_save_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_update_targets(
&self,
params: PluginShareUpdateTargetsParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_update_targets_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_list(
&self,
params: PluginShareListParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_list_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_checkout(
&self,
params: PluginShareCheckoutParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_checkout_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_share_delete(
&self,
params: PluginShareDeleteParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_share_delete_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_install(
&self,
params: PluginInstallParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_install_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) async fn plugin_uninstall(
&self,
params: PluginUninstallParams,
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
self.plugin_uninstall_response(params)
.await
.map(|response| Some(response.into()))
}
pub(crate) fn effective_plugins_changed_callback(&self) -> Arc<dyn Fn() + Send + Sync> {
let thread_manager = Arc::clone(&self.thread_manager);
let config_manager = self.config_manager.clone();
Arc::new(move || {
Self::spawn_effective_plugins_changed_task(
Arc::clone(&thread_manager),
config_manager.clone(),
);
})
}
fn on_effective_plugins_changed(&self) {
Self::spawn_effective_plugins_changed_task(
Arc::clone(&self.thread_manager),
self.config_manager.clone(),
);
}
fn spawn_effective_plugins_changed_task(
thread_manager: Arc<ThreadManager>,
config_manager: ConfigManager,
) {
tokio::spawn(async move {
thread_manager.plugins_manager().clear_cache();
thread_manager.skills_service().clear_cache();
if thread_manager.list_thread_ids().await.is_empty() {
return;
}
crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await;
});
}
fn clear_plugin_related_caches(&self) {
self.thread_manager.plugins_manager().clear_cache();
self.thread_manager.skills_service().clear_cache();
}
async fn load_latest_config(
&self,
fallback_cwd: Option<PathBuf>,
) -> Result<Config, JSONRPCErrorError> {
self.config_manager
.load_latest_config(fallback_cwd)
.await
.map_err(|err| internal_error(format!("failed to reload config: {err}")))
}
async fn workspace_codex_plugins_enabled(
&self,
config: &Config,
auth: Option<&CodexAuth>,
) -> bool {
match workspace_settings::codex_plugins_enabled_for_workspace(
config,
auth,
Some(&self.workspace_settings_cache),
)
.await
{
Ok(enabled) => enabled,
Err(err) => {
warn!(
"failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}"
);
true
}
}
}
async fn plugin_list_response(
&self,
params: PluginListParams,
) -> Result<PluginListResponse, JSONRPCErrorError> {
let plugins_manager = self.thread_manager.plugins_manager();
let PluginListParams {
cwds,
marketplace_kinds,
} = params;
let roots = cwds.unwrap_or_default();
let explicit_marketplace_kinds = marketplace_kinds.is_some();
let marketplace_kinds =
marketplace_kinds.unwrap_or_else(|| vec![PluginListMarketplaceKind::Local]);
let include_local = marketplace_kinds.contains(&PluginListMarketplaceKind::Local);
let include_vertical = marketplace_kinds.contains(&PluginListMarketplaceKind::Vertical);
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let empty_response = || PluginListResponse {
marketplaces: Vec::new(),
marketplace_load_errors: Vec::new(),
featured_plugin_ids: Vec::new(),
};
if !config.features.enabled(Feature::Plugins) {
return Ok(empty_response());
}
let auth = self.auth_manager.auth().await;
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
{
return Ok(empty_response());
}
let auth_mode = auth.as_ref().map(CodexAuth::api_auth_mode);
plugins_manager.set_auth_mode(auth_mode);
let plugins_input = config.plugins_config_input();
let include_shared_with_me =
marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe);
let include_created_by_me_remote = marketplace_kinds
.contains(&PluginListMarketplaceKind::CreatedByMeRemote)
&& config.features.enabled(Feature::RemotePlugin);
let include_global_remote =
!explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin);
let use_remote_global_catalog =
include_global_remote && auth_mode.is_some_and(AuthMode::uses_codex_backend);
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let refresh_global_remote_catalog_cache = use_remote_global_catalog
&& codex_core_plugins::remote::has_cached_global_remote_plugin_catalog(
config.codex_home.as_path(),
&remote_plugin_service_config,
auth.as_ref(),
);
let (mut data, marketplace_load_errors) = if include_local {
let config_for_marketplace_listing = plugins_input.clone();
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
let roots_for_marketplace_listing = roots.clone();
let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config)?;
match tokio::task::spawn_blocking(move || {
let outcome = plugins_manager_for_marketplace_listing
.list_marketplaces_for_config(
&config_for_marketplace_listing,
&roots_for_marketplace_listing,
/*include_openai_curated*/ !use_remote_global_catalog,
)?;
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| {
convert_configured_marketplace_plugin_to_plugin_summary(
plugin,
&shared_plugin_ids_by_local_path,
)
})
.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)) => {
return Err(Self::marketplace_error(err, "list marketplace plugins"));
}
Err(err) => {
return Err(internal_error(format!(
"failed to list marketplace plugins: {err}"
)));
}
}
} else {
(Vec::new(), Vec::new())
};
// TODO(remote plugins): Remove this once remote plugins are ready and vertical plugins are
// served directly from the normal remote catalog.
if include_vertical && !config.features.enabled(Feature::RemotePlugin) {
match codex_core_plugins::remote::fetch_openai_curated_remote_collection_marketplace(
&remote_plugin_service_config,
auth.as_ref(),
)
.await
{
Ok(Some(remote_marketplace)) => {
data.push(remote_marketplace_to_info(remote_marketplace));
}
Ok(None) => {}
Err(RemotePluginCatalogError::UnsupportedAuthMode) => {}
Err(err) if explicit_marketplace_kinds => {
return Err(remote_plugin_catalog_error_to_jsonrpc(
err,
"list OpenAI Curated remote plugin catalog",
));
}
Err(RemotePluginCatalogError::AuthRequired) => {}
Err(err) => {
warn!(
error = %err,
"plugin/list openai-curated-remote collection fetch failed; returning local marketplaces only"
);
}
}
}
let mut remote_sources = Vec::new();
if use_remote_global_catalog {
remote_sources.push(RemoteMarketplaceSource::Global);
}
if include_created_by_me_remote {
remote_sources.push(RemoteMarketplaceSource::CreatedByMeRemote);
}
if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) {
remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory);
}
if include_shared_with_me && config.features.enabled(Feature::PluginSharing) {
remote_sources.push(RemoteMarketplaceSource::SharedWithMe);
}
if !remote_sources.is_empty() {
match codex_core_plugins::remote::fetch_remote_marketplaces(
&remote_plugin_service_config,
auth.as_ref(),
&remote_sources,
/*global_catalog_cache_path*/ Some(config.codex_home.as_path()),
)
.await
{
Ok(remote_marketplaces) => {
for remote_marketplace in remote_marketplaces
.into_iter()
.map(remote_marketplace_to_info)
{
data.push(remote_marketplace);
}
}
Err(
err @ (RemotePluginCatalogError::AuthRequired
| RemotePluginCatalogError::UnsupportedAuthMode),
) if explicit_marketplace_kinds => {
return Err(remote_plugin_catalog_error_to_jsonrpc(
err,
"list remote plugin catalog",
));
}
Err(
RemotePluginCatalogError::AuthRequired
| RemotePluginCatalogError::UnsupportedAuthMode,
) => {}
Err(err) if explicit_marketplace_kinds => {
return Err(remote_plugin_catalog_error_to_jsonrpc(
err,
"list remote plugin catalog",
));
}
Err(err) => {
warn!(
error = %err,
"plugin/list remote plugin catalog fetch failed; returning local marketplaces only"
);
}
}
}
if include_local
|| include_created_by_me_remote
|| include_shared_with_me
|| include_global_remote
{
plugins_manager.maybe_start_plugin_list_background_tasks_for_config(
&plugins_input,
auth.clone(),
&roots,
PluginListBackgroundTaskOptions {
refresh_global_remote_catalog_cache,
},
Some(self.effective_plugins_changed_callback()),
);
}
let featured_plugin_ids = if !plugins_input.remote_plugin_enabled
&& data
.iter()
.any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
{
match plugins_manager
.featured_plugin_ids_for_config(&plugins_input, 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()
};
Ok(PluginListResponse {
marketplaces: data,
marketplace_load_errors,
featured_plugin_ids,
})
}
async fn plugin_installed_response(
&self,
params: PluginInstalledParams,
) -> Result<PluginInstalledResponse, JSONRPCErrorError> {
let plugins_manager = self.thread_manager.plugins_manager();
let PluginInstalledParams {
cwds,
install_suggestion_plugin_names,
} = params;
let roots = cwds.unwrap_or_default();
let install_suggestion_plugin_names = install_suggestion_plugin_names
.unwrap_or_default()
.into_iter()
.collect::<HashSet<_>>();
let empty_response = || PluginInstalledResponse {
marketplaces: Vec::new(),
marketplace_load_errors: Vec::new(),
};
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins) {
return Ok(empty_response());
}
let auth = self.auth_manager.auth().await;
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
{
return Ok(empty_response());
}
plugins_manager.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode));
let plugins_input = config.plugins_config_input();
let remote_installed_plugin_visible_marketplaces =
remote_installed_plugin_visible_marketplaces(&config);
plugins_manager.maybe_start_remote_installed_plugin_bundle_sync(
&plugins_input,
auth.clone(),
Some(self.effective_plugins_changed_callback()),
);
let (mut data, marketplace_load_errors) = self
.load_local_installed_and_suggested_plugins(
plugins_manager.clone(),
&config,
&plugins_input,
roots,
install_suggestion_plugin_names,
)
.await?;
data.extend(
self.load_remote_installed_plugins(
plugins_manager,
&plugins_input,
&remote_installed_plugin_visible_marketplaces,
auth.as_ref(),
)
.await,
);
filter_openai_curated_installed_conflicts(
&mut data,
config.features.enabled(Feature::RemotePlugin),
);
Ok(PluginInstalledResponse {
marketplaces: data,
marketplace_load_errors,
})
}
async fn load_local_installed_and_suggested_plugins(
&self,
plugins_manager: Arc<codex_core_plugins::PluginsManager>,
config: &Config,
plugins_input: &codex_core_plugins::PluginsConfigInput,
roots: Vec<AbsolutePathBuf>,
install_suggestion_plugin_names: HashSet<String>,
) -> Result<
(
Vec<PluginMarketplaceEntry>,
Vec<codex_app_server_protocol::MarketplaceLoadErrorInfo>,
),
JSONRPCErrorError,
> {
let config_for_marketplace_listing = plugins_input.clone();
let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(config)?;
match tokio::task::spawn_blocking(move || {
let outcome = plugins_manager.list_marketplaces_for_config(
&config_for_marketplace_listing,
&roots,
/*include_openai_curated*/ true,
)?;
Ok::<
(
Vec<PluginMarketplaceEntry>,
Vec<codex_app_server_protocol::MarketplaceLoadErrorInfo>,
),
MarketplaceError,
>((
outcome
.marketplaces
.into_iter()
.filter_map(|marketplace| {
let plugins = marketplace
.plugins
.into_iter()
.filter(|plugin| {
plugin.installed
|| install_suggestion_plugin_names.contains(&plugin.name)
})
.map(|plugin| {
convert_configured_marketplace_plugin_to_plugin_summary(
plugin,
&shared_plugin_ids_by_local_path,
)
})
.collect::<Vec<_>>();
(!plugins.is_empty()).then_some(PluginMarketplaceEntry {
name: marketplace.name,
path: Some(marketplace.path),
interface: marketplace.interface.map(|interface| {
MarketplaceInterface {
display_name: interface.display_name,
}
}),
plugins,
})
})
.collect(),
outcome
.errors
.into_iter()
.map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo {
marketplace_path: err.path,
message: err.message,
})
.collect(),
))
})
.await
{
Ok(Ok(outcome)) => Ok(outcome),
Ok(Err(err)) => Err(Self::marketplace_error(
err,
"list installed and suggested marketplace plugins",
)),
Err(err) => Err(internal_error(format!(
"failed to list installed and suggested plugins: {err}"
))),
}
}
async fn load_remote_installed_plugins(
&self,
plugins_manager: Arc<codex_core_plugins::PluginsManager>,
plugins_input: &codex_core_plugins::PluginsConfigInput,
visible_marketplaces: &[&str],
auth: Option<&CodexAuth>,
) -> Vec<PluginMarketplaceEntry> {
let remote_marketplaces = if let Some(remote_marketplaces) = plugins_manager
.build_remote_installed_plugin_marketplaces_from_cache(visible_marketplaces)
{
Ok(remote_marketplaces)
} else {
plugins_manager
.build_and_cache_remote_installed_plugin_marketplaces(
plugins_input,
auth,
visible_marketplaces,
Some(self.effective_plugins_changed_callback()),
)
.await
};
match remote_marketplaces {
Ok(remote_marketplaces) => remote_marketplaces
.into_iter()
.map(remote_marketplace_to_info)
.collect(),
Err(
RemotePluginCatalogError::AuthRequired
| RemotePluginCatalogError::UnsupportedAuthMode,
) => Vec::new(),
Err(err) => {
warn!(
error = %err,
"plugin/installed remote installed plugin fetch failed; returning local marketplaces only"
);
Vec::new()
}
}
}
async fn plugin_read_response(
&self,
params: PluginReadParams,
) -> Result<PluginReadResponse, JSONRPCErrorError> {
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) => {
return Err(invalid_request(
"plugin/read requires exactly one of marketplacePath or remoteMarketplaceName",
));
}
};
let config_cwd = read_source.as_ref().ok().and_then(|marketplace_path| {
marketplace_path.as_path().parent().map(Path::to_path_buf)
});
let config = self.load_latest_config(config_cwd).await?;
let plugins_input = config.plugins_config_input();
let auth = self.auth_manager.auth().await;
plugins_manager.set_auth_mode(auth.as_ref().map(CodexAuth::api_auth_mode));
let plugin = match read_source {
Ok(marketplace_path) => {
let request = PluginReadRequest {
plugin_name,
marketplace_path,
};
let outcome = plugins_manager
.read_plugin_for_config(&plugins_input, &request)
.await
.map_err(|err| Self::marketplace_error(err, "read plugin details"))?;
let shared_plugin_ids_by_local_path =
load_shared_plugin_ids_by_local_path(&config)?;
let share_context = share_context_for_source(
&outcome.plugin.source,
&shared_plugin_ids_by_local_path,
);
let share_context = match share_context {
Some(context) => {
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
match codex_core_plugins::remote::fetch_remote_plugin_share_context(
&remote_plugin_service_config,
auth.as_ref(),
&context.remote_plugin_id,
)
.await
{
Ok(Some(remote_share_context)) => {
if remote_share_context.share_principals.is_some() {
Some(remote_plugin_share_context_to_info(remote_share_context))
} else {
let remote_version = remote_share_context.remote_version;
let remote_plugin_id = context.remote_plugin_id.clone();
warn!(
remote_plugin_id = %remote_plugin_id,
"remote shared plugin detail did not include share principals; returning local share mapping context with remote version"
);
Some(PluginShareContext {
remote_version,
..context
})
}
}
Ok(None) => {
warn!(
remote_plugin_id = %context.remote_plugin_id,
"remote shared plugin detail did not include share context; returning local share mapping context"
);
Some(context)
}
Err(err) => {
warn!(
remote_plugin_id = %context.remote_plugin_id,
error = %err,
"failed to hydrate local plugin share context; returning local share mapping context"
);
Some(context)
}
}
}
None => None,
};
let app_summaries = load_plugin_app_summaries(
&config,
&outcome.plugin.apps,
&outcome.plugin.app_category_by_id,
)
.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,
remote_plugin_id: None,
local_version: outcome.plugin.local_version,
name: outcome.plugin.name,
share_context,
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(),
availability: PluginAvailability::Available,
interface: outcome.plugin.interface.map(local_plugin_interface_to_info),
keywords: outcome.plugin.keywords,
},
share_url: None,
description: outcome.plugin.description,
skills: plugin_skills_to_info(
&visible_skills,
&outcome.plugin.disabled_skill_paths,
),
hooks: outcome
.plugin
.hooks
.into_iter()
.map(|hook| codex_app_server_protocol::PluginHookSummary {
key: hook.key,
event_name: hook.event_name.into(),
})
.collect(),
apps: app_summaries,
app_templates: Vec::new(),
mcp_servers: outcome.plugin.mcp_server_names,
}
}
Err(remote_marketplace_name) => {
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request(format!(
"remote plugin read is not enabled for marketplace {remote_marketplace_name}"
)));
}
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
validate_remote_plugin_id(&plugin_name)?;
let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail(
&remote_plugin_service_config,
auth.as_ref(),
&remote_marketplace_name,
&plugin_name,
)
.await
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details")
})?;
let plugin_apps = remote_detail
.app_ids
.iter()
.cloned()
.map(codex_plugin::AppConnectorId)
.collect::<Vec<_>>();
let app_category_by_id = remote_detail
.app_manifest
.as_ref()
.map(plugin_app_category_by_id_from_value)
.unwrap_or_default();
let app_summaries =
load_plugin_app_summaries(&config, &plugin_apps, &app_category_by_id).await;
remote_plugin_detail_to_info(remote_detail, app_summaries)
}
};
Ok(PluginReadResponse { plugin })
}
async fn plugin_skill_read_response(
&self,
params: PluginSkillReadParams,
) -> Result<PluginSkillReadResponse, JSONRPCErrorError> {
let PluginSkillReadParams {
remote_marketplace_name,
remote_plugin_id,
skill_name,
} = params;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request(format!(
"remote plugin skill read is not enabled for marketplace {remote_marketplace_name}"
)));
}
validate_remote_plugin_id(&remote_plugin_id)?;
if skill_name.is_empty() {
return Err(invalid_request(
"invalid remote plugin skill name: cannot be empty",
));
}
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail(
&remote_plugin_service_config,
auth.as_ref(),
&remote_marketplace_name,
&remote_plugin_id,
&skill_name,
)
.await
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin skill details")
})?;
Ok(PluginSkillReadResponse {
contents: remote_skill_detail.contents,
})
}
async fn plugin_share_save_response(
&self,
params: PluginShareSaveParams,
) -> Result<PluginShareSaveResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
if !config.features.enabled(Feature::PluginSharing) {
return Err(invalid_request("plugin sharing is disabled"));
}
let PluginShareSaveParams {
plugin_path,
remote_plugin_id,
discoverability,
share_targets,
} = params;
if let Some(remote_plugin_id) = remote_plugin_id.as_ref()
&& (remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(remote_plugin_id))
{
return Err(invalid_request("invalid remote plugin id"));
}
if remote_plugin_id.is_some() && (discoverability.is_some() || share_targets.is_some()) {
return Err(invalid_request(
"discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share settings",
));
}
if discoverability == Some(PluginShareDiscoverability::Listed) {
return Err(invalid_request(
"discoverability LISTED is not supported for plugin/share/save; use UNLISTED or PRIVATE",
));
}
if let Some(share_targets) = share_targets.as_ref() {
validate_client_plugin_share_targets(share_targets)?;
}
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let access_policy = codex_core_plugins::remote::RemotePluginShareAccessPolicy {
discoverability: discoverability.map(remote_plugin_share_discoverability),
share_targets: share_targets.map(remote_plugin_share_targets),
};
let result = codex_core_plugins::remote::save_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
&plugin_path,
remote_plugin_id.as_deref(),
access_policy,
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?;
let remote_plugin_id = result.remote_plugin_id;
self.clear_plugin_related_caches();
Ok(PluginShareSaveResponse {
remote_plugin_id,
share_url: result.share_url.unwrap_or_default(),
})
}
async fn plugin_share_update_targets_response(
&self,
params: PluginShareUpdateTargetsParams,
) -> Result<PluginShareUpdateTargetsResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
if !config.features.enabled(Feature::PluginSharing) {
return Err(invalid_request("plugin sharing is disabled"));
}
let PluginShareUpdateTargetsParams {
remote_plugin_id,
discoverability,
share_targets,
} = params;
if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) {
return Err(invalid_request("invalid remote plugin id"));
}
validate_client_plugin_share_targets(&share_targets)?;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let result = codex_core_plugins::remote::update_remote_plugin_share_targets(
&remote_plugin_service_config,
auth.as_ref(),
&remote_plugin_id,
remote_plugin_share_targets(share_targets),
remote_plugin_share_update_discoverability(discoverability),
)
.await
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "update remote plugin share targets")
})?;
self.clear_plugin_related_caches();
Ok(PluginShareUpdateTargetsResponse {
principals: result
.principals
.into_iter()
.map(plugin_share_principal_from_remote)
.collect(),
discoverability: remote_plugin_share_discoverability_to_info(result.discoverability),
})
}
async fn plugin_share_list_response(
&self,
_params: PluginShareListParams,
) -> Result<PluginShareListResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let data = codex_core_plugins::remote::list_remote_plugin_shares(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))?
.into_iter()
.map(|summary| {
let RemoteCatalogPluginShareSummary {
summary,
local_plugin_path,
} = summary;
let plugin = remote_plugin_summary_to_info(summary);
PluginShareListItem {
plugin,
local_plugin_path,
}
})
.collect();
Ok(PluginShareListResponse { data })
}
async fn plugin_share_checkout_response(
&self,
params: PluginShareCheckoutParams,
) -> Result<PluginShareCheckoutResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
if !config.features.enabled(Feature::PluginSharing) {
return Err(invalid_request("plugin sharing is disabled"));
}
let PluginShareCheckoutParams { remote_plugin_id } = params;
if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) {
return Err(invalid_request("invalid remote plugin id"));
}
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let result = codex_core_plugins::remote::checkout_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
&remote_plugin_id,
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "checkout plugin share"))?;
self.clear_plugin_related_caches();
Ok(PluginShareCheckoutResponse {
remote_plugin_id: result.remote_plugin_id,
plugin_id: result.plugin_id,
plugin_name: result.plugin_name,
plugin_path: result.plugin_path,
marketplace_name: result.marketplace_name,
marketplace_path: result.marketplace_path,
remote_version: result.remote_version,
})
}
async fn plugin_share_delete_response(
&self,
params: PluginShareDeleteParams,
) -> Result<PluginShareDeleteResponse, JSONRPCErrorError> {
let (config, auth) = self.load_plugin_share_config_and_auth().await?;
let PluginShareDeleteParams { remote_plugin_id } = params;
if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) {
return Err(invalid_request("invalid remote plugin id"));
}
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
codex_core_plugins::remote::delete_remote_plugin_share(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.as_path(),
&remote_plugin_id,
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "delete remote plugin share"))?;
self.clear_plugin_related_caches();
Ok(PluginShareDeleteResponse {})
}
async fn load_plugin_share_config_and_auth(
&self,
) -> Result<(Config, Option<CodexAuth>), JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request("plugin sharing is not enabled"));
}
let auth = self.auth_manager.auth().await;
Ok((config, auth))
}
async fn plugin_install_response(
&self,
params: PluginInstallParams,
) -> Result<PluginInstallResponse, JSONRPCErrorError> {
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)) => {
return self
.remote_plugin_install_response(remote_marketplace_name, plugin_name)
.await;
}
(Some(_), Some(_)) | (None, None) => {
return Err(invalid_request(
"plugin/install requires exactly one of marketplacePath or remoteMarketplaceName",
));
}
};
let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf);
let config = self.load_latest_config(config_cwd.clone()).await?;
let auth = self.auth_manager.auth().await;
if !self
.workspace_codex_plugins_enabled(&config, auth.as_ref())
.await
{
return Err(invalid_request(
"Codex plugins are disabled for this workspace",
));
}
let plugins_manager = self.thread_manager.plugins_manager();
let request = PluginInstallRequest {
plugin_name,
marketplace_path,
};
let result = plugins_manager
.install_plugin(request)
.await
.map_err(Self::plugin_install_error)?;
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:?}"
);
config
}
};
self.on_effective_plugins_changed();
let plugin_mcp_servers = load_plugin_mcp_servers(
result.installed_path.as_path(),
auth.as_ref().map(CodexAuth::auth_mode),
)
.await;
if !plugin_mcp_servers.is_empty() {
self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers)
.await;
}
let plugin_app_declarations = load_plugin_apps(result.installed_path.as_path()).await;
let plugin_apps =
codex_plugin::app_connector_ids_from_declarations(&plugin_app_declarations);
let apps_needing_auth = self
.plugin_apps_needing_auth_for_install(
&config,
auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth),
&result.plugin_id.as_key(),
&plugin_apps,
)
.await;
Ok(PluginInstallResponse {
auth_policy: result.auth_policy.into(),
apps_needing_auth,
})
}
async fn remote_plugin_install_response(
&self,
remote_marketplace_name: String,
remote_plugin_id: String,
) -> Result<PluginInstallResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request(format!(
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
)));
}
validate_remote_plugin_id(&remote_plugin_id)?;
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let remote_detail =
codex_core_plugins::remote::fetch_remote_plugin_detail_with_download_urls(
&remote_plugin_service_config,
auth.as_ref(),
&remote_marketplace_name,
&remote_plugin_id,
)
.await
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(
err,
"read remote plugin details before install",
)
})?;
if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin {
return Err(invalid_request(format!(
"remote plugin {remote_plugin_id} is disabled by admin"
)));
}
if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable {
return Err(invalid_request(format!(
"remote plugin {remote_plugin_id} is not available for install"
)));
}
let actual_remote_marketplace_name = remote_detail.marketplace_name.clone();
// Direct install writes the same cache tree that installed-plugin sync
// prunes before the backend installed snapshot can include this plugin.
let _remote_plugin_cache_mutation =
codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight(
config.codex_home.as_path(),
&actual_remote_marketplace_name,
&remote_detail.summary.name,
);
let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle(
&remote_plugin_id,
&actual_remote_marketplace_name,
&remote_detail.summary.name,
remote_detail.release_version.as_deref(),
remote_detail.bundle_download_url.as_deref(),
remote_detail.app_manifest.clone(),
)
.map_err(remote_plugin_bundle_install_error_to_jsonrpc)?;
let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle(
config.codex_home.to_path_buf(),
validated_bundle,
)
.await
.map_err(remote_plugin_bundle_install_error_to_jsonrpc)?;
// Cache first so a backend install cannot succeed when local materialization fails.
// If this backend call fails, the cache entry is harmless because remote installed state
// is still backend-gated.
let install_result = codex_core_plugins::remote::install_remote_plugin(
&remote_plugin_service_config,
auth.as_ref(),
&actual_remote_marketplace_name,
&remote_plugin_id,
)
.await
.map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?;
self.thread_manager
.plugins_manager()
.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
&config.plugins_config_input(),
auth.clone(),
Some(self.effective_plugins_changed_callback()),
);
let mut plugin_metadata =
plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await;
plugin_metadata.remote_plugin_id = Some(remote_plugin_id.clone());
self.analytics_events_client
.track_plugin_installed(plugin_metadata);
let plugin_mcp_servers = load_plugin_mcp_servers(
result.installed_path.as_path(),
auth.as_ref().map(CodexAuth::auth_mode),
)
.await;
if !plugin_mcp_servers.is_empty() {
self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers)
.await;
}
let is_chatgpt_auth = auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth);
let apps_needing_auth = if let Some(app_ids_needing_auth) =
install_result.app_ids_needing_auth
{
if app_ids_needing_auth.is_empty()
|| !config.features.apps_enabled_for_auth(is_chatgpt_auth)
{
Vec::new()
} else {
let plugin_apps = app_ids_needing_auth
.into_iter()
.map(codex_plugin::AppConnectorId)
.collect::<Vec<_>>();
let app_category_by_id = remote_detail
.app_manifest
.as_ref()
.map(plugin_app_category_by_id_from_value)
.unwrap_or_default();
let all_connectors = connectors::list_cached_all_connectors(&config, &[])
.await
.unwrap_or_default();
connectors::connectors_for_plugin_apps(all_connectors, &plugin_apps)
.into_iter()
.map(|connector| {
let category = app_category_by_id
.get(&connector.id)
.cloned()
.or_else(|| connector.category());
AppSummary {
category,
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
}
})
.collect()
}
} else {
let plugin_app_declarations = load_plugin_apps(result.installed_path.as_path()).await;
let plugin_apps =
codex_plugin::app_connector_ids_from_declarations(&plugin_app_declarations);
self.plugin_apps_needing_auth_for_install(
&config,
is_chatgpt_auth,
&result.plugin_id.as_key(),
&plugin_apps,
)
.await
};
Ok(PluginInstallResponse {
auth_policy: remote_detail.summary.auth_policy,
apps_needing_auth,
})
}
async fn plugin_apps_needing_auth_for_install(
&self,
config: &Config,
is_chatgpt_auth: bool,
plugin_id: &str,
plugin_apps: &[codex_plugin::AppConnectorId],
) -> Vec<AppSummary> {
if plugin_apps.is_empty() || !config.features.apps_enabled_for_auth(is_chatgpt_auth) {
return Vec::new();
}
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*/ false, &[]),
connectors::list_accessible_connectors_from_mcp_tools_with_mcp_manager(
config,
/*force_refetch*/ true,
Arc::clone(&environment_manager),
self.thread_manager.mcp_manager(),
),
);
let all_connectors = match all_connectors_result {
Ok(connectors) => connectors,
Err(err) => {
warn!(
plugin = plugin_id,
"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 = plugin_id,
"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 = plugin_id,
"codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check"
);
}
plugin_apps_needing_auth(
&all_connectors,
&accessible_connectors,
plugin_apps,
codex_apps_ready,
)
}
async fn start_plugin_mcp_oauth_logins(
&self,
config: &Config,
plugin_mcp_servers: HashMap<String, McpServerConfig>,
) {
for (name, server) in plugin_mcp_servers {
let oauth_config = match oauth_login_support(&server.transport).await {
McpOAuthLoginSupport::Supported(config) => config,
McpOAuthLoginSupport::Unsupported => continue,
McpOAuthLoginSupport::Unknown(err) => {
warn!(
"MCP server may or may not require login for plugin install {name}: {err}"
);
continue;
}
};
let resolved_scopes = resolve_oauth_scopes(
/*explicit_scopes*/ None,
server.scopes.clone(),
oauth_config.discovered_scopes.clone(),
);
let store_mode = config.mcp_oauth_credentials_store_mode;
let keyring_backend_kind = config.auth_keyring_backend_kind();
let callback_port = config.mcp_oauth_callback_port;
let callback_url = config.mcp_oauth_callback_url.clone();
let outgoing = Arc::clone(&self.outgoing);
let notification_name = name.clone();
tokio::spawn(async move {
let oauth_client_id = server.oauth_client_id();
let first_attempt = perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
keyring_backend_kind,
oauth_config.http_headers.clone(),
oauth_config.env_http_headers.clone(),
&resolved_scopes.scopes,
oauth_client_id,
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
)
.await;
let final_result = match first_attempt {
Err(err) if should_retry_without_scopes(&resolved_scopes, &err) => {
perform_oauth_login_silent(
&name,
&oauth_config.url,
store_mode,
keyring_backend_kind,
oauth_config.http_headers,
oauth_config.env_http_headers,
&[],
oauth_client_id,
server.oauth_resource.as_deref(),
callback_port,
callback_url.as_deref(),
)
.await
}
result => result,
};
let (success, error) = match final_result {
Ok(()) => (true, None),
Err(err) => (false, Some(err.to_string())),
};
let notification = ServerNotification::McpServerOauthLoginCompleted(
McpServerOauthLoginCompletedNotification {
name: notification_name,
success,
error,
},
);
outgoing.send_server_notification(notification).await;
});
}
}
async fn plugin_uninstall_response(
&self,
params: PluginUninstallParams,
) -> Result<PluginUninstallResponse, JSONRPCErrorError> {
let PluginUninstallParams { plugin_id } = params;
if codex_plugin::PluginId::parse(&plugin_id).is_err()
&& !is_valid_remote_plugin_id(&plugin_id)
{
return Err(invalid_request("invalid remote plugin id"));
}
if is_valid_remote_plugin_id(&plugin_id) {
return self.remote_plugin_uninstall_response(plugin_id).await;
}
let plugins_manager = self.thread_manager.plugins_manager();
plugins_manager
.uninstall_plugin(plugin_id)
.await
.map_err(Self::plugin_uninstall_error)?;
match self.load_latest_config(/*fallback_cwd*/ None).await {
Ok(_) => self.on_effective_plugins_changed(),
Err(err) => {
warn!(
"failed to reload config after plugin uninstall, clearing plugin-related caches only: {err:?}"
);
self.clear_plugin_related_caches();
}
}
Ok(PluginUninstallResponse {})
}
fn plugin_install_error(err: CorePluginInstallError) -> JSONRPCErrorError {
if err.is_invalid_request() {
return invalid_request(err.to_string());
}
match err {
CorePluginInstallError::Marketplace(err) => {
Self::marketplace_error(err, "install plugin")
}
CorePluginInstallError::Config(err) => {
internal_error(format!("failed to persist installed plugin config: {err}"))
}
CorePluginInstallError::Remote(err) => {
internal_error(format!("failed to enable remote plugin: {err}"))
}
CorePluginInstallError::Join(err) => {
internal_error(format!("failed to install plugin: {err}"))
}
CorePluginInstallError::Store(err) => {
internal_error(format!("failed to install plugin: {err}"))
}
}
}
fn plugin_uninstall_error(err: CorePluginUninstallError) -> JSONRPCErrorError {
if err.is_invalid_request() {
return invalid_request(err.to_string());
}
match err {
CorePluginUninstallError::Config(err) => {
internal_error(format!("failed to clear plugin config: {err}"))
}
CorePluginUninstallError::Remote(err) => {
internal_error(format!("failed to uninstall remote plugin: {err}"))
}
CorePluginUninstallError::Join(err) => {
internal_error(format!("failed to uninstall plugin: {err}"))
}
CorePluginUninstallError::Store(err) => {
internal_error(format!("failed to uninstall plugin: {err}"))
}
CorePluginUninstallError::InvalidPluginId(_) => {
unreachable!("invalid plugin ids are handled above");
}
}
}
fn marketplace_error(err: MarketplaceError, action: &str) -> JSONRPCErrorError {
match err {
MarketplaceError::MarketplaceNotFound { .. }
| MarketplaceError::InvalidMarketplaceFile { .. }
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::PluginNotAvailable { .. }
| MarketplaceError::PluginsDisabled
| MarketplaceError::InvalidPlugin(_) => invalid_request(err.to_string()),
MarketplaceError::Io { .. } => internal_error(format!("failed to {action}: {err}")),
}
}
async fn remote_plugin_uninstall_response(
&self,
plugin_id: String,
) -> Result<PluginUninstallResponse, JSONRPCErrorError> {
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins) {
return Err(invalid_request("remote plugin uninstall is not enabled"));
}
validate_remote_plugin_id(&plugin_id)?;
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let uninstall_result = codex_core_plugins::remote::uninstall_remote_plugin(
&remote_plugin_service_config,
auth.as_ref(),
config.codex_home.to_path_buf(),
&plugin_id,
)
.await;
if matches!(
&uninstall_result,
Ok(()) | Err(RemotePluginCatalogError::CacheRemove(_))
) {
let plugins_manager = self.thread_manager.plugins_manager();
if plugins_manager.clear_remote_installed_plugins_cache() {
self.on_effective_plugins_changed();
}
plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
&config.plugins_config_input(),
auth.clone(),
Some(self.effective_plugins_changed_callback()),
);
}
uninstall_result.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "uninstall remote plugin")
})?;
Ok(PluginUninstallResponse {})
}
}
async fn load_plugin_app_summaries(
config: &Config,
plugin_apps: &[codex_plugin::AppConnectorId],
app_category_by_id: &HashMap<String, String>,
) -> Vec<AppSummary> {
if plugin_apps.is_empty() {
return Vec::new();
}
let connectors = match connectors::list_all_connectors_with_options(
config,
/*force_refetch*/ false,
&[],
)
.await
{
Ok(connectors) => connectors,
Err(err) => {
warn!("failed to load app metadata for plugin/read: {err:#}");
connectors::list_cached_all_connectors(config, &[])
.await
.unwrap_or_default()
}
};
let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps);
plugin_connectors
.into_iter()
.map(|connector| {
let category = app_category_by_id
.get(&connector.id)
.cloned()
.or_else(|| connector.category());
AppSummary {
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
category,
}
})
.collect()
}
fn plugin_app_category_by_id_from_value(value: &serde_json::Value) -> HashMap<String, String> {
codex_core_plugins::loader::plugin_app_declarations_from_value(value)
.into_iter()
.filter_map(|app| app.category.map(|category| (app.connector_id.0, category)))
.collect()
}
fn plugin_apps_needing_auth(
all_connectors: &[AppInfo],
accessible_connectors: &[AppInfo],
plugin_apps: &[codex_plugin::AppConnectorId],
codex_apps_ready: bool,
) -> Vec<AppSummary> {
if !codex_apps_ready {
return Vec::new();
}
let accessible_ids = accessible_connectors
.iter()
.map(|connector| connector.id.as_str())
.collect::<HashSet<_>>();
let plugin_app_ids = plugin_apps
.iter()
.map(|connector_id| connector_id.0.as_str())
.collect::<HashSet<_>>();
all_connectors
.iter()
.filter(|connector| {
plugin_app_ids.contains(connector.id.as_str())
&& !accessible_ids.contains(connector.id.as_str())
})
.cloned()
.map(|connector| {
let category = connector.category();
AppSummary {
category,
id: connector.id,
name: connector.name,
description: connector.description,
install_url: connector.install_url,
}
})
.collect()
}
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,
remote_plugin_id: Some(summary.remote_plugin_id),
local_version: None,
name: summary.name,
share_context: summary
.share_context
.map(remote_plugin_share_context_to_info),
source: PluginSource::Remote,
installed: summary.installed,
enabled: summary.enabled,
install_policy: summary.install_policy,
auth_policy: summary.auth_policy,
availability: summary.availability,
interface: summary.interface,
keywords: summary.keywords,
}
}
fn remote_plugin_share_context_to_info(
context: RemoteCatalogPluginShareContext,
) -> PluginShareContext {
PluginShareContext {
remote_plugin_id: context.remote_plugin_id,
remote_version: context.remote_version,
discoverability: Some(remote_plugin_share_discoverability_to_info(
context.discoverability,
)),
share_url: context.share_url,
creator_account_user_id: context.creator_account_user_id,
creator_name: context.creator_name,
share_principals: context.share_principals.map(|principals| {
principals
.into_iter()
.map(plugin_share_principal_from_remote)
.collect()
}),
}
}
fn remote_plugin_share_discoverability_to_info(
discoverability: codex_core_plugins::remote::RemotePluginShareDiscoverability,
) -> PluginShareDiscoverability {
match discoverability {
codex_core_plugins::remote::RemotePluginShareDiscoverability::Listed => {
PluginShareDiscoverability::Listed
}
codex_core_plugins::remote::RemotePluginShareDiscoverability::Unlisted => {
PluginShareDiscoverability::Unlisted
}
codex_core_plugins::remote::RemotePluginShareDiscoverability::Private => {
PluginShareDiscoverability::Private
}
}
}
fn remote_plugin_detail_to_info(
detail: RemoteCatalogPluginDetail,
apps: Vec<AppSummary>,
) -> PluginDetail {
let app_templates = detail
.app_templates
.into_iter()
.map(|template| AppTemplateSummary {
template_id: template.template_id,
name: template.name,
description: template.description,
category: template.category,
canonical_connector_id: template.canonical_connector_id,
logo_url: template.logo_url,
logo_url_dark: template.logo_url_dark,
materialized_app_ids: template.materialized_app_ids,
reason: template.reason.map(|reason| match reason {
RemoteAppTemplateUnavailableReason::NotConfiguredForWorkspace => {
AppTemplateUnavailableReason::NotConfiguredForWorkspace
}
RemoteAppTemplateUnavailableReason::NoActiveWorkspace => {
AppTemplateUnavailableReason::NoActiveWorkspace
}
}),
})
.collect();
PluginDetail {
marketplace_name: detail.marketplace_name,
marketplace_path: None,
summary: remote_plugin_summary_to_info(detail.summary),
share_url: detail.share_url,
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(),
hooks: Vec::new(),
apps,
app_templates,
mcp_servers: detail.mcp_servers,
}
}
fn remote_plugin_catalog_error_to_jsonrpc(
err: RemotePluginCatalogError,
context: &str,
) -> JSONRPCErrorError {
let message = format!("{context}: {err}");
match &err {
RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode => {
invalid_request(message)
}
RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => {
invalid_request(message)
}
RemotePluginCatalogError::InvalidPluginPath { .. }
| RemotePluginCatalogError::PluginShareCheckoutNotAvailable { .. }
| RemotePluginCatalogError::ArchiveTooLarge { .. }
| RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message),
RemotePluginCatalogError::AuthToken(_)
| RemotePluginCatalogError::Request { .. }
| RemotePluginCatalogError::UnexpectedStatus { .. }
| RemotePluginCatalogError::Decode { .. }
| RemotePluginCatalogError::InvalidBaseUrl(_)
| RemotePluginCatalogError::InvalidBaseUrlPath
| RemotePluginCatalogError::UnexpectedPluginId { .. }
| RemotePluginCatalogError::UnexpectedSkillName { .. }
| RemotePluginCatalogError::UnexpectedEnabledState { .. }
| RemotePluginCatalogError::Archive { .. }
| RemotePluginCatalogError::ArchiveJoin(_)
| RemotePluginCatalogError::MissingUploadEtag
| RemotePluginCatalogError::UnexpectedResponse(_)
| RemotePluginCatalogError::CacheRemove(_) => internal_error(message),
}
}
fn remote_plugin_bundle_install_error_to_jsonrpc(
err: codex_core_plugins::remote_bundle::RemotePluginBundleInstallError,
) -> JSONRPCErrorError {
internal_error(format!("install remote plugin bundle: {err}"))
}