[codex] [3/4] Activate endpoint plugin recommendations (#27704)

Summary\n- Await endpoint recommendation selection while constructing
each authenticated turn, removing the first-turn cache race.\n- Snapshot
and filter endpoint candidates once per turn, then use that same set for
the bounded contextual user fragment, tool exposure, and exact install
validation.\n- Keep recommendation selection ephemeral: do not persist
recommendation state in or gate resumed threads on prior context.\n-
Hide the legacy list tool in endpoint mode and preserve legacy discovery
unchanged when the endpoint is disabled or unavailable.\n- Keep remote
plugin and connector app identities out of model-visible context and
attach them only to Codex-owned elicitation metadata.\n\nStack\n- 3/4,
based on #28400.\n- Endpoint client and cache: #28399.\n- Generalized
suggestion presentation: #28400.\n- Install-schema follow-up:
#28403.\n\nValidation\n- \n- \n- \n- \n- Full : 2,649 passed and 88
environment-dependent tests failed because this sandbox cannot write ,
nest Seatbelt, or locate auxiliary test binaries.
This commit is contained in:
Alex Daley
2026-06-16 19:04:07 -04:00
committed by GitHub
Unverified
parent 587487df9e
commit a34da3b295
18 changed files with 846 additions and 124 deletions
+1
View File
@@ -49,6 +49,7 @@ pub use manager::PluginReadRequest;
pub use manager::PluginUninstallError;
pub use manager::PluginsConfigInput;
pub use manager::PluginsManager;
pub use manager::RecommendedPluginCandidatesInput;
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
pub use provider::ExecutorPluginProvider;
+67
View File
@@ -57,6 +57,8 @@ use codex_config::ConfigLayerStack;
use codex_config::clear_user_plugin;
use codex_config::set_user_plugin_enabled;
use codex_config::types::PluginConfig;
use codex_config::types::ToolSuggestDisabledTool;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_core_skills::SkillMetadata;
use codex_core_skills::config_rules::SkillConfigRules;
use codex_core_skills::config_rules::skill_config_rules_from_stack;
@@ -71,6 +73,9 @@ use codex_plugin::app_connector_ids_from_declarations;
use codex_plugin::prompt_safe_plugin_description;
use codex_protocol::protocol::HookEventName;
use codex_protocol::protocol::Product;
use codex_tools::DiscoverablePluginInfo;
use codex_tools::DiscoverableTool;
use codex_tools::filter_request_plugin_install_discoverable_tools_for_client;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::PluginSkillRoot;
use std::collections::HashMap;
@@ -114,6 +119,15 @@ impl PluginsConfigInput {
}
}
/// Inputs used to select endpoint-backed plugin install candidates.
pub struct RecommendedPluginCandidatesInput<'a> {
pub plugins_config: &'a PluginsConfigInput,
pub loaded_plugins: &'a PluginLoadOutcome,
pub auth: Option<&'a CodexAuth>,
pub disabled_tools: &'a [ToolSuggestDisabledTool],
pub app_server_client_name: Option<&'a str>,
}
#[derive(Clone, PartialEq, Eq)]
struct FeaturedPluginIdsCacheKey {
chatgpt_base_url: String,
@@ -997,6 +1011,59 @@ impl PluginsManager {
mode
}
/// Returns endpoint recommendations eligible for installation in the current client.
/// `None` selects the legacy discovery workflow.
pub async fn recommended_plugin_candidates_for_config(
&self,
input: RecommendedPluginCandidatesInput<'_>,
) -> Option<Vec<DiscoverableTool>> {
let RecommendedPluginsMode::Endpoint { plugins } = self
.recommended_plugins_mode_for_config(input.plugins_config, input.auth)
.await
else {
return None;
};
if plugins.is_empty() {
return Some(Vec::new());
}
let installed_plugin_ids = input
.loaded_plugins
.plugins()
.iter()
.map(|plugin| plugin.config_name.as_str())
.collect::<HashSet<_>>();
let disabled_plugin_ids = input
.disabled_tools
.iter()
.filter(|tool| tool.kind == ToolSuggestDiscoverableType::Plugin)
.map(|tool| tool.id.as_str())
.collect::<HashSet<_>>();
let candidates = plugins
.into_iter()
.filter(|plugin| {
!installed_plugin_ids.contains(plugin.config_id.as_str())
&& !disabled_plugin_ids.contains(plugin.config_id.as_str())
})
.map(|plugin| {
DiscoverableTool::from(DiscoverablePluginInfo {
id: plugin.config_id,
remote_plugin_id: Some(plugin.remote_plugin_id),
name: plugin.display_name,
description: None,
has_skills: false,
mcp_server_names: Vec::new(),
app_connector_ids: plugin.app_connector_ids,
})
})
.collect();
Some(filter_request_plugin_install_discoverable_tools_for_client(
candidates,
input.app_server_client_name,
))
}
fn cached_recommended_plugins_mode(
&self,
cache_key: &RecommendedPluginsCacheKey,
@@ -3844,6 +3844,79 @@ remote_plugin = true
);
}
#[tokio::test]
async fn recommended_plugin_candidates_filter_installed_and_disabled_plugins() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
remote_plugin = true
"#,
);
write_cached_plugin(tmp.path(), REMOTE_GLOBAL_MARKETPLACE_NAME, "linear");
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/ps/plugins/suggested"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"enabled": true,
"plugins": [
{
"id": "plugin_linear",
"name": "linear",
"release": {"display_name": "Linear"}
},
{
"id": "plugin_github",
"name": "github",
"release": {"display_name": "GitHub"}
},
{
"id": "plugin_slack",
"name": "slack",
"release": {"display_name": "Slack"}
}
]
})))
.expect(1)
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = server.uri();
let manager = PluginsManager::new(tmp.path().to_path_buf());
manager.write_remote_installed_plugins_cache(vec![remote_installed_plugin("linear")]);
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let disabled_tools = [ToolSuggestDisabledTool::plugin(
"github@openai-curated-remote",
)];
let loaded_plugins = manager.plugins_for_config(&config).await;
let candidates = manager
.recommended_plugin_candidates_for_config(RecommendedPluginCandidatesInput {
plugins_config: &config,
loaded_plugins: &loaded_plugins,
auth: Some(&auth),
disabled_tools: &disabled_tools,
app_server_client_name: None,
})
.await;
assert_eq!(
candidates,
Some(vec![DiscoverableTool::from(DiscoverablePluginInfo {
id: "slack@openai-curated-remote".to_string(),
remote_plugin_id: Some("plugin_slack".to_string()),
name: "Slack".to_string(),
description: None,
has_skills: false,
mcp_server_names: Vec::new(),
app_connector_ids: Vec::new(),
})])
);
}
#[tokio::test]
async fn recommended_plugins_mode_caches_explicit_false() {
let tmp = tempfile::tempdir().unwrap();