mirror of
https://github.com/openai/codex.git
synced 2026-06-02 11:22:01 +00:00
Compare commits
7 Commits
rust-v0.13
...
nm-codex/r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11d1e9cd19 | ||
|
|
c243abfc81 | ||
|
|
4cd3ab4a3d | ||
|
|
25d081d10b | ||
|
|
bcd8d541f5 | ||
|
|
10a8a4e84f | ||
|
|
24d5ccc19b |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
110
codex-rs/core-plugins/src/remote/tests.rs
Normal file
110
codex-rs/core-plugins/src/remote/tests.rs
Normal 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")]);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user