[plugins] Add dark-mode logo metadata (#29488)

Adds additive dark-mode plugin logo metadata across manifests, remote
catalogs, and the app-server protocol while keeping uninstalled Git
listings free of synthetic local paths.

Supersedes #28945. This replacement uses an upstream branch so trusted
CI can use the repository-provided remote Bazel configuration.

## Current state

Plugin interfaces expose only the default logo asset. Clients therefore
cannot select a dedicated dark-mode logo even when a plugin provides
one.

## What this PR changes

- Adds nullable `logoDark` and `logoUrlDark` fields to
`PluginInterface`.
- Resolves local `interface.logoDark` assets and maps remote
`logo_url_dark` values.
- Removes path-backed interface assets, including `logoDark`, from
uninstalled Git fallback listings until the plugin has a real local
root.
- Updates the bundled plugin validator and manifest reference.
- Regenerates the app-server JSON schemas and TypeScript types.

Local manifests expose `interface.logoDark` as a package-relative asset
path. Remote catalog responses expose `logo_url_dark`. These values map
into separate app-server fields so clients can preserve local-path and
remote-URL handling.

## Risk

The fields are additive and nullable, so existing clients retain their
current logo behavior. The main risks are an incomplete mapping path or
exposing a synthetic local path for an uninstalled Git plugin.
Local-manifest, remote-catalog, fallback-listing, protocol
serialization, and app-server integration tests cover those paths.

Spiciness: 2/5

## Testing

- `just write-app-server-schema`
- `just fmt`
- Regression test first failed with `logo_dark` resolved to
`/assets/logo-dark.png`, then passed after the fallback-listing fix.
- `just test -p codex-core-plugins` (267 tests passed)
- `just test -p codex-app-server 'suite::v2::plugin'` (114 tests passed)
- `just test -p codex-app-server-protocol -p codex-core-plugins -p
codex-plugin -p codex-skills` (517 tests passed before the follow-up)
- `just test -p codex-tui plugin` (47 tests passed)
- Validated a local plugin manifest containing `interface.logoDark` with
the bundled validator.

## Manual verification

Create a local plugin with both `interface.logo` and
`interface.logoDark`, then call `plugin/list` or `plugin/read`. Confirm
the response contains separate `logo` and `logoDark` paths. For a remote
catalog entry, confirm `logoUrlDark` is populated from `logo_url_dark`.
For an uninstalled Git marketplace entry, confirm path-backed interface
assets remain absent until installation.

Issue: N/A - coordinated maintainer change.
This commit is contained in:
Drew
2026-06-22 16:01:27 -07:00
committed by GitHub
parent 7ce84e64d8
commit 2c351cb864
24 changed files with 281 additions and 3 deletions
@@ -13407,6 +13407,17 @@
],
"description": "Local logo path, resolved from the installed plugin package."
},
"logoDark": {
"anyOf": [
{
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Local dark-mode logo path, resolved from the installed plugin package."
},
"logoUrl": {
"description": "Remote logo URL from the plugin catalog.",
"type": [
@@ -13414,6 +13425,13 @@
"null"
]
},
"logoUrlDark": {
"description": "Remote dark-mode logo URL from the plugin catalog.",
"type": [
"string",
"null"
]
},
"longDescription": {
"type": [
"string",
@@ -9811,6 +9811,17 @@
],
"description": "Local logo path, resolved from the installed plugin package."
},
"logoDark": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Local dark-mode logo path, resolved from the installed plugin package."
},
"logoUrl": {
"description": "Remote logo URL from the plugin catalog.",
"type": [
@@ -9818,6 +9829,13 @@
"null"
]
},
"logoUrlDark": {
"description": "Remote dark-mode logo URL from the plugin catalog.",
"type": [
"string",
"null"
]
},
"longDescription": {
"type": [
"string",
@@ -134,6 +134,17 @@
],
"description": "Local logo path, resolved from the installed plugin package."
},
"logoDark": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Local dark-mode logo path, resolved from the installed plugin package."
},
"logoUrl": {
"description": "Remote logo URL from the plugin catalog.",
"type": [
@@ -141,6 +152,13 @@
"null"
]
},
"logoUrlDark": {
"description": "Remote dark-mode logo URL from the plugin catalog.",
"type": [
"string",
"null"
]
},
"longDescription": {
"type": [
"string",
@@ -134,6 +134,17 @@
],
"description": "Local logo path, resolved from the installed plugin package."
},
"logoDark": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Local dark-mode logo path, resolved from the installed plugin package."
},
"logoUrl": {
"description": "Remote logo URL from the plugin catalog.",
"type": [
@@ -141,6 +152,13 @@
"null"
]
},
"logoUrlDark": {
"description": "Remote dark-mode logo URL from the plugin catalog.",
"type": [
"string",
"null"
]
},
"longDescription": {
"type": [
"string",
@@ -313,6 +313,17 @@
],
"description": "Local logo path, resolved from the installed plugin package."
},
"logoDark": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Local dark-mode logo path, resolved from the installed plugin package."
},
"logoUrl": {
"description": "Remote logo URL from the plugin catalog.",
"type": [
@@ -320,6 +331,13 @@
"null"
]
},
"logoUrlDark": {
"description": "Remote dark-mode logo URL from the plugin catalog.",
"type": [
"string",
"null"
]
},
"longDescription": {
"type": [
"string",
@@ -108,6 +108,17 @@
],
"description": "Local logo path, resolved from the installed plugin package."
},
"logoDark": {
"anyOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
},
{
"type": "null"
}
],
"description": "Local dark-mode logo path, resolved from the installed plugin package."
},
"logoUrl": {
"description": "Remote logo URL from the plugin catalog.",
"type": [
@@ -115,6 +126,13 @@
"null"
]
},
"logoUrlDark": {
"description": "Remote dark-mode logo URL from the plugin catalog.",
"type": [
"string",
"null"
]
},
"longDescription": {
"type": [
"string",
@@ -21,10 +21,18 @@ composerIconUrl: string | null,
* Local logo path, resolved from the installed plugin package.
*/
logo: AbsolutePathBuf | null,
/**
* Local dark-mode logo path, resolved from the installed plugin package.
*/
logoDark: AbsolutePathBuf | null,
/**
* Remote logo URL from the plugin catalog.
*/
logoUrl: string | null,
/**
* Remote dark-mode logo URL from the plugin catalog.
*/
logoUrlDark: string | null,
/**
* Local screenshot paths, resolved from the installed plugin package.
*/
@@ -716,8 +716,12 @@ pub struct PluginInterface {
pub composer_icon_url: Option<String>,
/// Local logo path, resolved from the installed plugin package.
pub logo: Option<AbsolutePathBuf>,
/// Local dark-mode logo path, resolved from the installed plugin package.
pub logo_dark: Option<AbsolutePathBuf>,
/// Remote logo URL from the plugin catalog.
pub logo_url: Option<String>,
/// Remote dark-mode logo URL from the plugin catalog.
pub logo_url_dark: Option<String>,
/// Local screenshot paths, resolved from the installed plugin package.
pub screenshots: Vec<AbsolutePathBuf>,
/// Remote screenshot URLs from the plugin catalog.
@@ -2938,6 +2938,13 @@ fn plugin_interface_serializes_local_paths_and_remote_urls_separately() {
};
let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap();
let composer_icon_json = composer_icon.as_path().display().to_string();
let logo_dark = if cfg!(windows) {
r"C:\plugins\linear\logo-dark.png"
} else {
"/plugins/linear/logo-dark.png"
};
let logo_dark = AbsolutePathBuf::try_from(PathBuf::from(logo_dark)).unwrap();
let logo_dark_json = logo_dark.as_path().display().to_string();
let interface = PluginInterface {
display_name: Some("Linear".to_string()),
@@ -2954,7 +2961,9 @@ fn plugin_interface_serializes_local_paths_and_remote_urls_separately() {
composer_icon: Some(composer_icon),
composer_icon_url: Some("https://example.com/linear/icon.png".to_string()),
logo: None,
logo_dark: Some(logo_dark),
logo_url: Some("https://example.com/linear/logo.png".to_string()),
logo_url_dark: Some("https://example.com/linear/logo-dark.png".to_string()),
screenshots: Vec::new(),
screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()],
};
@@ -2976,7 +2985,9 @@ fn plugin_interface_serializes_local_paths_and_remote_urls_separately() {
"composerIcon": composer_icon_json,
"composerIconUrl": "https://example.com/linear/icon.png",
"logo": null,
"logoDark": logo_dark_json,
"logoUrl": "https://example.com/linear/logo.png",
"logoUrlDark": "https://example.com/linear/logo-dark.png",
"screenshots": [],
"screenshotUrls": ["https://example.com/linear/screenshot.png"],
}),
@@ -77,7 +77,9 @@ fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginI
composer_icon: interface.composer_icon,
composer_icon_url: None,
logo: interface.logo,
logo_dark: interface.logo_dark,
logo_url: None,
logo_url_dark: None,
screenshots: interface.screenshots,
screenshot_urls: Vec::new(),
}
@@ -753,7 +753,9 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
composer_icon: None,
composer_icon_url: None,
logo: None,
logo_dark: None,
logo_url: None,
logo_url_dark: None,
screenshots: Vec::new(),
screenshot_urls: Vec::new(),
}),
@@ -162,7 +162,8 @@ apps = true
"short_description": "Example plugin",
"capabilities": [],
"default_prompt": "Use the legacy example prompt",
"default_prompts": []
"default_prompts": [],
"logo_url_dark": "https://example.com/example-plugin-dark.png"
},
"skills": [],
"mcp_servers": [
@@ -273,6 +274,15 @@ apps = true
.and_then(|interface| interface.default_prompt.clone()),
Some(vec!["Use the legacy example prompt".to_string()])
);
assert_eq!(
response
.plugin
.summary
.interface
.as_ref()
.and_then(|interface| interface.logo_url_dark.as_deref()),
Some("https://example.com/example-plugin-dark.png")
);
assert_eq!(
response.plugin.mcp_servers,
vec!["other-server".to_string()]
@@ -1330,6 +1340,7 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()>
"brandColor": "#3B82F6",
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
"logoDark": "./assets/logo-dark.png",
"screenshots": ["./assets/screenshot1.png"]
}
}"##,
@@ -1508,6 +1519,18 @@ enabled = false
"Find my next action".to_string()
])
);
assert_eq!(
response
.plugin
.summary
.interface
.as_ref()
.and_then(|interface| interface.logo_dark.as_ref()),
Some(
&AbsolutePathBuf::try_from(plugin_root.join("assets/logo-dark.png"))
.expect("absolute dark logo path")
)
);
assert_eq!(
response.plugin.summary.keywords,
vec!["api-key".to_string(), "developer tools".to_string()]
@@ -1357,7 +1357,9 @@ fn expected_plugin_interface() -> PluginInterface {
composer_icon: None,
composer_icon_url: None,
logo: None,
logo_dark: None,
logo_url: None,
logo_url_dark: None,
screenshots: Vec::new(),
screenshot_urls: Vec::new(),
}
@@ -1032,7 +1032,9 @@ async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metad
composer_icon: None,
composer_icon_url: None,
logo: None,
logo_dark: None,
logo_url: None,
logo_url_dark: None,
screenshots: Vec::new(),
screenshot_urls: Vec::new(),
});
+35
View File
@@ -74,6 +74,8 @@ struct RawPluginManifestInterface {
#[serde(default)]
logo: Option<String>,
#[serde(default)]
logo_dark: Option<String>,
#[serde(default)]
screenshots: Vec<String>,
}
@@ -175,6 +177,7 @@ pub(crate) fn parse_plugin_manifest(
brand_color,
composer_icon,
logo,
logo_dark,
screenshots,
} = interface;
@@ -196,6 +199,11 @@ pub(crate) fn parse_plugin_manifest(
composer_icon.as_deref(),
),
logo: resolve_interface_asset_path(plugin_root, "interface.logo", logo.as_deref()),
logo_dark: resolve_interface_asset_path(
plugin_root,
"interface.logoDark",
logo_dark.as_deref(),
),
screenshots: screenshots
.iter()
.filter_map(|screenshot| {
@@ -221,6 +229,7 @@ pub(crate) fn parse_plugin_manifest(
|| interface.brand_color.is_some()
|| interface.composer_icon.is_some()
|| interface.logo.is_some()
|| interface.logo_dark.is_some()
|| !interface.screenshots.is_empty();
has_fields.then_some(interface)
@@ -594,6 +603,32 @@ mod tests {
assert_eq!(interface.default_prompt, None);
}
#[test]
fn plugin_interface_reads_dark_logo_path() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
&plugin_root,
/*version*/ None,
r#"{
"logoDark": "./assets/logo-dark.svg"
}"#,
);
let manifest = load_manifest(&plugin_root);
let interface = manifest.interface.expect("plugin interface");
assert_eq!(
interface.logo_dark,
Some(
AbsolutePathBuf::from_absolute_path_checked(
plugin_root.join("assets/logo-dark.svg"),
)
.expect("absolute dark logo path")
)
);
}
#[test]
fn plugin_manifest_reads_trimmed_version() {
let tmp = tempdir().expect("tempdir");
+1
View File
@@ -103,6 +103,7 @@ impl MarketplacePluginManifestFallback {
if let Some(interface) = manifest.interface.as_mut() {
interface.composer_icon = None;
interface.logo = None;
interface.logo_dark = None;
interface.screenshots.clear();
}
Some(manifest)
@@ -180,6 +180,51 @@ fn find_marketplace_plugin_supports_git_subdir_sources() {
);
}
#[test]
fn find_marketplace_plugin_omits_interface_asset_paths_for_git_sources() {
let tmp = tempdir().unwrap();
let repo_root = tmp.path().join("repo");
fs::create_dir_all(repo_root.join(".git")).unwrap();
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
fs::write(
repo_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "codex-curated",
"plugins": [
{
"name": "remote-plugin",
"source": {
"source": "git-subdir",
"url": "openai/joey_marketplace3",
"path": "plugins/toolkit"
},
"interface": {
"displayName": "Remote Plugin",
"composerIcon": "./assets/icon.svg",
"logo": "./assets/logo.png",
"logoDark": "./assets/logo-dark.png",
"screenshots": ["./assets/shot.png"]
}
}
]
}"#,
)
.unwrap();
let resolved = find_marketplace_plugin(
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
"remote-plugin",
)
.unwrap();
let interface = resolved.interface.expect("fallback interface");
assert_eq!(interface.display_name.as_deref(), Some("Remote Plugin"));
assert_eq!(interface.composer_icon, None);
assert_eq!(interface.logo, None);
assert_eq!(interface.logo_dark, None);
assert!(interface.screenshots.is_empty());
}
#[test]
fn find_marketplace_plugin_builds_manifest_fallback_from_entry() {
let tmp = tempdir().unwrap();
@@ -319,6 +364,7 @@ fn find_marketplace_plugin_builds_manifest_fallback_from_entry() {
AbsolutePathBuf::try_from(plugin_root.join("assets/icon.svg")).unwrap()
),
logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()),
logo_dark: None,
screenshots: vec![
AbsolutePathBuf::try_from(plugin_root.join("assets/shot.png")).unwrap()
],
@@ -634,6 +680,7 @@ fn list_marketplaces_supports_alternate_manifest_layout() {
brand_color: None,
composer_icon: None,
logo: None,
logo_dark: None,
screenshots: Vec::new(),
}),
keywords: Vec::new(),
@@ -720,6 +767,7 @@ fn list_marketplaces_supports_repo_root_local_plugin_sources() {
brand_color: None,
composer_icon: None,
logo: None,
logo_dark: None,
screenshots: Vec::new(),
}),
keywords: Vec::new(),
@@ -1625,6 +1673,7 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(),
),
logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()),
logo_dark: None,
screenshots: vec![
AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(),
],
@@ -1739,6 +1788,7 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
brand_color: None,
composer_icon: None,
logo: None,
logo_dark: None,
screenshots: Vec::new(),
})
);
+4
View File
@@ -463,6 +463,7 @@ struct RemotePluginReleaseInterfaceResponse {
default_prompts: Option<Vec<String>>,
composer_icon_url: Option<String>,
logo_url: Option<String>,
logo_url_dark: Option<String>,
#[serde(default)]
screenshot_urls: Vec<String>,
}
@@ -1529,7 +1530,9 @@ fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option
composer_icon: None,
composer_icon_url: interface.composer_icon_url.clone(),
logo: None,
logo_dark: None,
logo_url: interface.logo_url.clone(),
logo_url_dark: interface.logo_url_dark.clone(),
screenshots: Vec::new(),
screenshot_urls: interface.screenshot_urls.clone(),
};
@@ -1546,6 +1549,7 @@ fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option
|| result.brand_color.is_some()
|| result.composer_icon_url.is_some()
|| result.logo_url.is_some()
|| result.logo_url_dark.is_some()
|| !result.screenshot_urls.is_empty();
has_fields.then_some(result)
}
@@ -156,7 +156,9 @@ fn expected_plugin_interface() -> PluginInterface {
composer_icon: None,
composer_icon_url: None,
logo: None,
logo_dark: None,
logo_url: None,
logo_url_dark: None,
screenshots: Vec::new(),
screenshot_urls: Vec::new(),
}
+15
View File
@@ -69,6 +69,7 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem {
default_prompts: None,
composer_icon_url: None,
logo_url: None,
logo_url_dark: None,
screenshot_urls: Vec::new(),
},
skills: Vec::new(),
@@ -76,6 +77,20 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem {
},
}
}
#[test]
fn remote_plugin_interface_maps_dark_logo_url() {
let mut plugin = directory_plugin("plugin-linear", "linear");
plugin.release.interface.logo_url_dark =
Some("https://example.com/linear/logo-dark.png".to_string());
assert_eq!(
remote_plugin_interface_to_info(&plugin)
.expect("plugin interface")
.logo_url_dark,
Some("https://example.com/linear/logo-dark.png".to_string())
);
}
fn item(name: &str, display_name: &str) -> RecommendedPluginItem {
RecommendedPluginItem {
id: format!("plugin_{name}"),
+4
View File
@@ -53,6 +53,7 @@ pub struct PluginManifestInterface<Resource> {
pub brand_color: Option<String>,
pub composer_icon: Option<Resource>,
pub logo: Option<Resource>,
pub logo_dark: Option<Resource>,
pub screenshots: Vec<Resource>,
}
@@ -72,6 +73,7 @@ impl<Resource> Default for PluginManifestInterface<Resource> {
brand_color: None,
composer_icon: None,
logo: None,
logo_dark: None,
screenshots: Vec::new(),
}
}
@@ -141,6 +143,7 @@ impl<Resource> PluginManifest<Resource> {
brand_color,
composer_icon,
logo,
logo_dark,
screenshots,
} = interface;
Some(PluginManifestInterface {
@@ -157,6 +160,7 @@ impl<Resource> PluginManifest<Resource> {
brand_color,
composer_icon: composer_icon.map(&mut map).transpose()?,
logo: logo.map(&mut map).transpose()?,
logo_dark: logo_dark.map(&mut map).transpose()?,
screenshots: screenshots
.into_iter()
.map(&mut map)
@@ -36,6 +36,7 @@
"brandColor": "#3B82F6",
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
"logoDark": "./assets/logo-dark.png",
"screenshots": [
"./assets/screenshot1.png",
"./assets/screenshot2.png",
@@ -105,6 +106,7 @@ Or as an object directly in `plugin.json`:
- `brandColor` (`string`): Theme color for the plugin card.
- `composerIcon` (`string`): Path to icon asset.
- `logo` (`string`): Path to logo asset.
- `logoDark` (`string`): Optional path to the logo asset used in dark mode.
- `screenshots` (`array` of `string`): List of screenshot asset paths.
- Screenshot entries must be PNG filenames and stored under `./assets/`.
- Keep file paths relative to plugin root.
@@ -205,7 +207,7 @@ personal marketplace unless the caller explicitly requests a repo-local destinat
- `version` must use strict semver.
- `websiteURL`, `privacyPolicyURL`, and `termsOfServiceURL` must be absolute `https://` URLs when
present.
- `composerIcon`, `logo`, and `screenshots` must point to real files inside the plugin archive when
- `composerIcon`, `logo`, `logoDark`, and `screenshots` must point to real files inside the plugin archive when
present.
- `apps` should appear in `plugin.json` only when `.app.json` actually exists.
- `mcpServers` may point to `.mcp.json` or contain the MCP server object directly in
@@ -153,6 +153,7 @@ def validate_manifest_shape(
"brandColor",
"composerIcon",
"logo",
"logoDark",
"screenshots",
"defaultPrompt",
"default_prompt",
@@ -184,7 +185,7 @@ def validate_manifest_shape(
not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None
):
errors.append("plugin.json field `interface.brandColor` must use `#RRGGBB`")
for field in ("composerIcon", "logo"):
for field in ("composerIcon", "logo", "logoDark"):
validate_optional_asset_path(plugin_root, plugin_root, interface, field, errors)
screenshots = interface.get("screenshots", [])
if not isinstance(screenshots, list):
@@ -1304,7 +1304,9 @@ pub(super) fn plugins_test_interface(
composer_icon: None,
composer_icon_url: None,
logo: None,
logo_dark: None,
logo_url: None,
logo_url_dark: None,
screenshots: Vec::new(),
screenshot_urls: Vec::new(),
}