From 2c351cb8646054427e0ea81a484233114b1234f7 Mon Sep 17 00:00:00 2001 From: Drew Date: Mon, 22 Jun 2026 16:01:27 -0700 Subject: [PATCH] [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. --- .../codex_app_server_protocol.schemas.json | 18 +++++++ .../codex_app_server_protocol.v2.schemas.json | 18 +++++++ .../json/v2/PluginInstalledResponse.json | 18 +++++++ .../schema/json/v2/PluginListResponse.json | 18 +++++++ .../schema/json/v2/PluginReadResponse.json | 18 +++++++ .../json/v2/PluginShareListResponse.json | 18 +++++++ .../schema/typescript/v2/PluginInterface.ts | 8 +++ .../src/protocol/v2/plugin.rs | 4 ++ .../src/protocol/v2/tests.rs | 11 ++++ .../src/request_processors/plugins.rs | 2 + .../app-server/tests/suite/v2/plugin_list.rs | 2 + .../app-server/tests/suite/v2/plugin_read.rs | 25 +++++++++- .../app-server/tests/suite/v2/plugin_share.rs | 2 + codex-rs/core-plugins/src/manager_tests.rs | 2 + codex-rs/core-plugins/src/manifest.rs | 35 +++++++++++++ codex-rs/core-plugins/src/marketplace.rs | 1 + .../core-plugins/src/marketplace_tests.rs | 50 +++++++++++++++++++ codex-rs/core-plugins/src/remote.rs | 4 ++ .../core-plugins/src/remote/share/tests.rs | 2 + codex-rs/core-plugins/src/remote_tests.rs | 15 ++++++ codex-rs/plugin/src/manifest.rs | 4 ++ .../references/plugin-json-spec.md | 4 +- .../plugin-creator/scripts/validate_plugin.py | 3 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + 24 files changed, 281 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b2be46f1f..810b8541d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 98b0d4297..de7d46aac 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json index ffe20e8c5..ce4ae5aa0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index d756f61a6..1002ad1a8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index bef85afdd..a2d2d442c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index 7cffdeab9..be97e20b6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -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", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts index 4e97ee66f..1e57d4974 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts @@ -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. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 128425d1c..dbe0b3d55 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -716,8 +716,12 @@ pub struct PluginInterface { pub composer_icon_url: Option, /// Local logo path, resolved from the installed plugin package. pub logo: Option, + /// Local dark-mode logo path, resolved from the installed plugin package. + pub logo_dark: Option, /// Remote logo URL from the plugin catalog. pub logo_url: Option, + /// Remote dark-mode logo URL from the plugin catalog. + pub logo_url_dark: Option, /// Local screenshot paths, resolved from the installed plugin package. pub screenshots: Vec, /// Remote screenshot URLs from the plugin catalog. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 74e534be0..ba69b6d6e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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"], }), diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 3913ded3a..76123b6aa 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -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(), } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index bcfd85f19..4d143109f 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -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(), }), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 5009beb64..9d7b7902b 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -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()] diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 99fc1289d..ba142feb9 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -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(), } diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 50a5fe3e7..adf721fc6 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -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(), }); diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 37630a843..2352fdbef 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -74,6 +74,8 @@ struct RawPluginManifestInterface { #[serde(default)] logo: Option, #[serde(default)] + logo_dark: Option, + #[serde(default)] screenshots: Vec, } @@ -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"); diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 456bd9004..f5024ab25 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -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) diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 45aa2364e..3ea2c9d60 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -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(), }) ); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index a0007aa1d..05ce11095 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -463,6 +463,7 @@ struct RemotePluginReleaseInterfaceResponse { default_prompts: Option>, composer_icon_url: Option, logo_url: Option, + logo_url_dark: Option, #[serde(default)] screenshot_urls: Vec, } @@ -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) } diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index e33d3c5d5..ed4834123 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -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(), } diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index 793a8a55b..f1d4478d7 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -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}"), diff --git a/codex-rs/plugin/src/manifest.rs b/codex-rs/plugin/src/manifest.rs index 599f4ee40..eb8e1f7d9 100644 --- a/codex-rs/plugin/src/manifest.rs +++ b/codex-rs/plugin/src/manifest.rs @@ -53,6 +53,7 @@ pub struct PluginManifestInterface { pub brand_color: Option, pub composer_icon: Option, pub logo: Option, + pub logo_dark: Option, pub screenshots: Vec, } @@ -72,6 +73,7 @@ impl Default for PluginManifestInterface { brand_color: None, composer_icon: None, logo: None, + logo_dark: None, screenshots: Vec::new(), } } @@ -141,6 +143,7 @@ impl PluginManifest { brand_color, composer_icon, logo, + logo_dark, screenshots, } = interface; Some(PluginManifestInterface { @@ -157,6 +160,7 @@ impl PluginManifest { 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) diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md index ec5476ae1..40d3ae6b5 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -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 diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py index 5c6fae8e6..88fae0fd0 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py @@ -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): diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 9a50b3783..abedbe567 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -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(), }