[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:
charlesgong-openai
2026-06-11 10:34:41 -07:00
committed by GitHub
Unverified
parent 52db447c77
commit eb76336f60
16 changed files with 445 additions and 36 deletions
@@ -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",
@@ -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,
+63 -16
View File
@@ -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(),
}),
}
}
+10 -2
View File
@@ -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,
})
+4
View File
@@ -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(),