feat(webui): show packaged agent plugin logos

This commit is contained in:
Xubin Ren
2026-08-10 23:01:46 +09:00
parent 8b03beef27
commit d531d1cdf8
6 changed files with 116 additions and 2 deletions
+4
View File
@@ -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
`<workspace>/plugins/`; updates refresh that package and uninstall removes it. The external
+51
View File
@@ -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):
+1 -1
View File
@@ -869,7 +869,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),
+53
View File
@@ -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"),
[
+5
View File
@@ -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
@@ -8303,6 +8303,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)
@@ -8310,7 +8311,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 (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"