[codex] Add installed-plugin mention API (#22448)

## Summary
- add app-server `plugin/installed` for mention-oriented plugin loading
- return installed plugins plus explicitly requested install-suggestion
rows
- keep remote handling on installed-state data instead of the broad
catalog listing path

## Why
The `@` mention surface only needs plugins that are usable now, plus a
small product-approved set of install suggestions. It does not need the
full catalog-shaped `plugin/list` payload that the Plugins page uses.

## Validation
- `just write-app-server-schema`
- `just fmt`
- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-core-plugins`
- `cargo test -p codex-app-server --test all plugin_installed_`

## Notes
- The package-wide `cargo test -p codex-app-server` run still hits an
existing unrelated stack overflow in
`in_process::tests::in_process_start_clamps_zero_channel_capacity`.
- Companion webview PR: https://github.com/openai/openai/pull/915672
This commit is contained in:
xli-oai
2026-05-18 03:11:54 -07:00
committed by GitHub
parent 22dd9ad392
commit da14dd2add
22 changed files with 1609 additions and 82 deletions
+35 -1
View File
@@ -37,6 +37,7 @@ use crate::marketplace_upgrade::configured_git_marketplace_names;
use crate::marketplace_upgrade::upgrade_configured_git_marketplaces;
use crate::remote::RemoteInstalledPlugin;
use crate::remote::RemotePluginCatalogError;
use crate::remote::RemotePluginScope;
use crate::remote::RemotePluginServiceConfig;
use crate::remote_legacy::RemotePluginFetchError;
use crate::remote_legacy::RemotePluginMutationError;
@@ -597,6 +598,39 @@ impl PluginsManager {
remote_installed_plugins_to_config(plugins, &self.store)
}
pub fn build_remote_installed_plugin_marketplaces_from_cache(
&self,
visible_scopes: &[RemotePluginScope],
) -> Option<Vec<crate::remote::RemoteMarketplace>> {
let cache = match self.remote_installed_plugins_cache.read() {
Ok(cache) => cache,
Err(err) => err.into_inner(),
};
let plugins = cache.as_ref()?;
Some(crate::remote::group_remote_installed_plugins_by_marketplaces(plugins, visible_scopes))
}
pub async fn build_and_cache_remote_installed_plugin_marketplaces(
&self,
config: &PluginsConfigInput,
auth: Option<&CodexAuth>,
visible_scopes: &[RemotePluginScope],
on_effective_plugins_changed: Option<Arc<dyn Fn() + Send + Sync + 'static>>,
) -> Result<Vec<crate::remote::RemoteMarketplace>, RemotePluginCatalogError> {
let plugins = crate::remote::fetch_remote_installed_plugins(
&remote_plugin_service_config(config),
auth,
)
.await?;
let marketplaces =
crate::remote::group_remote_installed_plugins_by_marketplaces(&plugins, visible_scopes);
let changed = self.write_remote_installed_plugins_cache(plugins);
if changed && let Some(on_effective_plugins_changed) = on_effective_plugins_changed {
on_effective_plugins_changed();
}
Ok(marketplaces)
}
fn write_remote_installed_plugins_cache(&self, plugins: Vec<RemoteInstalledPlugin>) -> bool {
let mut cache = match self.remote_installed_plugins_cache.write() {
Ok(cache) => cache,
@@ -674,7 +708,7 @@ impl PluginsManager {
);
}
fn maybe_start_remote_installed_plugin_bundle_sync(
pub fn maybe_start_remote_installed_plugin_bundle_sync(
self: &Arc<Self>,
config: &PluginsConfigInput,
auth: Option<CodexAuth>,
+89 -38
View File
@@ -7,6 +7,7 @@ use crate::loader::refresh_non_curated_plugin_cache;
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall;
use crate::marketplace::MarketplacePluginInstallPolicy;
use crate::remote::RemoteInstalledPlugin;
use crate::remote::RemotePluginScope;
use crate::startup_sync::curated_plugins_repo_path;
use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
use crate::test_support::TEST_CURATED_PLUGIN_SHA;
@@ -147,6 +148,20 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput {
load_plugins_config_input(codex_home, cwd).await
}
fn remote_installed_linear_plugin() -> RemoteInstalledPlugin {
RemoteInstalledPlugin {
marketplace_name: "chatgpt-global".to_string(),
id: "plugins~Plugin_linear".to_string(),
name: "linear".to_string(),
enabled: true,
install_policy: codex_app_server_protocol::PluginInstallPolicy::Available,
auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse,
availability: codex_app_server_protocol::PluginAvailability::Available,
interface: None,
keywords: Vec::new(),
}
}
#[tokio::test]
async fn load_plugins_loads_default_skills_and_mcp_servers() {
let codex_home = TempDir::new().unwrap();
@@ -334,38 +349,6 @@ approval_mode = "approve"
);
}
#[tokio::test]
async fn remote_installed_cache_adds_plugin_skill_roots_without_remote_plugin_flag() {
let codex_home = TempDir::new().unwrap();
let plugin_base = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear");
write_plugin(&plugin_base, "local", "linear");
write_file(
&codex_home.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = PluginsManager::new(codex_home.path().to_path_buf());
manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin {
marketplace_name: "chatgpt-global".to_string(),
id: "plugins~Plugin_linear".to_string(),
name: "linear".to_string(),
enabled: true,
}]);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(
outcome.effective_skill_roots(),
vec![AbsolutePathBuf::try_from(plugin_base.join("local/skills")).unwrap()]
);
assert_eq!(outcome.plugins().len(), 1);
assert_eq!(outcome.plugins()[0].config_name, "linear@chatgpt-global");
}
#[tokio::test]
async fn remote_installed_cache_ignores_plugins_missing_local_cache() {
let codex_home = TempDir::new().unwrap();
@@ -379,17 +362,85 @@ remote_plugin = true
let config = load_config(codex_home.path(), codex_home.path()).await;
let manager = PluginsManager::new(codex_home.path().to_path_buf());
manager.write_remote_installed_plugins_cache(vec![RemoteInstalledPlugin {
marketplace_name: "chatgpt-global".to_string(),
id: "plugins~Plugin_linear".to_string(),
name: "linear".to_string(),
enabled: true,
}]);
manager.write_remote_installed_plugins_cache(vec![remote_installed_linear_plugin()]);
let outcome = manager.plugins_for_config(&config).await;
assert_eq!(outcome, PluginLoadOutcome::default());
}
#[tokio::test]
async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metadata() {
let codex_home = TempDir::new().unwrap();
let manager = PluginsManager::new(codex_home.path().to_path_buf());
let mut plugin = remote_installed_linear_plugin();
plugin.install_policy = codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault;
plugin.auth_policy = codex_app_server_protocol::PluginAuthPolicy::OnInstall;
plugin.interface = Some(codex_app_server_protocol::PluginInterface {
display_name: Some("Linear".to_string()),
short_description: Some("Track remote work".to_string()),
long_description: None,
developer_name: None,
category: None,
capabilities: Vec::new(),
website_url: None,
privacy_policy_url: None,
terms_of_service_url: None,
default_prompt: None,
brand_color: Some("#111111".to_string()),
composer_icon: None,
composer_icon_url: None,
logo: None,
logo_url: None,
screenshots: Vec::new(),
screenshot_urls: Vec::new(),
});
plugin.keywords = vec!["issues".to_string()];
manager.write_remote_installed_plugins_cache(vec![plugin]);
let marketplaces = manager
.build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global])
.expect("remote installed cache should be present");
assert_eq!(marketplaces.len(), 1);
assert_eq!(marketplaces[0].name, "chatgpt-global");
assert_eq!(marketplaces[0].display_name, "ChatGPT Plugins");
assert_eq!(marketplaces[0].plugins.len(), 1);
let plugin = &marketplaces[0].plugins[0];
assert_eq!(plugin.id, "linear@chatgpt-global");
assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear");
assert_eq!(plugin.name, "linear");
assert_eq!(plugin.installed, true);
assert_eq!(plugin.enabled, true);
assert_eq!(
plugin.install_policy,
codex_app_server_protocol::PluginInstallPolicy::InstalledByDefault
);
assert_eq!(
plugin.auth_policy,
codex_app_server_protocol::PluginAuthPolicy::OnInstall
);
assert_eq!(plugin.keywords, vec!["issues".to_string()]);
assert_eq!(
plugin
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("Linear")
);
assert_eq!(
plugin
.interface
.as_ref()
.and_then(|interface| interface.short_description.as_deref()),
Some("Track remote work")
);
assert_eq!(
manager
.build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Workspace])
.expect("remote installed cache should be present"),
Vec::new()
);
}
#[tokio::test]
async fn load_plugins_resolves_disabled_skill_names_against_loaded_plugin_skills() {
let codex_home = TempDir::new().unwrap();
+99 -12
View File
@@ -27,7 +27,7 @@ pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncError;
pub use remote_installed_plugin_sync::RemoteInstalledPluginBundleSyncOutcome;
pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard;
pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight;
pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync;
pub(crate) use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync;
pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once;
pub use share::RemotePluginShareAccessPolicy;
pub use share::RemotePluginShareDiscoverability;
@@ -63,6 +63,28 @@ const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [
(
REMOTE_GLOBAL_MARKETPLACE_NAME,
REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME,
),
(
REMOTE_WORKSPACE_MARKETPLACE_NAME,
REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME,
),
(
REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME,
),
(
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME,
),
(
REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME,
REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME,
),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginServiceConfig {
@@ -89,6 +111,11 @@ pub struct RemoteInstalledPlugin {
pub id: String,
pub name: String,
pub enabled: bool,
pub install_policy: PluginInstallPolicy,
pub auth_policy: PluginAuthPolicy,
pub availability: PluginAvailability,
pub interface: Option<PluginInterface>,
pub keywords: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
@@ -264,7 +291,7 @@ pub enum RemotePluginCatalogError {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
enum RemotePluginScope {
pub enum RemotePluginScope {
#[serde(rename = "GLOBAL")]
Global,
#[serde(rename = "WORKSPACE")]
@@ -608,13 +635,7 @@ fn build_remote_marketplace(
})
.map(|(plugin, installed_plugin)| build_remote_plugin_summary(plugin, installed_plugin))
.collect::<Result<Vec<_>, _>>()?;
plugins.sort_by(|left, right| {
remote_plugin_display_name(left)
.to_ascii_lowercase()
.cmp(&remote_plugin_display_name(right).to_ascii_lowercase())
.then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right)))
.then_with(|| left.id.cmp(&right.id))
});
sort_remote_plugin_summaries_by_display_name(&mut plugins);
Ok(Some(RemoteMarketplace {
name: name.to_string(),
display_name: display_name.to_string(),
@@ -622,7 +643,7 @@ fn build_remote_marketplace(
}))
}
pub async fn fetch_remote_installed_plugins(
pub(crate) async fn fetch_remote_installed_plugins(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Vec<RemoteInstalledPlugin>, RemotePluginCatalogError> {
@@ -642,7 +663,7 @@ pub async fn fetch_remote_installed_plugins(
let mut installed_plugins = [global, workspace]
.into_iter()
.flat_map(|(_scope, plugins)| plugins)
.map(|plugin| remote_installed_plugin_to_info(&plugin))
.map(|plugin| remote_installed_plugin_to_cache_entry(&plugin))
.collect::<Result<Vec<_>, _>>()?;
installed_plugins.sort_by(|left, right| {
left.marketplace_name
@@ -652,6 +673,55 @@ pub async fn fetch_remote_installed_plugins(
Ok(installed_plugins)
}
pub fn group_remote_installed_plugins_by_marketplaces(
plugins: &[RemoteInstalledPlugin],
visible_scopes: &[RemotePluginScope],
) -> Vec<RemoteMarketplace> {
let mut plugins_by_marketplace = BTreeMap::<String, Vec<RemotePluginSummary>>::new();
for plugin in plugins {
if !RemotePluginScope::from_marketplace_name(&plugin.marketplace_name)
.is_some_and(|scope| visible_scopes.contains(&scope))
{
continue;
}
let Ok(plugin_id) = PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone())
else {
continue;
};
let plugin_summary = RemotePluginSummary {
id: plugin_id.as_key(),
remote_plugin_id: plugin.id.clone(),
name: plugin.name.clone(),
share_context: None,
installed: true,
enabled: plugin.enabled,
install_policy: plugin.install_policy,
auth_policy: plugin.auth_policy,
availability: plugin.availability,
interface: plugin.interface.clone(),
keywords: plugin.keywords.clone(),
};
plugins_by_marketplace
.entry(plugin.marketplace_name.clone())
.or_default()
.push(plugin_summary);
}
REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER
.into_iter()
.filter_map(|(marketplace_name, display_name)| {
let mut marketplace_plugins = plugins_by_marketplace.remove(marketplace_name)?;
sort_remote_plugin_summaries_by_display_name(&mut marketplace_plugins);
Some(RemoteMarketplace {
name: marketplace_name.to_string(),
display_name: display_name.to_string(),
plugins: marketplace_plugins,
})
})
.collect()
}
pub async fn fetch_remote_plugin_detail(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
@@ -982,7 +1052,7 @@ fn remote_plugin_share_context(
}
}
fn remote_installed_plugin_to_info(
fn remote_installed_plugin_to_cache_entry(
installed_plugin: &RemotePluginInstalledItem,
) -> Result<RemoteInstalledPlugin, RemotePluginCatalogError> {
let plugin = &installed_plugin.plugin;
@@ -994,6 +1064,11 @@ fn remote_installed_plugin_to_info(
id: plugin.id.clone(),
name: plugin.name.clone(),
enabled: installed_plugin.enabled,
install_policy: plugin.installation_policy,
auth_policy: plugin.authentication_policy,
availability: plugin.availability,
interface: remote_plugin_interface_to_info(plugin),
keywords: plugin.release.keywords.clone(),
})
}
@@ -1068,6 +1143,18 @@ fn remote_plugin_display_name(plugin: &RemotePluginSummary) -> &str {
.unwrap_or(&plugin.name)
}
fn sort_remote_plugin_summaries_by_display_name(plugins: &mut [RemotePluginSummary]) {
plugins.sort_by(|left, right| {
let left_display_name = remote_plugin_display_name(left);
let right_display_name = remote_plugin_display_name(right);
left_display_name
.to_ascii_lowercase()
.cmp(&right_display_name.to_ascii_lowercase())
.then_with(|| left_display_name.cmp(right_display_name))
.then_with(|| left.id.cmp(&right.id))
});
}
fn non_empty_string(value: Option<&str>) -> Option<String> {
value.and_then(|value| {
let value = value.trim();
@@ -78,7 +78,7 @@ pub struct RemotePluginCacheMutationGuard {
key: RemotePluginCacheMutationKey,
}
pub fn maybe_start_remote_installed_plugin_bundle_sync(
pub(crate) fn maybe_start_remote_installed_plugin_bundle_sync(
codex_home: PathBuf,
config: RemotePluginServiceConfig,
auth: Option<CodexAuth>,