mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
@@ -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>,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user