[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
+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();