Compare commits

...

7 Commits

Author SHA1 Message Date
Noah MacCallum
11d1e9cd19 Merge remote-tracking branch 'origin/main' into nm-codex/resolve-template-app-ids
# Conflicts:
#	codex-rs/core/src/connectors.rs
#	codex-rs/core/src/plugins/discoverable.rs
#	codex-rs/core/src/plugins/discoverable_tests.rs
2026-05-29 15:58:21 -07:00
Noah MacCallum
c243abfc81 Match plugin suggestions against resolved connector candidates 2026-05-29 15:32:11 -07:00
Noah MacCallum
4cd3ab4a3d Resolve templated connectors from workspace directory 2026-05-29 15:04:17 -07:00
Noah MacCallum
25d081d10b Resolve templated connector suggestions 2026-05-29 14:14:56 -07:00
Noah MacCallum
bcd8d541f5 Resolve templated plugin app IDs 2026-05-29 13:40:54 -07:00
Noah MacCallum
10a8a4e84f Support plugin install suggestions from loaded plugin apps 2026-05-29 01:31:17 -07:00
Noah MacCallum
24d5ccc19b Filter plugin install suggestions by installed apps 2026-05-28 21:45:10 -07:00
10 changed files with 1062 additions and 65 deletions

View File

@@ -1364,8 +1364,16 @@ impl PluginRequestProcessor {
.await;
}
let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await;
let auth = self.auth_manager.auth().await;
let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await;
let plugin_apps = codex_core_plugins::remote::resolve_remote_plugin_app_ids(
&RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
},
auth.as_ref(),
&plugin_apps,
)
.await;
let apps_needing_auth = self
.plugin_apps_needing_auth_for_install(
&config,
@@ -1481,6 +1489,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

@@ -1,4 +1,5 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
@@ -13,6 +14,7 @@ use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use axum::Json;
use axum::Router;
use axum::extract::Path as AxumPath;
use axum::extract::State;
use axum::http::HeaderMap;
use axum::http::StatusCode;
@@ -801,6 +803,56 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn remote_plugin_install_resolves_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_contents(
r#"{"name":"linear"}"#,
Some(r#"{"apps":{"databricks":{"id":"templated_apps_Databricks"}}}"#),
)?,
)
.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_Databricks",
&["asdk_app_databricks_workspace"],
)
.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_Databricks",
/*expected_count*/ 1,
)
.await?;
Ok(())
}
#[tokio::test]
async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -953,6 +1005,100 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_install_resolves_template_apps_for_apps_needing_auth() -> Result<()> {
let connectors = vec![AppInfo {
id: "asdk_app_databricks_workspace".to_string(),
name: "Databricks".to_string(),
description: Some("Workspace Databricks connector".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}];
let (server_url, server_handle) = start_apps_server_with_template_connector_ids(
connectors,
Vec::new(),
HashMap::from([(
"templated_apps_Databricks".to_string(),
vec!["asdk_app_databricks_workspace".to_string()],
)]),
)
.await?;
let codex_home = TempDir::new()?;
write_connectors_config(codex_home.path(), &server_url)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
let repo_root = TempDir::new()?;
write_plugin_marketplace(
repo_root.path(),
"debug",
"sample-plugin",
"./sample-plugin",
/*install_policy*/ None,
/*auth_policy*/ None,
)?;
write_plugin_source(
repo_root.path(),
"sample-plugin",
&["templated_apps_Databricks"],
)?;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_install_request(PluginInstallParams {
marketplace_path: Some(marketplace_path),
remote_marketplace_name: None,
plugin_name: "sample-plugin".to_string(),
})
.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,
PluginInstallResponse {
auth_policy: PluginAuthPolicy::OnInstall,
apps_needing_auth: vec![AppSummary {
id: "asdk_app_databricks_workspace".to_string(),
name: "Databricks".to_string(),
description: Some("Workspace Databricks connector".to_string()),
install_url: Some(
"https://chatgpt.com/apps/databricks/asdk_app_databricks_workspace".to_string(),
),
needs_auth: true,
}],
}
);
server_handle.abort();
let _ = server_handle.await;
Ok(())
}
#[tokio::test]
async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
let connectors = vec![AppInfo {
@@ -1113,6 +1259,7 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests
#[derive(Clone)]
struct AppsServerState {
response: Arc<StdMutex<serde_json::Value>>,
template_connector_ids: Arc<StdMutex<HashMap<String, Vec<String>>>>,
}
#[derive(Clone)]
@@ -1149,11 +1296,20 @@ impl ServerHandler for PluginInstallMcpServer {
async fn start_apps_server(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
) -> Result<(String, JoinHandle<()>)> {
start_apps_server_with_template_connector_ids(connectors, tools, HashMap::new()).await
}
async fn start_apps_server_with_template_connector_ids(
connectors: Vec<AppInfo>,
tools: Vec<Tool>,
template_connector_ids: HashMap<String, Vec<String>>,
) -> Result<(String, JoinHandle<()>)> {
let state = Arc::new(AppsServerState {
response: Arc::new(StdMutex::new(
json!({ "apps": connectors, "next_token": null }),
)),
template_connector_ids: Arc::new(StdMutex::new(template_connector_ids)),
});
let tools = Arc::new(StdMutex::new(tools));
@@ -1177,6 +1333,10 @@ async fn start_apps_server(
"/connectors/directory/list_workspace",
get(list_directory_connectors),
)
.route(
"/ps/connectors/by_template_id/{template_id}",
get(template_connector_ids_response),
)
.with_state(state)
.nest_service("/api/codex/apps", mcp_service);
@@ -1187,6 +1347,33 @@ async fn start_apps_server(
Ok((format!("http://{addr}"), handle))
}
async fn template_connector_ids_response(
State(state): State<Arc<AppsServerState>>,
AxumPath(template_id): AxumPath<String>,
headers: HeaderMap,
) -> Result<impl axum::response::IntoResponse, StatusCode> {
let bearer_ok = headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "Bearer chatgpt-token");
let account_ok = headers
.get("chatgpt-account-id")
.and_then(|value| value.to_str().ok())
.is_some_and(|value| value == "account-123");
if !bearer_ok || !account_ok {
return Err(StatusCode::UNAUTHORIZED);
}
let connector_ids = state
.template_connector_ids
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&template_id)
.cloned()
.unwrap_or_default();
Ok(Json(json!({ "connector_ids": connector_ids })))
}
async fn list_directory_connectors(
State(state): State<Arc<AppsServerState>>,
headers: HeaderMap,
@@ -1492,6 +1679,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,

View File

@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::future::Future;
use std::sync::LazyLock;
use std::sync::Mutex as StdMutex;
@@ -77,6 +78,8 @@ pub struct DirectoryApp {
logo_url_dark: Option<String>,
#[serde(alias = "distributionChannel")]
distribution_channel: Option<String>,
#[serde(alias = "templateId")]
template_id: Option<String>,
visibility: Option<String>,
}
@@ -152,16 +155,7 @@ where
.into_iter()
.map(directory_app_to_app_info)
.collect::<Vec<_>>();
for connector in &mut connectors {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.name, &connector.id),
};
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
normalize_directory_app_infos(&mut connectors);
connectors.sort_by(|left, right| {
left.name
.cmp(&right.name)
@@ -248,6 +242,35 @@ where
}
}
pub async fn list_workspace_template_connectors<F, Fut>(
template_ids: &HashSet<String>,
mut fetch_page: F,
) -> anyhow::Result<Vec<AppInfo>>
where
F: FnMut(String) -> Fut,
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
{
let mut connectors = list_workspace_connectors(&mut fetch_page)
.await?
.into_iter()
.filter(|app| {
app.template_id
.as_deref()
.is_some_and(|template_id| template_ids.contains(template_id))
})
.map(directory_app_to_app_info)
.collect::<Vec<_>>();
for connector in &mut connectors {
normalize_directory_app_info(connector);
}
connectors.sort_by(|left, right| {
left.name
.cmp(&right.name)
.then_with(|| left.id.cmp(&right.id))
});
Ok(connectors)
}
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
for app in apps {
@@ -271,6 +294,7 @@ fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
logo_url,
logo_url_dark,
distribution_channel,
template_id,
visibility: _,
} = incoming;
@@ -296,6 +320,9 @@ fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
existing.distribution_channel = distribution_channel;
}
if existing.template_id.is_none() && template_id.is_some() {
existing.template_id = template_id;
}
if let Some(incoming_branding) = branding {
if let Some(existing_branding) = existing.branding.as_mut() {
@@ -422,6 +449,23 @@ fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
}
}
fn normalize_directory_app_infos(connectors: &mut [AppInfo]) {
for connector in connectors {
normalize_directory_app_info(connector);
}
}
fn normalize_directory_app_info(connector: &mut AppInfo) {
let install_url = match connector.install_url.take() {
Some(install_url) => install_url,
None => connector_install_url(&connector.name, &connector.id),
};
connector.name = normalize_connector_name(&connector.name, &connector.id);
connector.description = normalize_connector_value(connector.description.as_deref());
connector.install_url = Some(install_url);
connector.is_accessible = false;
}
fn connector_install_url(name: &str, connector_id: &str) -> String {
let slug = connector_name_slug(name);
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
@@ -504,6 +548,7 @@ mod tests {
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
template_id: None,
visibility: None,
}
}

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;
@@ -19,6 +20,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;
@@ -65,6 +67,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_";
const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [
(
REMOTE_GLOBAL_MARKETPLACE_NAME,
@@ -485,6 +488,20 @@ struct RemotePluginMutationResponse {
enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemoteWorkspaceConnectorDirectoryResponse {
#[serde(default)]
apps: Vec<RemoteWorkspaceConnectorDirectoryApp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemoteWorkspaceConnectorDirectoryApp {
id: String,
#[serde(alias = "templateId")]
template_id: Option<String>,
visibility: Option<String>,
}
pub async fn fetch_remote_marketplaces(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
@@ -703,6 +720,67 @@ pub(crate) 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 fn group_remote_installed_plugins_by_marketplaces(
plugins: &[RemoteInstalledPlugin],
visible_scopes: &[RemotePluginScope],
@@ -1378,6 +1456,27 @@ 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_workspace_connector_directory_url(config)?;
let client = build_reqwest_client();
let request = authenticated_request(client.get(&url), auth)?;
let response: RemoteWorkspaceConnectorDirectoryResponse =
send_and_decode(request, &url).await?;
Ok(response
.apps
.into_iter()
.filter(|app| {
app.template_id.as_deref() == Some(template_id)
&& !matches!(app.visibility.as_deref(), Some("HIDDEN"))
})
.map(|app| app.id)
.collect())
}
fn remote_plugin_skill_detail_url(
config: &RemotePluginServiceConfig,
plugin_id: &str,
@@ -1399,6 +1498,24 @@ fn remote_plugin_skill_detail_url(
Ok(url.to_string())
}
fn remote_workspace_connector_directory_url(
config: &RemotePluginServiceConfig,
) -> 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("connectors");
segments.push("directory");
segments.push("list_workspace");
}
url.set_query(Some("external_logos=true"));
Ok(url.to_string())
}
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> {
let Some(auth) = auth else {
return Err(RemotePluginCatalogError::AuthRequired);
@@ -1444,3 +1561,6 @@ async fn send_and_decode<T: for<'de> Deserialize<'de>>(
source,
})
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,110 @@
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/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"apps":[
{"id":"connector_ghe","template_id":"templated_apps_GitHubEnterprise"},
{"id":"asdk_app_ghe","template_id":"templated_apps_GitHubEnterprise"},
{"id":"asdk_app_other","template_id":"templated_apps_Other"},
{"id":"asdk_app_hidden","template_id":"templated_apps_GitHubEnterprise","visibility":"HIDDEN"}
]}"#,
))
.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/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"apps":[{"id":"asdk_app_other","template_id":"templated_apps_Other"}]}"#,
))
.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/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.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")]);
}

View File

@@ -22,7 +22,7 @@ use tracing::warn;
use crate::config::Config;
use crate::mcp::McpManager;
use crate::plugins::list_tool_suggest_discoverable_plugins;
use crate::plugins::list_tool_suggest_discoverable_plugins_with_connector_candidates;
use crate::session::INITIAL_SUBMIT_ID;
use codex_config::AppsRequirementsToml;
use codex_config::types::AppToolApproval;
@@ -32,6 +32,7 @@ use codex_core_plugins::PluginsManager;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use codex_login::default_client::originator;
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
use codex_mcp::McpConnectionManager;
@@ -42,8 +43,11 @@ use codex_mcp::codex_apps_tools_cache_key;
use codex_mcp::compute_auth_statuses;
use codex_mcp::host_owned_codex_apps_enabled;
use codex_mcp::with_codex_apps_mcp;
use codex_plugin::AppConnectorId;
use url::Url;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
const TEMPLATE_APP_ID_PREFIX: &str = "templated_apps_";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AppToolPolicy {
@@ -115,9 +119,24 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
accessible_connectors: &[AppInfo],
loaded_plugin_app_connector_ids: &[String],
) -> anyhow::Result<Vec<DiscoverableTool>> {
let connector_ids = tool_suggest_connector_ids(config).await;
let connector_selection = tool_suggest_connector_selection(config).await;
let mut connector_ids = connector_selection.connector_ids.clone();
let mut directory_connectors =
cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await;
let template_directory_connectors = workspace_template_directory_connectors(
config,
auth,
&connector_selection.template_ids,
&connector_selection.disabled_template_ids,
&connector_selection.disabled_connector_ids,
)
.await;
for connector in template_directory_connectors {
connector_ids.insert(connector.id.clone());
directory_connectors.push(connector);
}
let directory_connectors = codex_connectors::merge::merge_plugin_connectors(
cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await,
directory_connectors,
connector_ids.iter().cloned(),
);
let discoverable_connectors =
@@ -129,11 +148,16 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
)
.into_iter()
.map(DiscoverableTool::from);
let discoverable_plugins =
list_tool_suggest_discoverable_plugins(config, loaded_plugin_app_connector_ids)
.await?
.into_iter()
.map(DiscoverableTool::from);
let candidate_app_connector_ids = connector_ids.iter().cloned().collect::<Vec<_>>();
let discoverable_plugins = list_tool_suggest_discoverable_plugins_with_connector_candidates(
config,
auth,
loaded_plugin_app_connector_ids,
&candidate_app_connector_ids,
)
.await?
.into_iter()
.map(DiscoverableTool::from);
Ok(discoverable_connectors
.chain(discoverable_plugins)
.collect())
@@ -406,33 +430,168 @@ fn write_cached_accessible_connectors(
});
}
async fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
#[derive(Debug, Default, PartialEq, Eq)]
struct ToolSuggestConnectorSelection {
connector_ids: HashSet<String>,
template_ids: HashSet<String>,
disabled_connector_ids: HashSet<String>,
disabled_template_ids: HashSet<String>,
}
async fn tool_suggest_connector_selection(config: &Config) -> ToolSuggestConnectorSelection {
let plugins_input = config.plugins_config_input();
let mut connector_ids = PluginsManager::new(config.codex_home.to_path_buf())
let connector_ids = PluginsManager::new(config.codex_home.to_path_buf())
.plugins_for_config(&plugins_input)
.await
.capability_summaries()
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.collect::<HashSet<_>>();
connector_ids.extend(
config
.tool_suggest
.discoverables
.iter()
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Connector)
.map(|discoverable| discoverable.id.clone()),
);
.cloned()
.chain(
config
.tool_suggest
.discoverables
.iter()
.filter(|discoverable| discoverable.kind == ToolSuggestDiscoverableType::Connector)
.map(|discoverable| AppConnectorId(discoverable.id.clone())),
)
.collect::<Vec<_>>();
let disabled_connector_ids = config
.tool_suggest
.disabled_tools
.iter()
.filter(|disabled_tool| disabled_tool.kind == ToolSuggestDiscoverableType::Connector)
.map(|disabled_tool| disabled_tool.id.as_str())
.map(|disabled_tool| AppConnectorId(disabled_tool.id.clone()))
.collect::<Vec<_>>();
let mut selection = ToolSuggestConnectorSelection::default();
for connector_id in connector_ids {
selection.insert_connector_id(connector_id.0);
}
for connector_id in disabled_connector_ids {
selection.insert_disabled_connector_id(connector_id.0);
}
selection
.connector_ids
.retain(|connector_id| !selection.disabled_connector_ids.contains(connector_id));
selection
}
impl ToolSuggestConnectorSelection {
fn insert_connector_id(&mut self, connector_id: String) {
insert_connector_or_template_id(
connector_id,
&mut self.connector_ids,
&mut self.template_ids,
);
}
fn insert_disabled_connector_id(&mut self, connector_id: String) {
insert_connector_or_template_id(
connector_id,
&mut self.disabled_connector_ids,
&mut self.disabled_template_ids,
);
}
}
fn insert_connector_or_template_id(
connector_id: String,
connector_ids: &mut HashSet<String>,
template_ids: &mut HashSet<String>,
) {
let connector_id = connector_id.trim();
if connector_id.is_empty() {
return;
}
if is_template_app_id(connector_id) {
template_ids.insert(connector_id.to_string());
} else {
connector_ids.insert(connector_id.to_string());
}
}
fn is_template_app_id(connector_id: &str) -> bool {
connector_id.starts_with(TEMPLATE_APP_ID_PREFIX)
}
async fn workspace_template_directory_connectors(
config: &Config,
auth: Option<&CodexAuth>,
template_ids: &HashSet<String>,
disabled_template_ids: &HashSet<String>,
disabled_connector_ids: &HashSet<String>,
) -> Vec<AppInfo> {
if template_ids.is_empty() {
return Vec::new();
}
let active_template_ids = template_ids
.difference(disabled_template_ids)
.cloned()
.collect::<HashSet<_>>();
connector_ids.retain(|connector_id| !disabled_connector_ids.contains(connector_id.as_str()));
connector_ids
if active_template_ids.is_empty() {
return Vec::new();
}
let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else {
return Vec::new();
};
let client = build_reqwest_client();
let base_url = config.chatgpt_base_url.clone();
let auth_headers = codex_model_provider::auth_provider_from_auth(auth).to_auth_headers();
match codex_connectors::list_workspace_template_connectors(&active_template_ids, move |path| {
let client = client.clone();
let base_url = base_url.clone();
let auth_headers = auth_headers.clone();
async move {
let url = chatgpt_backend_path_url(&base_url, &path)?;
let response = client
.get(&url)
.timeout(Duration::from_secs(30))
.headers(auth_headers)
.send()
.await?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
anyhow::bail!("connector directory request failed with status {status}: {body}");
}
Ok(serde_json::from_str(&body)?)
}
})
.await
{
Ok(connectors) => connectors
.into_iter()
.filter(|connector| !disabled_connector_ids.contains(&connector.id))
.collect(),
Err(err) => {
warn!("failed to load workspace connector directory for template resolution: {err:#}");
Vec::new()
}
}
}
fn chatgpt_backend_path_url(base_url: &str, path: &str) -> anyhow::Result<String> {
let mut url = Url::parse(base_url.trim_end_matches('/'))?;
let (path, query) = path
.trim_start_matches('/')
.split_once('?')
.map_or((path.trim_start_matches('/'), None), |(path, query)| {
(path, Some(query))
});
{
let mut segments = url
.path_segments_mut()
.map_err(|()| anyhow::anyhow!("invalid ChatGPT base URL path"))?;
segments.pop_if_empty();
for segment in path.split('/').filter(|segment| !segment.is_empty()) {
segments.push(segment);
}
}
url.set_query(query);
Ok(url.to_string())
}
async fn cached_directory_connectors_for_tool_suggest_with_auth(

View File

@@ -29,6 +29,12 @@ use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
fn annotations(destructive_hint: Option<bool>, open_world_hint: Option<bool>) -> ToolAnnotations {
ToolAnnotations::from_raw(
@@ -1159,7 +1165,7 @@ fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() {
}
#[tokio::test]
async fn tool_suggest_connector_ids_include_configured_tool_suggest_discoverables() {
async fn tool_suggest_connector_selection_includes_configured_tool_suggest_discoverables() {
let codex_home = tempdir().expect("tempdir should succeed");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
@@ -1180,13 +1186,18 @@ discoverables = [
.expect("config should load");
assert_eq!(
tool_suggest_connector_ids(&config).await,
HashSet::from(["connector_2128aebfecb84f64a069897515042a44".to_string()])
tool_suggest_connector_selection(&config).await,
ToolSuggestConnectorSelection {
connector_ids: HashSet::from(
["connector_2128aebfecb84f64a069897515042a44".to_string()]
),
..ToolSuggestConnectorSelection::default()
}
);
}
#[tokio::test]
async fn tool_suggest_connector_ids_exclude_disabled_tool_suggestions() {
async fn tool_suggest_connector_selection_excludes_disabled_tool_suggestions() {
let codex_home = tempdir().expect("tempdir should succeed");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
@@ -1209,8 +1220,183 @@ disabled_tools = [
.expect("config should load");
assert_eq!(
tool_suggest_connector_ids(&config).await,
HashSet::from(["connector_gmail".to_string()])
tool_suggest_connector_selection(&config).await,
ToolSuggestConnectorSelection {
connector_ids: HashSet::from(["connector_gmail".to_string()]),
disabled_connector_ids: HashSet::from(["connector_calendar".to_string()]),
..ToolSuggestConnectorSelection::default()
}
);
}
#[tokio::test]
async fn tool_suggest_connector_selection_tracks_template_connectors_separately() {
let codex_home = tempdir().expect("tempdir should succeed");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[tool_suggest]
discoverables = [
{ type = "connector", id = "templated_apps_Databricks" }
]
"#,
)
.expect("write config");
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config should load");
assert_eq!(
tool_suggest_connector_selection(&config).await,
ToolSuggestConnectorSelection {
template_ids: HashSet::from(["templated_apps_Databricks".to_string()]),
..ToolSuggestConnectorSelection::default()
}
);
}
#[tokio::test]
async fn tool_suggest_resolves_template_connectors_before_returning_install_entries() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"apps":[
{
"id":"asdk_app_databricks_workspace",
"name":"Databricks Workspace",
"description":"Query Databricks",
"template_id":"templated_apps_Databricks"
},
{
"id":"asdk_app_other",
"name":"Other",
"template_id":"templated_apps_Other"
}
]}"#,
))
.mount(&server)
.await;
let codex_home = tempdir().expect("tempdir should succeed");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
apps = true
[tool_suggest]
discoverables = [
{ type = "connector", id = "templated_apps_Databricks" }
]
"#,
)
.expect("write config");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config should load");
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let discoverable_tools =
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[])
.await
.expect("discoverable tools should load");
assert_eq!(
discoverable_tools,
vec![DiscoverableTool::from(AppInfo {
id: "asdk_app_databricks_workspace".to_string(),
name: "Databricks Workspace".to_string(),
description: Some("Query Databricks".to_string()),
install_url: Some(connector_install_url(
"Databricks Workspace",
"asdk_app_databricks_workspace",
)),
..app("asdk_app_databricks_workspace")
})]
);
}
#[tokio::test]
async fn tool_suggest_returns_all_resolved_connectors_for_template() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"apps":[
{
"id":"asdk_app_databricks_a",
"name":"Databricks A",
"template_id":"templated_apps_Databricks"
},
{
"id":"asdk_app_databricks_b",
"name":"Databricks B",
"template_id":"templated_apps_Databricks"
}
]}"#,
))
.mount(&server)
.await;
let codex_home = tempdir().expect("tempdir should succeed");
std::fs::write(
codex_home.path().join(CONFIG_TOML_FILE),
r#"
[features]
apps = true
[tool_suggest]
discoverables = [
{ type = "connector", id = "templated_apps_Databricks" }
]
"#,
)
.expect("write config");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("config should load");
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let discoverable_tools =
list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[], &[])
.await
.expect("discoverable tools should load");
assert_eq!(
discoverable_tools,
vec![
DiscoverableTool::from(AppInfo {
id: "asdk_app_databricks_a".to_string(),
name: "Databricks A".to_string(),
install_url: Some(connector_install_url(
"Databricks A",
"asdk_app_databricks_a",
)),
..app("asdk_app_databricks_a")
}),
DiscoverableTool::from(AppInfo {
id: "asdk_app_databricks_b".to_string(),
name: "Databricks B".to_string(),
install_url: Some(connector_install_url(
"Databricks B",
"asdk_app_databricks_b",
)),
..app("asdk_app_databricks_b")
}),
]
);
}

View File

@@ -10,7 +10,10 @@ use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core_plugins::PluginsManager;
use codex_core_plugins::TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST as TOOL_SUGGEST_DISCOVERABLE_PLUGIN_FALLBACK_ALLOWLIST;
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
use codex_core_plugins::remote::RemotePluginServiceConfig;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_plugin::AppConnectorId;
use codex_tools::DiscoverablePluginInfo;
const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
@@ -18,9 +21,26 @@ const TOOL_SUGGEST_DISCOVERABLE_MARKETPLACE_ALLOWLIST: &[&str] = &[
OPENAI_CURATED_MARKETPLACE_NAME,
];
#[cfg(test)]
pub(crate) async fn list_tool_suggest_discoverable_plugins(
config: &Config,
auth: Option<&CodexAuth>,
loaded_plugin_app_connector_ids: &[String],
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
list_tool_suggest_discoverable_plugins_with_connector_candidates(
config,
auth,
loaded_plugin_app_connector_ids,
&[],
)
.await
}
pub(crate) async fn list_tool_suggest_discoverable_plugins_with_connector_candidates(
config: &Config,
auth: Option<&CodexAuth>,
loaded_plugin_app_connector_ids: &[String],
candidate_app_connector_ids: &[String],
) -> anyhow::Result<Vec<DiscoverablePluginInfo>> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
@@ -46,15 +66,38 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
.list_marketplaces_for_config(&plugins_input, &[])
.context("failed to list plugin marketplaces for tool suggestions")?
.marketplaces;
let mut installed_app_connector_ids = plugins_manager
let installed_app_connector_ids = plugins_manager
.plugins_for_config(&plugins_input)
.await
.capability_summaries()
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.collect::<HashSet<_>>();
installed_app_connector_ids.extend(loaded_plugin_app_connector_ids.iter().cloned());
.cloned()
.chain(
loaded_plugin_app_connector_ids
.iter()
.cloned()
.map(AppConnectorId),
)
.chain(
candidate_app_connector_ids
.iter()
.cloned()
.map(AppConnectorId),
)
.collect::<Vec<_>>();
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let installed_app_connector_ids = codex_core_plugins::remote::resolve_remote_plugin_app_ids(
&remote_plugin_service_config,
auth,
&installed_app_connector_ids,
)
.await
.into_iter()
.map(|connector_id| connector_id.0)
.collect::<HashSet<_>>();
let mut discoverable_plugins = Vec::<DiscoverablePluginInfo>::new();
for marketplace in marketplaces {
@@ -86,10 +129,16 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
{
Ok(plugin) => {
let plugin: PluginCapabilitySummary = plugin.into();
let matches_installed_app =
plugin.app_connector_ids.iter().any(|connector_id| {
installed_app_connector_ids.contains(connector_id.0.as_str())
});
let app_connector_ids =
codex_core_plugins::remote::resolve_remote_plugin_app_ids(
&remote_plugin_service_config,
auth,
&plugin.app_connector_ids,
)
.await;
let matches_installed_app = app_connector_ids.iter().any(|connector_id| {
installed_app_connector_ids.contains(connector_id.0.as_str())
});
if !is_configured_plugin && !is_fallback_plugin && !matches_installed_app {
continue;
}
@@ -100,8 +149,7 @@ pub(crate) async fn list_tool_suggest_discoverable_plugins(
description: plugin.description,
has_skills: plugin.has_skills,
mcp_server_names: plugin.mcp_server_names,
app_connector_ids: plugin
.app_connector_ids
app_connector_ids: app_connector_ids
.into_iter()
.map(|connector_id| connector_id.0)
.collect(),

View File

@@ -8,6 +8,7 @@ use crate::plugins::test_support::write_plugins_feature_config;
use codex_core_plugins::OPENAI_BUNDLED_MARKETPLACE_NAME;
use codex_core_plugins::PluginInstallRequest;
use codex_core_plugins::startup_sync::curated_plugins_repo_path;
use codex_login::CodexAuth;
use codex_tools::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -16,6 +17,12 @@ use tempfile::tempdir;
use tracing::Level;
use tracing_subscriber::fmt::format::FmtSpan;
use tracing_test::internal::MockWriter;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without_installed_apps() {
@@ -25,7 +32,7 @@ async fn list_tool_suggest_discoverable_plugins_returns_fallback_plugins_without
write_plugins_feature_config(codex_home.path());
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -51,7 +58,7 @@ async fn list_tool_suggest_discoverable_plugins_filters_non_fallback_by_installe
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "slack").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -77,7 +84,7 @@ async fn list_tool_suggest_discoverable_plugins_filters_by_loaded_plugin_apps()
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins =
list_tool_suggest_discoverable_plugins(&config, &[hubspot_app_id.to_string()])
list_tool_suggest_discoverable_plugins(&config, None, &[hubspot_app_id.to_string()])
.await
.unwrap();
@@ -90,6 +97,109 @@ async fn list_tool_suggest_discoverable_plugins_filters_by_loaded_plugin_apps()
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_matches_on_resolved_template_apps() {
let databricks_app_id = "asdk_app_databricks_workspace";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
r#"{{"apps":[{{"id":"{databricks_app_id}","template_id":"templated_apps_Databricks"}}]}}"#
)))
.mount(&server)
.await;
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["databricks-source"]);
write_plugin_app(
&curated_root,
"databricks-source",
"databricks",
"templated_apps_Databricks",
);
write_plugins_feature_config(codex_home.path());
let mut config = load_plugins_config(codex_home.path()).await;
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let discoverable_plugins = list_tool_suggest_discoverable_plugins(
&config,
Some(&auth),
&[databricks_app_id.to_string()],
)
.await
.unwrap();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "databricks-source@openai-curated".to_string(),
name: "databricks-source".to_string(),
description: Some(
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec![databricks_app_id.to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_matches_on_resolved_connector_candidate_apps() {
let databricks_app_id = "asdk_app_databricks_workspace";
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/connectors/directory/list_workspace"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(format!(
r#"{{"apps":[{{"id":"{databricks_app_id}","template_id":"templated_apps_Databricks"}}]}}"#
)))
.mount(&server)
.await;
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["databricks-source"]);
write_plugin_app(
&curated_root,
"databricks-source",
"databricks",
"templated_apps_Databricks",
);
write_plugins_feature_config(codex_home.path());
let mut config = load_plugins_config(codex_home.path()).await;
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
let discoverable_plugins = list_tool_suggest_discoverable_plugins_with_connector_candidates(
&config,
Some(&auth),
&[],
&[databricks_app_id.to_string()],
)
.await
.unwrap();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "databricks-source@openai-curated".to_string(),
name: "databricks-source".to_string(),
description: Some(
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec![databricks_app_id.to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_apps() {
let codex_home = tempdir().expect("tempdir should succeed");
@@ -102,7 +212,7 @@ async fn list_tool_suggest_discoverable_plugins_filters_microsoft_by_installed_a
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "teams").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -179,7 +289,7 @@ source = "/tmp/{sales_marketplace_name}"
install_marketplace_plugin(codex_home.path(), sales_marketplace_root.as_path(), "sales").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -234,7 +344,7 @@ discoverables = [{{ type = "plugin", id = "{plugin_id}" }}]
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -278,7 +388,7 @@ source = "/tmp/{marketplace_name}"
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -299,7 +409,7 @@ plugins = false
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -322,7 +432,7 @@ async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -359,7 +469,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins(
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config, None, &[])
.await
.unwrap();
@@ -384,7 +494,7 @@ disabled_tools = [
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -435,7 +545,7 @@ async fn list_tool_suggest_discoverable_plugins_omits_not_available_curated_plug
install_marketplace_plugin(codex_home.path(), curated_root.as_path(), "installed").await;
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -464,7 +574,7 @@ discoverables = [{ type = "plugin", id = "sample@openai-curated" }]
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();
@@ -519,7 +629,7 @@ async fn list_tool_suggest_discoverable_plugins_does_not_reload_marketplace_per_
.finish();
let _guard = tracing::subscriber::set_default(subscriber);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, &[])
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config, None, &[])
.await
.unwrap();

View File

@@ -7,7 +7,7 @@ pub(crate) mod test_support;
pub(crate) use codex_plugin::PluginCapabilitySummary;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins_with_connector_candidates;
pub(crate) use injection::build_plugin_injections;
pub(crate) use render::render_explicit_plugin_instructions;