From 5f86b85c8614cc8532d324f376a0db7b44bf72f6 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Tue, 12 May 2026 03:44:16 -0700 Subject: [PATCH] Resolve remote plugin template app ids --- .../src/request_processors/plugins.rs | 6 + .../tests/suite/v2/plugin_install.rs | 81 +++++++++++++ codex-rs/core-plugins/src/loader.rs | 6 +- codex-rs/core-plugins/src/manager.rs | 49 +++++++- codex-rs/core-plugins/src/manager_tests.rs | 49 ++++++++ codex-rs/core-plugins/src/remote.rs | 106 ++++++++++++++++++ codex-rs/core-plugins/src/remote/tests.rs | 105 +++++++++++++++++ 7 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 codex-rs/core-plugins/src/remote/tests.rs diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 125accad82..d8df427a1b 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -1176,6 +1176,12 @@ impl PluginRequestProcessor { } let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; + let plugin_apps = codex_core_plugins::remote::resolve_remote_plugin_app_ids( + &remote_plugin_service_config, + auth.as_ref(), + &plugin_apps, + ) + .await; let apps_needing_auth = self .plugin_apps_needing_auth_for_install( &config, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 6adcd92195..e1a68bb0f9 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -723,6 +723,56 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_resolves_remote_bundle_template_app_ids() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let bundle_url = mount_remote_plugin_bundle( + &server, + /*status_code*/ 200, + remote_plugin_bundle_tar_gz_bytes_with_apps( + "linear", + &["templated_apps_GitHubEnterprise"], + )?, + ) + .await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_empty_remote_installed_plugins(&server).await; + mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; + mount_remote_template_connector_ids( + &server, + "templated_apps_GitHubEnterprise", + &["asdk_app_ghe"], + ) + .await; + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstallResponse = to_response(response)?; + assert_eq!(response.apps_needing_auth, Vec::::new()); + + wait_for_remote_plugin_request_count( + &server, + "GET", + "/ps/connectors/by_template_id/templated_apps_GitHubEnterprise", + /*expected_count*/ 1, + ) + .await?; + Ok(()) +} + #[tokio::test] async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> { let codex_home = TempDir::new()?; @@ -1429,6 +1479,24 @@ async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str .await; } +async fn mount_remote_template_connector_ids( + server: &MockServer, + template_id: &str, + connector_ids: &[&str], +) { + Mock::given(method("GET")) + .and(path(format!( + "/backend-api/ps/connectors/by_template_id/{template_id}" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "connector_ids": connector_ids, + }))) + .mount(server) + .await; +} + #[derive(Debug, Clone)] struct CacheManifestExists { manifest_path: std::path::PathBuf, @@ -1575,8 +1643,20 @@ fn write_plugin_source( } fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { + remote_plugin_bundle_tar_gz_bytes_with_apps(plugin_name, &[]) +} + +fn remote_plugin_bundle_tar_gz_bytes_with_apps( + plugin_name: &str, + app_ids: &[&str], +) -> Result> { let manifest = format!(r#"{{"name":"{plugin_name}"}}"#); let skill = "# Plan Work\n\nTrack work in Linear.\n"; + let apps = app_ids + .iter() + .map(|app_id| ((*app_id).to_string(), json!({ "id": app_id }))) + .collect::>(); + let apps = serde_json::to_vec_pretty(&json!({ "apps": apps }))?; let encoder = GzEncoder::new(Vec::new(), Compression::default()); let mut tar = tar::Builder::new(encoder); for (path, contents, mode) in [ @@ -1590,6 +1670,7 @@ fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { skill.as_bytes(), /*mode*/ 0o644, ), + (".app.json", apps.as_slice(), /*mode*/ 0o644), ] { let mut header = tar::Header::new_gnu(); header.set_size(contents.len() as u64); diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index f348a3414d..f65670e20d 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -110,6 +110,7 @@ struct PluginAppConfig { pub async fn load_plugins_from_layer_stack( config_layer_stack: &ConfigLayerStack, extra_plugins: HashMap, + app_overrides: HashMap>, store: &PluginStore, restriction_product: Option, plugin_hooks_enabled: bool, @@ -123,7 +124,7 @@ pub async fn load_plugins_from_layer_stack( let mut plugins = Vec::with_capacity(configured_plugins.len()); let mut seen_mcp_server_names = HashMap::::new(); for (configured_name, plugin) in configured_plugins { - let loaded_plugin = load_plugin( + let mut loaded_plugin = load_plugin( configured_name.clone(), &plugin, store, @@ -132,6 +133,9 @@ pub async fn load_plugins_from_layer_stack( plugin_hooks_enabled, ) .await; + if let Some(apps) = app_overrides.get(&configured_name) { + loaded_plugin.apps = apps.clone(); + } for name in loaded_plugin.mcp_servers.keys() { if let Some(previous_plugin) = seen_mcp_server_names.insert(name.clone(), configured_name.clone()) diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index 42f5ac73db..553082b3cb 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -493,6 +493,7 @@ impl PluginsManager { let outcome = load_plugins_from_layer_stack( &config.config_layer_stack, self.remote_installed_plugin_configs(config), + self.remote_installed_plugin_app_overrides(config), &self.store, self.restriction_product, plugin_hooks_enabled, @@ -541,6 +542,7 @@ impl PluginsManager { load_plugins_from_layer_stack( config_layer_stack, self.remote_installed_plugin_configs(config), + self.remote_installed_plugin_app_overrides(config), &self.store, self.restriction_product, plugin_hooks_feature_enabled, @@ -602,6 +604,32 @@ impl PluginsManager { remote_installed_plugins_to_config(plugins, &self.store) } + fn remote_installed_plugin_app_overrides( + &self, + config: &PluginsConfigInput, + ) -> HashMap> { + if !config.remote_plugin_enabled { + return HashMap::new(); + } + + let cache = match self.remote_installed_plugins_cache.read() { + Ok(cache) => cache, + Err(err) => err.into_inner(), + }; + let Some(plugins) = cache.as_ref() else { + return HashMap::new(); + }; + + plugins + .iter() + .filter_map(|plugin| { + PluginId::new(plugin.name.clone(), plugin.marketplace_name.clone()) + .ok() + .map(|plugin_id| (plugin_id.as_key(), plugin.app_connector_ids.clone())) + }) + .collect() + } + fn write_remote_installed_plugins_cache(&self, plugins: Vec) -> bool { let mut cache = match self.remote_installed_plugins_cache.write() { Ok(cache) => cache, @@ -1756,7 +1784,26 @@ impl PluginsManager { ) .await; match installed_plugins { - Ok(installed_plugins) => { + Ok(mut installed_plugins) => { + for installed_plugin in &mut installed_plugins { + let Ok(plugin_id) = PluginId::new( + installed_plugin.name.clone(), + installed_plugin.marketplace_name.clone(), + ) else { + continue; + }; + let Some(plugin_root) = self.store.active_plugin_root(&plugin_id) else { + continue; + }; + let bundle_app_ids = load_plugin_apps(plugin_root.as_path()).await; + installed_plugin.app_connector_ids = + crate::remote::resolve_remote_plugin_app_ids( + &request.service_config, + request.auth.as_ref(), + &bundle_app_ids, + ) + .await; + } // TODO(remote plugins): reconcile missing or stale local bundles before // publishing remote installed state as effective local plugin config. let changed = self.write_remote_installed_plugins_cache(installed_plugins); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index e1c0f1b121..d651d7c2b2 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -352,6 +352,7 @@ remote_plugin = true id: "plugins~Plugin_linear".to_string(), name: "linear".to_string(), enabled: true, + app_connector_ids: Vec::new(), }]); let outcome = manager.plugins_for_config(&config).await; @@ -363,6 +364,52 @@ remote_plugin = true assert_eq!(outcome.plugins()[0].config_name, "linear@chatgpt-global"); } +#[tokio::test] +async fn remote_installed_cache_uses_resolved_bundle_app_ids_for_runtime_loading() { + 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( + &plugin_base.join("local/.app.json"), + r#"{ + "apps": { + "github-enterprise": { + "id": "templated_apps_GitHubEnterprise" + } + } +}"#, + ); + write_file( + &codex_home.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true +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, + app_connector_ids: vec![AppConnectorId("asdk_app_ghe".to_string())], + }]); + + let outcome = manager.plugins_for_config(&config).await; + assert_eq!( + outcome.effective_apps(), + vec![AppConnectorId("asdk_app_ghe".to_string())] + ); + assert_eq!( + outcome.capability_summaries()[0].app_connector_ids, + vec![AppConnectorId("asdk_app_ghe".to_string())] + ); +} + #[tokio::test] async fn remote_installed_cache_ignores_plugins_missing_local_cache() { let codex_home = TempDir::new().unwrap(); @@ -381,6 +428,7 @@ remote_plugin = true id: "plugins~Plugin_linear".to_string(), name: "linear".to_string(), enabled: true, + app_connector_ids: Vec::new(), }]); let outcome = manager.plugins_for_config(&config).await; @@ -3666,6 +3714,7 @@ async fn load_plugins_ignores_project_config_files() { let outcome = load_plugins_from_layer_stack( &stack, std::collections::HashMap::new(), + std::collections::HashMap::new(), &PluginStore::new(codex_home.path().to_path_buf()), Some(Product::Codex), /*plugin_hooks_enabled*/ false, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index bb0e13a79d..bba621b758 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::SkillInterface; use codex_login::CodexAuth; use codex_login::default_client::build_reqwest_client; +use codex_plugin::AppConnectorId; use codex_plugin::PluginId; use codex_utils_absolute_path::AbsolutePathBuf; use reqwest::RequestBuilder; @@ -18,6 +19,7 @@ use std::collections::HashSet; use std::fs; use std::path::PathBuf; use std::time::Duration; +use tracing::warn; use url::Url; mod remote_installed_plugin_sync; @@ -56,6 +58,7 @@ 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 TEMPLATE_APP_ID_PREFIX: &str = "templated_apps_"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginServiceConfig { @@ -82,6 +85,7 @@ pub struct RemoteInstalledPlugin { pub id: String, pub name: String, pub enabled: bool, + pub app_connector_ids: Vec, } #[derive(Debug, Clone, PartialEq)] @@ -441,6 +445,12 @@ struct RemotePluginMutationResponse { enabled: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemoteTemplateConnectorIdsResponse { + #[serde(default)] + connector_ids: Vec, +} + pub async fn fetch_remote_marketplaces( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -588,6 +598,67 @@ pub async fn fetch_remote_installed_plugins( Ok(installed_plugins) } +pub async fn resolve_remote_plugin_app_ids( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + app_ids: &[AppConnectorId], +) -> Vec { + let mut resolved_app_ids = Vec::new(); + let mut seen_app_ids = HashSet::new(); + let mut template_connector_ids = BTreeMap::>>::new(); + + for app_id in app_ids { + if !app_id.0.starts_with(TEMPLATE_APP_ID_PREFIX) { + if seen_app_ids.insert(app_id.clone()) { + resolved_app_ids.push(app_id.clone()); + } + continue; + } + + let connector_ids = if let Some(connector_ids) = template_connector_ids.get(&app_id.0) { + connector_ids.clone() + } else { + let connector_ids = match ensure_chatgpt_auth(auth) { + Ok(auth) => { + match fetch_template_connector_ids(config, auth, app_id.0.as_str()).await { + Ok(connector_ids) => Some(connector_ids), + Err(err) => { + warn!( + template_app_id = %app_id.0, + error = %err, + "failed to resolve remote plugin template app id; dropping it" + ); + None + } + } + } + Err(err) => { + warn!( + template_app_id = %app_id.0, + error = %err, + "cannot resolve remote plugin template app id without ChatGPT auth; dropping it" + ); + None + } + }; + template_connector_ids.insert(app_id.0.clone(), connector_ids.clone()); + connector_ids + }; + + let Some(connector_ids) = connector_ids else { + continue; + }; + for connector_id in connector_ids { + let connector_id = AppConnectorId(connector_id); + if seen_app_ids.insert(connector_id.clone()) { + resolved_app_ids.push(connector_id); + } + } + } + + resolved_app_ids +} + pub async fn fetch_remote_plugin_detail( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -929,6 +1000,7 @@ fn remote_installed_plugin_to_info( id: plugin.id.clone(), name: plugin.name.clone(), enabled: installed_plugin.enabled, + app_connector_ids: Vec::new(), }) } @@ -1163,6 +1235,18 @@ async fn fetch_plugin_detail( send_and_decode(request, &url).await } +async fn fetch_template_connector_ids( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + template_id: &str, +) -> Result, RemotePluginCatalogError> { + let url = remote_template_connector_ids_url(config, template_id)?; + let client = build_reqwest_client(); + let request = authenticated_request(client.get(&url), auth)?; + let response: RemoteTemplateConnectorIdsResponse = send_and_decode(request, &url).await?; + Ok(response.connector_ids) +} + fn remote_plugin_skill_detail_url( config: &RemotePluginServiceConfig, plugin_id: &str, @@ -1184,6 +1268,25 @@ fn remote_plugin_skill_detail_url( Ok(url.to_string()) } +fn remote_template_connector_ids_url( + config: &RemotePluginServiceConfig, + template_id: &str, +) -> Result { + let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/')) + .map_err(RemotePluginCatalogError::InvalidBaseUrl)?; + { + let mut segments = url + .path_segments_mut() + .map_err(|()| RemotePluginCatalogError::InvalidBaseUrlPath)?; + segments.pop_if_empty(); + segments.push("ps"); + segments.push("connectors"); + segments.push("by_template_id"); + segments.push(template_id); + } + Ok(url.to_string()) +} + fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> { let Some(auth) = auth else { return Err(RemotePluginCatalogError::AuthRequired); @@ -1229,3 +1332,6 @@ async fn send_and_decode Deserialize<'de>>( source, }) } + +#[cfg(test)] +mod tests; diff --git a/codex-rs/core-plugins/src/remote/tests.rs b/codex-rs/core-plugins/src/remote/tests.rs new file mode 100644 index 0000000000..878f4f32cd --- /dev/null +++ b/codex-rs/core-plugins/src/remote/tests.rs @@ -0,0 +1,105 @@ +use super::*; +use codex_login::CodexAuth; +use pretty_assertions::assert_eq; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +fn test_config(server: &MockServer) -> RemotePluginServiceConfig { + RemotePluginServiceConfig { + chatgpt_base_url: format!("{}/backend-api", server.uri()), + } +} + +fn test_auth() -> CodexAuth { + CodexAuth::create_dummy_chatgpt_auth_for_testing() +} + +fn app(id: &str) -> AppConnectorId { + AppConnectorId(id.to_string()) +} + +#[tokio::test] +async fn resolve_remote_plugin_app_ids_expands_templates_and_dedupes_stably() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/connectors/by_template_id/templated_apps_GitHubEnterprise", + )) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{"connector_ids":["connector_ghe","asdk_app_ghe","connector_ghe"]}"#, + )) + .mount(&server) + .await; + + let resolved = resolve_remote_plugin_app_ids( + &test_config(&server), + Some(&test_auth()), + &[ + app("asdk_app_linear"), + app("templated_apps_GitHubEnterprise"), + app("asdk_app_linear"), + app("asdk_app_ghe"), + ], + ) + .await; + + assert_eq!( + resolved, + vec![ + app("asdk_app_linear"), + app("connector_ghe"), + app("asdk_app_ghe"), + ] + ); +} + +#[tokio::test] +async fn resolve_remote_plugin_app_ids_drops_missing_template_mappings() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/connectors/by_template_id/templated_apps_GitHubEnterprise", + )) + .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"connector_ids":[]}"#)) + .mount(&server) + .await; + + let resolved = resolve_remote_plugin_app_ids( + &test_config(&server), + Some(&test_auth()), + &[app("templated_apps_GitHubEnterprise")], + ) + .await; + + assert_eq!(resolved, Vec::::new()); +} + +#[tokio::test] +async fn resolve_remote_plugin_app_ids_drops_templates_when_lookup_fails() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/connectors/by_template_id/templated_apps_GitHubEnterprise", + )) + .respond_with(ResponseTemplate::new(500).set_body_string("lookup failed")) + .mount(&server) + .await; + + let resolved = resolve_remote_plugin_app_ids( + &test_config(&server), + Some(&test_auth()), + &[ + app("asdk_app_linear"), + app("templated_apps_GitHubEnterprise"), + ], + ) + .await; + + assert_eq!(resolved, vec![app("asdk_app_linear")]); +}