Compare commits

...

1 Commits

Author SHA1 Message Date
xli-oai
5f86b85c86 Resolve remote plugin template app ids 2026-05-12 03:44:16 -07:00
7 changed files with 400 additions and 2 deletions

View File

@@ -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,

View File

@@ -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::<AppSummary>::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<Vec<u8>> {
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<Vec<u8>> {
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::<serde_json::Map<_, _>>();
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<Vec<u8>> {
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);

View File

@@ -110,6 +110,7 @@ struct PluginAppConfig {
pub async fn load_plugins_from_layer_stack(
config_layer_stack: &ConfigLayerStack,
extra_plugins: HashMap<String, PluginConfig>,
app_overrides: HashMap<String, Vec<AppConnectorId>>,
store: &PluginStore,
restriction_product: Option<Product>,
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::<String, String>::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())

View File

@@ -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<String, Vec<AppConnectorId>> {
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<RemoteInstalledPlugin>) -> 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);

View File

@@ -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,

View File

@@ -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<AppConnectorId>,
}
#[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<String>,
}
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<AppConnectorId> {
let mut resolved_app_ids = Vec::new();
let mut seen_app_ids = HashSet::new();
let mut template_connector_ids = BTreeMap::<String, Option<Vec<String>>>::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<Vec<String>, 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<String, RemotePluginCatalogError> {
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<T: for<'de> Deserialize<'de>>(
source,
})
}
#[cfg(test)]
mod tests;

View File

@@ -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::<AppConnectorId>::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")]);
}