From 70bd454d322029479a2a8d9bc050660a3a5aa594 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:11:32 +0900 Subject: [PATCH] feat(webui): show packaged agent plugin logos --- docs/configuration.md | 4 ++ nanobot/agent/agent_plugins.py | 51 ++++++++++++++++++ nanobot/webui/mcp_presets_api.py | 2 +- tests/agent/test_agent_plugins.py | 53 +++++++++++++++++++ tests/webui/test_mcp_presets_api.py | 5 ++ .../src/components/settings/SettingsView.tsx | 3 +- 6 files changed, 116 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 5a9b937ee..d3c9ad149 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2343,6 +2343,10 @@ local WebUI runs it once per plugin version before first enable; remote WebUI cl plugin setup unless remote package installation was explicitly allowed. Agent Plugins v1 does not define a registry, so package distribution remains separate from discovery and execution. +The optional `extensions.dev.nanobot.logo` field points to a packaged PNG, JPEG, or WebP asset +such as `./assets/icon.png`. nanobot only reads contained raster files up to 256 KiB and embeds +them locally in the Apps catalog; invalid or missing assets fall back to the plugin initials. + CLI Apps installed from the WebUI use the same package layout. nanobot installs the CLI through its catalog adapter, then writes and enables a skills-only Agent Plugin under `/plugins/`; updates refresh that package and uninstall removes it. The external diff --git a/nanobot/agent/agent_plugins.py b/nanobot/agent/agent_plugins.py index 493283a67..7fe81ce68 100644 --- a/nanobot/agent/agent_plugins.py +++ b/nanobot/agent/agent_plugins.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import json import os import re @@ -43,6 +44,13 @@ _MCP_SERVER_FIELDS = { } _SETUP_ENV = {"HOME", "LANG", "LC_ALL", "LOGNAME", "PATH", "SHELL", "TMPDIR", "USER"} _SETUP_TIMEOUT_SECONDS = 600 +_LOGO_MIME_TYPES = { + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +} +_MAX_LOGO_BYTES = 256 * 1024 @dataclass(frozen=True) @@ -66,6 +74,7 @@ class AgentPlugin: display_name: str category: str accent_color: str | None + logo: Path | None permissions: tuple[str, ...] install_command: tuple[str, ...] @@ -167,6 +176,7 @@ def _load_manifest(plugin_root: Path) -> AgentPlugin | None: display_name=_string(nanobot.get("displayName")) or name, category=_string(nanobot.get("category")) or "Plugin", accent_color=_accent_color(nanobot.get("accentColor")), + logo=_plugin_logo(nanobot.get("logo"), plugin_root), permissions=_string_tuple(nanobot.get("permissions")), install_command=_install_command(nanobot.get("installCommand"), plugin_root), ) @@ -214,6 +224,7 @@ def agent_plugins_payload(workspace: Path) -> dict[str, Any]: "category": plugin.category, "repository": plugin.repository, "accent_color": plugin.accent_color, + "logo_url": _plugin_logo_data_url(plugin.logo), "permissions": list(plugin.permissions), "mcp_servers": mcp_servers, "enabled": enabled, @@ -283,6 +294,46 @@ def _accent_color(value: object) -> str | None: return value if isinstance(value, str) and re.fullmatch(r"#[0-9a-fA-F]{6}", value) else None +def _plugin_logo(value: object, plugin_root: Path) -> Path | None: + """Resolve nanobot's optional packaged logo extension.""" + if value is None: + return None + if not isinstance(value, str) or not value.startswith("./"): + logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root) + return None + logo = _contained_file(plugin_root / value[2:], plugin_root) + if logo is None or logo.suffix.lower() not in _LOGO_MIME_TYPES: + logger.warning("Ignoring invalid Agent Plugin logo in '{}'", plugin_root) + return None + try: + if logo.stat().st_size > _MAX_LOGO_BYTES: + logger.warning("Ignoring oversized Agent Plugin logo in '{}'", plugin_root) + return None + except OSError: + return None + return logo + + +def _plugin_logo_data_url(path: Path | None) -> str | None: + if path is None: + return None + try: + data = path.read_bytes() + except OSError: + return None + suffix = path.suffix.lower() + valid = ( + suffix == ".png" and data.startswith(b"\x89PNG\r\n\x1a\n") + or suffix in {".jpg", ".jpeg"} and data.startswith(b"\xff\xd8\xff") + or suffix == ".webp" and data.startswith(b"RIFF") and data[8:12] == b"WEBP" + ) + if not valid: + logger.warning("Ignoring malformed Agent Plugin logo '{}'", path) + return None + encoded = base64.b64encode(data).decode("ascii") + return f"data:{_LOGO_MIME_TYPES[suffix]};base64,{encoded}" + + def _install_command(value: object, plugin_root: Path) -> tuple[str, ...]: """Validate nanobot's optional, shell-free setup command extension.""" if not isinstance(value, list): diff --git a/nanobot/webui/mcp_presets_api.py b/nanobot/webui/mcp_presets_api.py index 191b3f527..21c9552cf 100644 --- a/nanobot/webui/mcp_presets_api.py +++ b/nanobot/webui/mcp_presets_api.py @@ -866,7 +866,7 @@ def _agent_plugin_payload(plugin: Mapping[str, Any]) -> dict[str, Any]: "configured": enabled, "available": enabled, "status": "configured" if enabled else "not_installed", - "logo_url": None, + "logo_url": plugin.get("logo_url"), "brand_color": plugin.get("accent_color"), "required_fields": [], "connection_summary": ", ".join(server_names), diff --git a/tests/agent/test_agent_plugins.py b/tests/agent/test_agent_plugins.py index 29b7a76d3..0bf3fb11a 100644 --- a/tests/agent/test_agent_plugins.py +++ b/tests/agent/test_agent_plugins.py @@ -142,6 +142,59 @@ def test_unknown_manifest_fields_and_non_object_extensions_are_ignored(tmp_path: assert [skill.name for skill in discover_agent_plugin_skills(tmp_path)] == ["example"] +def test_agent_plugin_payload_embeds_contained_raster_logo(tmp_path: Path) -> None: + plugin = _write_plugin( + tmp_path, + "demo", + manifest={ + "$schema": AGENT_PLUGIN_SCHEMA, + "name": "demo", + "extensions": {"dev.nanobot": {"logo": "./assets/icon.png"}}, + }, + ) + assets = plugin / "assets" + assets.mkdir() + (assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo") + executable = plugin / "bin" / "server" + executable.parent.mkdir() + executable.write_text("#!/bin/sh\n", encoding="utf-8") + (plugin / "mcp.json").write_text( + json.dumps( + { + "$schema": AGENT_PLUGIN_MCP_SCHEMA, + "mcpServers": {"demo": {"type": "stdio", "command": "./bin/server"}}, + } + ), + encoding="utf-8", + ) + + logo_url = agent_plugins_payload(tmp_path)["plugins"][0]["logo_url"] + + assert logo_url == "data:image/png;base64,iVBORw0KGgpsb2dv" + + +def test_agent_plugin_logo_cannot_escape_package(tmp_path: Path) -> None: + outside = tmp_path / "outside.png" + outside.write_bytes(b"\x89PNG\r\n\x1a\nlogo") + plugin = _write_plugin( + tmp_path, + "demo", + manifest={ + "$schema": AGENT_PLUGIN_SCHEMA, + "name": "demo", + "extensions": {"dev.nanobot": {"logo": "./assets/icon.png"}}, + }, + ) + assets = plugin / "assets" + assets.mkdir() + try: + (assets / "icon.png").symlink_to(outside) + except OSError as exc: + pytest.skip(f"file symlink unavailable: {exc}") + + assert agent_plugins.discover_agent_plugins(tmp_path)[0].logo is None + + @pytest.mark.parametrize( ("skill_name", "frontmatter"), [ diff --git a/tests/webui/test_mcp_presets_api.py b/tests/webui/test_mcp_presets_api.py index 506e9ba4d..1810d667c 100644 --- a/tests/webui/test_mcp_presets_api.py +++ b/tests/webui/test_mcp_presets_api.py @@ -33,6 +33,9 @@ def _write_agent_plugin(workspace: Path) -> None: command = root / "bin" / "server" command.parent.mkdir(parents=True, exist_ok=True) command.write_text("#!/bin/sh\n", encoding="utf-8") + assets = root / "assets" + assets.mkdir() + (assets / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\nlogo") (root / "plugin.json").write_text( json.dumps( { @@ -43,6 +46,7 @@ def _write_agent_plugin(workspace: Path) -> None: "dev.nanobot": { "displayName": "Desktop Control", "accentColor": "#ff7a1a", + "logo": "./assets/icon.png", "permissions": ["screen-recording"], } }, @@ -109,6 +113,7 @@ def test_agent_plugin_reuses_mcp_catalog_and_runtime_action( row = next(item for item in mcp_presets_payload()["presets"] if item["source"] == "agent-plugin") assert row["name"] == "plugin-desktop" assert row["display_name"] == "Desktop Control" + assert row["logo_url"] == "data:image/png;base64,iVBORw0KGgpsb2dv" assert row["installed"] is True assert row["configured"] is False diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 92be1394c..504464871 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -8232,6 +8232,7 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show const bg = preset.brand_color || "hsl(var(--muted))"; const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls); + const packagedLogo = preset.logo_url?.startsWith("data:image/") === true; const initials = preset.display_name .split(/\s+/) .filter(Boolean) @@ -8239,7 +8240,7 @@ function McpPresetLogo({ preset, showBrandLogos }: { preset: McpPresetInfo; show .map((part) => part[0]?.toUpperCase()) .join("") || preset.name.slice(0, 2).toUpperCase(); - if (showBrandLogos && logoUrl) { + if ((showBrandLogos || packagedLogo) && logoUrl) { return (