mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Propagate plugin app categories (#27420)
## What - Parse optional `.app.json` `category` overrides for plugin apps. - Add nullable `category` to `AppSummary` and `AppTemplateSummary` in the app-server protocol. - Fall back from `branding.category` to the first non-empty `app_metadata.categories` value when building app/template summaries. - Regenerate schema/type fixtures and update plugin read/install tests. ## Why The plugin details UI needs a normalized per-app category. Some apps only provide their default category in metadata, while others need a local `.app.json` override.
This commit is contained in:
committed by
GitHub
Unverified
parent
52db447c77
commit
eb76336f60
+12
@@ -6416,6 +6416,12 @@
|
||||
"AppSummary": {
|
||||
"description": "EXPERIMENTAL - app metadata summary for plugin responses.",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -6449,6 +6455,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
+12
@@ -694,6 +694,12 @@
|
||||
"AppSummary": {
|
||||
"description": "EXPERIMENTAL - app metadata summary for plugin responses.",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -727,6 +733,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -4,6 +4,12 @@
|
||||
"AppSummary": {
|
||||
"description": "EXPERIMENTAL - app metadata summary for plugin responses.",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
"AppSummary": {
|
||||
"description": "EXPERIMENTAL - app metadata summary for plugin responses.",
|
||||
"properties": {
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
@@ -41,6 +47,12 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"category": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"string",
|
||||
|
||||
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* EXPERIMENTAL - app metadata summary for plugin responses.
|
||||
*/
|
||||
export type AppSummary = { id: string, name: string, description: string | null, installUrl: string | null, };
|
||||
export type AppSummary = { id: string, name: string, description: string | null, installUrl: string | null, category: string | null, };
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AppTemplateUnavailableReason } from "./AppTemplateUnavailableReason";
|
||||
|
||||
export type AppTemplateSummary = { templateId: string, name: string, description: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array<string>, reason: AppTemplateUnavailableReason | null, };
|
||||
export type AppTemplateSummary = { templateId: string, name: string, description: string | null, category: string | null, canonicalConnectorId: string | null, logoUrl: string | null, logoUrlDark: string | null, materializedAppIds: Array<string>, reason: AppTemplateUnavailableReason | null, };
|
||||
|
||||
@@ -102,6 +102,33 @@ pub struct AppInfo {
|
||||
pub plugin_display_names: Vec<String>,
|
||||
}
|
||||
|
||||
impl AppInfo {
|
||||
pub fn category(&self) -> Option<String> {
|
||||
self.branding
|
||||
.as_ref()
|
||||
.and_then(|branding| non_empty_category(branding.category.as_deref()))
|
||||
.or_else(|| {
|
||||
self.app_metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.categories.as_ref())
|
||||
.and_then(|categories| {
|
||||
categories
|
||||
.iter()
|
||||
.find_map(|category| non_empty_category(Some(category.as_str())))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_category(category: Option<&str>) -> Option<String> {
|
||||
let category = category?.trim();
|
||||
if category.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(category.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -111,15 +138,18 @@ pub struct AppSummary {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub install_url: Option<String>,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
impl From<AppInfo> for AppSummary {
|
||||
fn from(value: AppInfo) -> Self {
|
||||
let category = value.category();
|
||||
Self {
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
description: value.description,
|
||||
install_url: value.install_url,
|
||||
category,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -661,6 +661,7 @@ pub struct AppTemplateSummary {
|
||||
pub template_id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub canonical_connector_id: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub logo_url_dark: Option<String>,
|
||||
|
||||
@@ -1035,7 +1035,12 @@ impl PluginRequestProcessor {
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let app_summaries = load_plugin_app_summaries(&config, &outcome.plugin.apps).await;
|
||||
let app_summaries = load_plugin_app_summaries(
|
||||
&config,
|
||||
&outcome.plugin.apps,
|
||||
&outcome.plugin.app_category_by_id,
|
||||
)
|
||||
.await;
|
||||
let visible_skills = outcome
|
||||
.plugin
|
||||
.skills
|
||||
@@ -1111,7 +1116,13 @@ impl PluginRequestProcessor {
|
||||
.cloned()
|
||||
.map(codex_plugin::AppConnectorId)
|
||||
.collect::<Vec<_>>();
|
||||
let app_summaries = load_plugin_app_summaries(&config, &plugin_apps).await;
|
||||
let app_category_by_id = remote_detail
|
||||
.app_manifest
|
||||
.as_ref()
|
||||
.map(plugin_app_category_by_id_from_value)
|
||||
.unwrap_or_default();
|
||||
let app_summaries =
|
||||
load_plugin_app_summaries(&config, &plugin_apps, &app_category_by_id).await;
|
||||
remote_plugin_detail_to_info(remote_detail, app_summaries)
|
||||
}
|
||||
};
|
||||
@@ -1558,16 +1569,28 @@ impl PluginRequestProcessor {
|
||||
.into_iter()
|
||||
.map(codex_plugin::AppConnectorId)
|
||||
.collect::<Vec<_>>();
|
||||
let app_category_by_id = remote_detail
|
||||
.app_manifest
|
||||
.as_ref()
|
||||
.map(plugin_app_category_by_id_from_value)
|
||||
.unwrap_or_default();
|
||||
let all_connectors = connectors::list_cached_all_connectors(&config)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
connectors::connectors_for_plugin_apps(all_connectors, &plugin_apps)
|
||||
.into_iter()
|
||||
.map(|connector| AppSummary {
|
||||
id: connector.id,
|
||||
name: connector.name,
|
||||
description: connector.description,
|
||||
install_url: connector.install_url,
|
||||
.map(|connector| {
|
||||
let category = app_category_by_id
|
||||
.get(&connector.id)
|
||||
.cloned()
|
||||
.or_else(|| connector.category());
|
||||
AppSummary {
|
||||
category,
|
||||
id: connector.id,
|
||||
name: connector.name,
|
||||
description: connector.description,
|
||||
install_url: connector.install_url,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1872,6 +1895,7 @@ impl PluginRequestProcessor {
|
||||
async fn load_plugin_app_summaries(
|
||||
config: &Config,
|
||||
plugin_apps: &[codex_plugin::AppConnectorId],
|
||||
app_category_by_id: &HashMap<String, String>,
|
||||
) -> Vec<AppSummary> {
|
||||
if plugin_apps.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -1889,9 +1913,29 @@ async fn load_plugin_app_summaries(
|
||||
};
|
||||
|
||||
let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps);
|
||||
|
||||
plugin_connectors
|
||||
.into_iter()
|
||||
.map(AppSummary::from)
|
||||
.map(|connector| {
|
||||
let category = app_category_by_id
|
||||
.get(&connector.id)
|
||||
.cloned()
|
||||
.or_else(|| connector.category());
|
||||
AppSummary {
|
||||
id: connector.id,
|
||||
name: connector.name,
|
||||
description: connector.description,
|
||||
install_url: connector.install_url,
|
||||
category,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn plugin_app_category_by_id_from_value(value: &serde_json::Value) -> HashMap<String, String> {
|
||||
codex_core_plugins::loader::plugin_app_metadata_from_value(value)
|
||||
.into_iter()
|
||||
.filter_map(|app| app.category.map(|category| (app.id.0, category)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1921,11 +1965,15 @@ fn plugin_apps_needing_auth(
|
||||
&& !accessible_ids.contains(connector.id.as_str())
|
||||
})
|
||||
.cloned()
|
||||
.map(|connector| AppSummary {
|
||||
id: connector.id,
|
||||
name: connector.name,
|
||||
description: connector.description,
|
||||
install_url: connector.install_url,
|
||||
.map(|connector| {
|
||||
let category = connector.category();
|
||||
AppSummary {
|
||||
category,
|
||||
id: connector.id,
|
||||
name: connector.name,
|
||||
description: connector.description,
|
||||
install_url: connector.install_url,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -2013,6 +2061,7 @@ fn remote_plugin_detail_to_info(
|
||||
template_id: template.template_id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
canonical_connector_id: template.canonical_connector_id,
|
||||
logo_url: template.logo_url,
|
||||
logo_url_dark: template.logo_url_dark,
|
||||
|
||||
@@ -292,6 +292,14 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
|
||||
async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
let remote_app_manifest = json!({
|
||||
"apps": {
|
||||
"alpha": {
|
||||
"id": "alpha",
|
||||
"category": "Developer Tools"
|
||||
}
|
||||
}
|
||||
});
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
/*status_code*/ 200,
|
||||
@@ -299,7 +307,14 @@ async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> {
|
||||
)
|
||||
.await;
|
||||
configure_remote_plugin_with_apps_test(codex_home.path(), &server)?;
|
||||
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
|
||||
mount_remote_plugin_detail_with_app_manifest(
|
||||
&server,
|
||||
REMOTE_PLUGIN_ID,
|
||||
"1.2.3",
|
||||
Some(&bundle_url),
|
||||
remote_app_manifest,
|
||||
)
|
||||
.await;
|
||||
mount_empty_remote_installed_plugins(&server).await;
|
||||
mount_remote_plugin_install_with_apps_needing_auth(&server, REMOTE_PLUGIN_ID, &["alpha"]).await;
|
||||
|
||||
@@ -327,6 +342,7 @@ async fn plugin_install_uses_remote_apps_needing_auth_response() -> Result<()> {
|
||||
name: "alpha".to_string(),
|
||||
description: None,
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
category: Some("Developer Tools".to_string()),
|
||||
}],
|
||||
}
|
||||
);
|
||||
@@ -1000,6 +1016,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
category: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
@@ -1087,6 +1104,7 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
|
||||
category: None,
|
||||
}],
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
@@ -5,6 +7,16 @@ use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::TestAppServer;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use axum::Json;
|
||||
use axum::Router;
|
||||
use axum::extract::State;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::Uri;
|
||||
use axum::http::header::AUTHORIZATION;
|
||||
use axum::routing::get;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppMetadata;
|
||||
use codex_app_server_protocol::AppTemplateSummary;
|
||||
use codex_app_server_protocol::AppTemplateUnavailableReason;
|
||||
use codex_app_server_protocol::HookEventName;
|
||||
@@ -27,6 +39,8 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
@@ -409,6 +423,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
"template_id": "templated_apps_GitHubEnterprise",
|
||||
"name": "GitHub Enterprise",
|
||||
"description": "Connect GitHub Enterprise",
|
||||
"category": "Developer Tools",
|
||||
"canonical_connector_id": "github_enterprise",
|
||||
"logo_url": "https://example.com/ghe-light.png",
|
||||
"logo_url_dark": "https://example.com/ghe-dark.png",
|
||||
@@ -570,6 +585,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
template_id: "templated_apps_GitHubEnterprise".to_string(),
|
||||
name: "GitHub Enterprise".to_string(),
|
||||
description: Some("Connect GitHub Enterprise".to_string()),
|
||||
category: Some("Developer Tools".to_string()),
|
||||
canonical_connector_id: Some("github_enterprise".to_string()),
|
||||
logo_url: Some("https://example.com/ghe-light.png".to_string()),
|
||||
logo_url_dark: Some("https://example.com/ghe-dark.png".to_string()),
|
||||
@@ -580,6 +596,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
template_id: "templated_apps_Databricks".to_string(),
|
||||
name: "Databricks".to_string(),
|
||||
description: None,
|
||||
category: None,
|
||||
canonical_connector_id: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
@@ -1288,7 +1305,8 @@ description: Visible only for ChatGPT
|
||||
r#"{
|
||||
"apps": {
|
||||
"gmail": {
|
||||
"id": "gmail"
|
||||
"id": "gmail",
|
||||
"category": "Communication"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
@@ -1458,11 +1476,119 @@ enabled = false
|
||||
response.plugin.apps[0].install_url.as_deref(),
|
||||
Some("https://chatgpt.com/apps/gmail/gmail")
|
||||
);
|
||||
assert_eq!(
|
||||
response.plugin.apps[0].category.as_deref(),
|
||||
Some("Communication")
|
||||
);
|
||||
assert_eq!(response.plugin.mcp_servers.len(), 1);
|
||||
assert_eq!(response.plugin.mcp_servers[0], "demo");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_returns_app_metadata_category() -> Result<()> {
|
||||
let connectors = vec![
|
||||
AppInfo {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
description: Some("Alpha connector".to_string()),
|
||||
logo_url: Some("https://example.com/alpha.png".to_string()),
|
||||
logo_url_dark: None,
|
||||
distribution_channel: Some("featured".to_string()),
|
||||
branding: None,
|
||||
app_metadata: Some(AppMetadata {
|
||||
review: None,
|
||||
categories: Some(vec!["Productivity".to_string()]),
|
||||
sub_categories: None,
|
||||
seo_description: None,
|
||||
screenshots: None,
|
||||
developer: None,
|
||||
version: None,
|
||||
version_id: None,
|
||||
version_notes: None,
|
||||
first_party_type: None,
|
||||
first_party_requires_install: None,
|
||||
show_in_composer_when_unlinked: None,
|
||||
}),
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "beta".to_string(),
|
||||
name: "Beta".to_string(),
|
||||
description: Some("Beta 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(connectors).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",
|
||||
)?;
|
||||
write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
|
||||
let mut mcp = TestAppServer::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
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: PluginReadResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(
|
||||
response
|
||||
.plugin
|
||||
.apps
|
||||
.iter()
|
||||
.map(|app| (app.id.as_str(), app.category.as_deref()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("alpha", Some("Productivity")), ("beta", None)]
|
||||
);
|
||||
|
||||
server_handle.abort();
|
||||
let _ = server_handle.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1728,6 +1854,82 @@ plugins = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppsServerState {
|
||||
response: Arc<StdMutex<serde_json::Value>>,
|
||||
}
|
||||
|
||||
async fn start_apps_server(connectors: Vec<AppInfo>) -> Result<(String, JoinHandle<()>)> {
|
||||
let state = Arc::new(AppsServerState {
|
||||
response: Arc::new(StdMutex::new(
|
||||
json!({ "apps": connectors, "next_token": null }),
|
||||
)),
|
||||
});
|
||||
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let addr = listener.local_addr()?;
|
||||
let router = Router::new()
|
||||
.route("/connectors/directory/list", get(list_directory_connectors))
|
||||
.route(
|
||||
"/connectors/directory/list_workspace",
|
||||
get(list_directory_connectors),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
|
||||
Ok((format!("http://{addr}"), handle))
|
||||
}
|
||||
|
||||
async fn list_directory_connectors(
|
||||
State(state): State<Arc<AppsServerState>>,
|
||||
headers: HeaderMap,
|
||||
uri: Uri,
|
||||
) -> 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");
|
||||
let external_logos_ok = uri
|
||||
.query()
|
||||
.is_some_and(|query| query.split('&').any(|pair| pair == "external_logos=true"));
|
||||
|
||||
if !bearer_ok || !account_ok {
|
||||
Err(StatusCode::UNAUTHORIZED)
|
||||
} else if !external_logos_ok {
|
||||
Err(StatusCode::BAD_REQUEST)
|
||||
} else {
|
||||
let response = state
|
||||
.response
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.clone();
|
||||
Ok(Json(response))
|
||||
}
|
||||
}
|
||||
|
||||
fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
chatgpt_base_url = "{base_url}"
|
||||
mcp_oauth_credentials_store = "file"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
connectors = true
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_remote_plugin_catalog_config(
|
||||
codex_home: &std::path::Path,
|
||||
base_url: &str,
|
||||
|
||||
@@ -61,6 +61,12 @@ pub struct PluginHookLoadOutcome {
|
||||
pub hook_load_warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginAppMetadata {
|
||||
pub id: AppConnectorId,
|
||||
pub category: Option<String>,
|
||||
}
|
||||
|
||||
enum PluginLoadScope<'a> {
|
||||
AllCapabilities {
|
||||
restriction_product: Option<Product>,
|
||||
@@ -123,6 +129,7 @@ struct PluginAppFile {
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct PluginAppConfig {
|
||||
id: String,
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn load_plugins_from_layer_stack(
|
||||
@@ -848,6 +855,14 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
|
||||
}
|
||||
|
||||
pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppConnectorId> {
|
||||
load_plugin_app_metadata(plugin_root)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|app| app.id)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn load_plugin_app_metadata(plugin_root: &Path) -> Vec<PluginAppMetadata> {
|
||||
if let Some(manifest) = load_plugin_manifest(plugin_root) {
|
||||
return load_apps_from_paths(
|
||||
plugin_root,
|
||||
@@ -858,6 +873,16 @@ pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppConnectorId> {
|
||||
load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await
|
||||
}
|
||||
|
||||
pub fn plugin_app_metadata_from_value(value: &JsonValue) -> Vec<PluginAppMetadata> {
|
||||
let Ok(parsed) = serde_json::from_value::<PluginAppFile>(value.clone()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut apps = plugin_app_metadata_from_file(parsed, /*plugin_root*/ None);
|
||||
let mut seen_connector_ids = HashSet::new();
|
||||
apps.retain(|app| seen_connector_ids.insert(app.id.0.clone()));
|
||||
apps
|
||||
}
|
||||
|
||||
fn plugin_app_config_paths(
|
||||
plugin_root: &Path,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
@@ -994,8 +1019,8 @@ fn append_plugin_hook_file(
|
||||
async fn load_apps_from_paths(
|
||||
plugin_root: &Path,
|
||||
app_config_paths: Vec<AbsolutePathBuf>,
|
||||
) -> Vec<AppConnectorId> {
|
||||
let mut connector_ids = Vec::new();
|
||||
) -> Vec<PluginAppMetadata> {
|
||||
let mut apps = Vec::new();
|
||||
for app_config_path in app_config_paths {
|
||||
let Ok(contents) = tokio::fs::read_to_string(app_config_path.as_path()).await else {
|
||||
continue;
|
||||
@@ -1011,21 +1036,40 @@ async fn load_apps_from_paths(
|
||||
}
|
||||
};
|
||||
|
||||
connector_ids.extend(parsed.apps.into_values().filter_map(|app| {
|
||||
if app.id.trim().is_empty() {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
"plugin app config is missing an app id"
|
||||
);
|
||||
None
|
||||
} else {
|
||||
Some(AppConnectorId(app.id))
|
||||
}
|
||||
}));
|
||||
apps.extend(plugin_app_metadata_from_file(parsed, Some(plugin_root)));
|
||||
}
|
||||
let mut seen_connector_ids = HashSet::new();
|
||||
connector_ids.retain(|connector_id| seen_connector_ids.insert(connector_id.0.clone()));
|
||||
connector_ids
|
||||
apps.retain(|app| seen_connector_ids.insert(app.id.0.clone()));
|
||||
apps
|
||||
}
|
||||
|
||||
fn plugin_app_metadata_from_file(
|
||||
parsed: PluginAppFile,
|
||||
plugin_root: Option<&Path>,
|
||||
) -> Vec<PluginAppMetadata> {
|
||||
parsed
|
||||
.apps
|
||||
.into_values()
|
||||
.filter_map(|app| {
|
||||
if app.id.trim().is_empty() {
|
||||
if let Some(plugin_root) = plugin_root {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
"plugin app config is missing an app id"
|
||||
);
|
||||
}
|
||||
None
|
||||
} else {
|
||||
Some(PluginAppMetadata {
|
||||
id: AppConnectorId(app.id),
|
||||
category: app
|
||||
.category
|
||||
.map(|category| category.trim().to_string())
|
||||
.filter(|category| !category.is_empty()),
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn plugin_telemetry_metadata_from_root(
|
||||
@@ -1063,7 +1107,10 @@ pub async fn plugin_telemetry_metadata_from_root(
|
||||
plugin_root.as_path(),
|
||||
plugin_app_config_paths(plugin_root.as_path(), manifest_paths),
|
||||
)
|
||||
.await,
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|app| app.id)
|
||||
.collect(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::loader::PluginHookLoadOutcome;
|
||||
use crate::loader::configured_curated_plugin_ids_from_codex_home;
|
||||
use crate::loader::curated_plugin_cache_version;
|
||||
use crate::loader::installed_plugin_telemetry_metadata;
|
||||
use crate::loader::load_plugin_apps;
|
||||
use crate::loader::load_plugin_app_metadata;
|
||||
use crate::loader::load_plugin_hooks;
|
||||
use crate::loader::load_plugin_hooks_from_layer_stack;
|
||||
use crate::loader::load_plugin_mcp_servers;
|
||||
@@ -254,6 +254,7 @@ pub struct PluginDetail {
|
||||
pub disabled_skill_paths: HashSet<AbsolutePathBuf>,
|
||||
pub hooks: Vec<PluginHookSummary>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub app_category_by_id: HashMap<String, String>,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
pub details_unavailable_reason: Option<PluginDetailsUnavailableReason>,
|
||||
}
|
||||
@@ -1224,6 +1225,7 @@ impl PluginsManager {
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
hooks: Vec::new(),
|
||||
apps: Vec::new(),
|
||||
app_category_by_id: HashMap::new(),
|
||||
mcp_server_names: Vec::new(),
|
||||
details_unavailable_reason: Some(
|
||||
PluginDetailsUnavailableReason::InstallRequiredForRemoteSource,
|
||||
@@ -1290,7 +1292,12 @@ impl PluginsManager {
|
||||
event_name: hook.event_name,
|
||||
})
|
||||
.collect();
|
||||
let apps = load_plugin_apps(source_path.as_path()).await;
|
||||
let app_metadata = load_plugin_app_metadata(source_path.as_path()).await;
|
||||
let apps = app_metadata.iter().map(|app| app.id.clone()).collect();
|
||||
let app_category_by_id = app_metadata
|
||||
.into_iter()
|
||||
.filter_map(|app| app.category.map(|category| (app.id.0, category)))
|
||||
.collect();
|
||||
let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path())
|
||||
.await
|
||||
.into_keys()
|
||||
@@ -1313,6 +1320,7 @@ impl PluginsManager {
|
||||
disabled_skill_paths: resolved_skills.disabled_skill_paths,
|
||||
hooks,
|
||||
apps,
|
||||
app_category_by_id,
|
||||
mcp_server_names,
|
||||
details_unavailable_reason: None,
|
||||
})
|
||||
|
||||
@@ -178,6 +178,7 @@ pub struct RemoteAppTemplate {
|
||||
pub template_id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub canonical_connector_id: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub logo_url_dark: Option<String>,
|
||||
@@ -458,6 +459,8 @@ struct RemoteAppTemplateResponse {
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
#[serde(default)]
|
||||
canonical_connector_id: Option<String>,
|
||||
#[serde(default)]
|
||||
logo_url: Option<String>,
|
||||
@@ -1059,6 +1062,7 @@ async fn build_remote_plugin_detail(
|
||||
template_id: template.template_id,
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
category: template.category,
|
||||
canonical_connector_id: template.canonical_connector_id,
|
||||
logo_url: template.logo_url,
|
||||
logo_url_dark: template.logo_url_dark,
|
||||
|
||||
@@ -307,10 +307,17 @@ def validate_app_manifest(path: Path, errors: list[str]) -> None:
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"`.app.json` app `{key}` must be an object")
|
||||
continue
|
||||
reject_companion_unknown_fields(value, {"id"}, f"`.app.json` app `{key}`", errors)
|
||||
reject_companion_unknown_fields(
|
||||
value, {"id", "category"}, f"`.app.json` app `{key}`", errors
|
||||
)
|
||||
app_id = value.get("id")
|
||||
if not isinstance(app_id, str) or not app_id.strip():
|
||||
errors.append(f"`.app.json` app `{key}` field `id` must be a non-empty string")
|
||||
category = value.get("category")
|
||||
if category is not None and (not isinstance(category, str) or not category.strip()):
|
||||
errors.append(
|
||||
f"`.app.json` app `{key}` field `category` must be a non-empty string"
|
||||
)
|
||||
|
||||
|
||||
def validate_mcp_manifest(path: Path, errors: list[str]) -> None:
|
||||
|
||||
@@ -1455,6 +1455,7 @@ pub(super) fn plugins_test_detail(
|
||||
name: (*name).to_string(),
|
||||
description: Some(format!("{name} app")),
|
||||
install_url: Some(format!("https://example.test/{name}")),
|
||||
category: None,
|
||||
})
|
||||
.collect(),
|
||||
app_templates: Vec::new(),
|
||||
|
||||
Reference in New Issue
Block a user