From 44e008745d829201482c0dbb16b9491d9341b0ec Mon Sep 17 00:00:00 2001
From: frdel <38891707+frdel@users.noreply.github.com>
Date: Mon, 30 Mar 2026 11:50:59 +0200
Subject: [PATCH] Sanitize print logs; refactor popular plugin logic
Ensure printed output and HTML logs are safe by importing and applying sanitize_string, opening log files with utf-8 and errors='replace', and sanitizing text before writing. Add tests to verify lone surrogate characters are replaced and that logging won't crash on invalid Unicode. In the plugin installer UI, introduce POPULAR_PLUGIN_MIN_STARS and centralize popularity checking in _isPopularPlugin, using it for filtering and counts.
---
helpers/print_style.py | 11 ++-
.../webui/install-detail.html | 85 +++++++++++++++++++
.../webui/install-index.html | 29 +++++++
.../webui/pluginInstallStore.js | 36 +++++++-
tests/test_print_style.py | 52 ++++++++++++
tools/skills_tool.py | 70 ++++++++++++++-
6 files changed, 272 insertions(+), 11 deletions(-)
create mode 100644 tests/test_print_style.py
diff --git a/helpers/print_style.py b/helpers/print_style.py
index 88db30532..ab70f9469 100644
--- a/helpers/print_style.py
+++ b/helpers/print_style.py
@@ -3,6 +3,7 @@ import sys
from datetime import datetime
from collections.abc import Mapping
from . import files
+from .strings import sanitize_string
_runtime_module = None
@@ -34,7 +35,7 @@ class PrintStyle:
os.makedirs(logs_dir, exist_ok=True)
log_filename = datetime.now().strftime("log_%Y%m%d_%H%M%S.html")
PrintStyle.log_file_path = os.path.join(logs_dir, log_filename)
- with open(PrintStyle.log_file_path, "w") as f:
+ with open(PrintStyle.log_file_path, "w", encoding="utf-8", errors="replace") as f:
f.write("
+
+
+ priority_high
+ Suspended
+
+
Installed
@@ -100,6 +106,17 @@
+
+
+
+ priority_high
+ Suspended
+
+
check_circle
@@ -641,6 +647,19 @@
border-color: rgba(59, 130, 246, 0.3);
}
+ .pi-card-bubble-suspended .material-symbols-outlined {
+ color: #f59e0b;
+ }
+
+ .pi-card-bubble-suspended .pi-card-bubble-text {
+ color: #f59e0b;
+ }
+
+ .pi-card-bubble-suspended:hover {
+ background: rgba(245, 158, 11, 0.12);
+ border-color: rgba(245, 158, 11, 0.35);
+ }
+
body.light-mode .pi-card-bubble-installed .material-symbols-outlined,
body.light-mode .pi-card-bubble-installed .pi-card-bubble-text {
color: #166534;
@@ -661,6 +680,16 @@
border-color: rgba(59, 130, 246, 0.35);
}
+ body.light-mode .pi-card-bubble-suspended .material-symbols-outlined,
+ body.light-mode .pi-card-bubble-suspended .pi-card-bubble-text {
+ color: #b45309;
+ }
+
+ body.light-mode .pi-card-bubble-suspended:hover {
+ background: rgba(245, 158, 11, 0.16);
+ border-color: rgba(217, 119, 6, 0.35);
+ }
+
.pi-pagination {
display: flex;
align-items: center;
diff --git a/plugins/_plugin_installer/webui/pluginInstallStore.js b/plugins/_plugin_installer/webui/pluginInstallStore.js
index da4e3f1ad..665103386 100644
--- a/plugins/_plugin_installer/webui/pluginInstallStore.js
+++ b/plugins/_plugin_installer/webui/pluginInstallStore.js
@@ -11,6 +11,7 @@ import { store as pluginSettingsStore } from "/components/plugins/plugin-setting
const PLUGIN_API = "plugins/_plugin_installer/plugin_install";
const PER_PAGE = 24;
+const POPULAR_PLUGIN_MIN_STARS = 3;
const SECURITY_WARNING = {
title: "Security Warning",
@@ -93,11 +94,23 @@ const model = {
.join(" ");
},
+ _isPopularPlugin(plugin) {
+ return (plugin?.stars || 0) >= POPULAR_PLUGIN_MIN_STARS;
+ },
+
+ _getSuspensionReason(plugin) {
+ return typeof plugin?.suspended === "string" ? plugin.suspended.trim() : "";
+ },
+
+ isPluginSuspended(plugin) {
+ return !!this._getSuspensionReason(plugin);
+ },
+
_matchesBrowseFilter(plugin, filterKey) {
if (!filterKey || filterKey === "all") return true;
if (filterKey === "installed") return !!plugin?.installed;
if (filterKey === "update") return !!plugin?.has_update;
- if (filterKey === "popular") return (plugin?.stars || 0) >= 3;
+ if (filterKey === "popular") return this._isPopularPlugin(plugin);
if (filterKey.startsWith("tag:")) {
return this._pluginPrimaryTag(plugin) === filterKey.slice(4);
}
@@ -126,6 +139,22 @@ const model = {
return true;
},
+ _comparePluginsByStars(a, b) {
+ const aSuspended = this.isPluginSuspended(a);
+ const bSuspended = this.isPluginSuspended(b);
+ if (aSuspended !== bSuspended) {
+ return aSuspended ? 1 : -1;
+ }
+
+ const aStars = aSuspended ? 0 : Number(a?.stars) || 0;
+ const bStars = bSuspended ? 0 : Number(b?.stars) || 0;
+ if (aStars !== bStars) {
+ return bStars - aStars;
+ }
+
+ return (a.title || a.key).localeCompare(b.title || b.key);
+ },
+
// ── ZIP Install ──────────────────────────────
handleFileUpload(event) {
@@ -341,6 +370,7 @@ const model = {
commit: val?.commit || val?.latest_commit || "",
updated: val?.updated || val?.latest_commit_timestamp || "",
version: val?.version || "",
+ suspended: this._getSuspensionReason(val),
installed,
};
@@ -365,7 +395,7 @@ const model = {
const updateCount = plugins.filter((plugin) => plugin.has_update).length;
filters.push({ key: "update", label: "Update", count: updateCount });
- const popularCount = plugins.filter((plugin) => (plugin.stars || 0) > 0).length;
+ const popularCount = plugins.filter((plugin) => this._isPopularPlugin(plugin)).length;
if (popularCount) {
filters.push({ key: "popular", label: "Popular", count: popularCount });
}
@@ -406,7 +436,7 @@ const model = {
);
}
if (this.sortBy === "stars") {
- list.sort((a, b) => (b.stars || 0) - (a.stars || 0));
+ list.sort((a, b) => this._comparePluginsByStars(a, b));
} else {
list.sort((a, b) =>
(a.title || a.key).localeCompare(b.title || b.key)
diff --git a/tests/test_print_style.py b/tests/test_print_style.py
new file mode 100644
index 000000000..3c85f1755
--- /dev/null
+++ b/tests/test_print_style.py
@@ -0,0 +1,52 @@
+import sys
+from pathlib import Path
+
+import pytest
+
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
+if str(PROJECT_ROOT) not in sys.path:
+ sys.path.insert(0, str(PROJECT_ROOT))
+
+from helpers.print_style import PrintStyle
+
+
+class _PassthroughSecretsManager:
+ def mask_values(self, text: str) -> str:
+ return text
+
+
+@pytest.fixture(autouse=True)
+def _reset_print_style_state():
+ PrintStyle.log_file_path = None
+ PrintStyle.last_endline = True
+ yield
+ PrintStyle.log_file_path = None
+ PrintStyle.last_endline = True
+
+
+def test_get_sanitizes_lone_surrogates(tmp_path, monkeypatch):
+ monkeypatch.setattr("helpers.print_style.files.get_abs_path", lambda _: str(tmp_path))
+
+ style = PrintStyle(log_only=True)
+ style.secrets_mgr = _PassthroughSecretsManager()
+
+ plain_text, styled_text, html_text = style.get("bad \ud83d")
+
+ assert plain_text == "bad ?"
+ assert "\ud83d" not in styled_text
+ assert "\ud83d" not in html_text
+
+
+def test_print_writes_html_log_without_surrogate_crash(tmp_path, monkeypatch):
+ monkeypatch.setattr("helpers.print_style.files.get_abs_path", lambda _: str(tmp_path))
+
+ style = PrintStyle(log_only=True)
+ style.secrets_mgr = _PassthroughSecretsManager()
+
+ style.print("bad \ud83d")
+
+ log_path = Path(PrintStyle.log_file_path)
+ content = log_path.read_text(encoding="utf-8")
+
+ assert "bad ?" in content
+ assert "\ud83d" not in content
diff --git a/tools/skills_tool.py b/tools/skills_tool.py
index 01bb74f8c..fd462ddd1 100644
--- a/tools/skills_tool.py
+++ b/tools/skills_tool.py
@@ -6,6 +6,7 @@ from typing import List
from helpers.tool import Tool, Response
from helpers import projects, files, file_tree
from helpers import skills as skills_helper, runtime
+from helpers.print_style import PrintStyle
DATA_NAME_LOADED_SKILLS = "loaded_skills"
@@ -24,6 +25,67 @@ class SkillsTool(Tool):
Script execution is handled by code_execution_tool directly.
"""
+ def _current_method(self) -> str:
+ return (
+ (self.args.get("method") or self.method or "")
+ .strip()
+ .lower()
+ )
+
+ @staticmethod
+ def _normalize_skill_name(skill_name: str) -> str:
+ skill_name = skill_name.strip()
+ if skill_name.startswith("**") and skill_name.endswith("**"):
+ skill_name = skill_name[2:-2]
+ return skill_name.strip()
+
+ def get_log_object(self):
+ import uuid
+
+ if self._current_method() == "load":
+ skill_name = self._normalize_skill_name(
+ str(self.args.get("skill_name") or "")
+ )
+ heading = (
+ f"icon://construction Loading skill {skill_name}"
+ if skill_name
+ else "icon://construction Loading skill"
+ )
+ return self.agent.context.log.log(
+ type="tool",
+ heading=heading,
+ content="",
+ kvps={"_tool_name": self.name},
+ id=str(uuid.uuid4()),
+ )
+
+ return super().get_log_object()
+
+ async def before_execution(self, **kwargs):
+ if self._current_method() != "load":
+ await super().before_execution(**kwargs)
+ return
+
+ skill_name = self._normalize_skill_name(
+ str(kwargs.get("skill_name") or self.args.get("skill_name") or "")
+ )
+ label = f"{self.name}:{self._current_method()}"
+ if skill_name:
+ PrintStyle(
+ font_color="#1B4F72",
+ padding=True,
+ background_color="white",
+ bold=True,
+ ).print(f"{self.agent.agent_name}: Loading skill '{skill_name}'")
+ else:
+ PrintStyle(
+ font_color="#1B4F72",
+ padding=True,
+ background_color="white",
+ bold=True,
+ ).print(f"{self.agent.agent_name}: Using tool '{label}'")
+ self.log = self.get_log_object()
+
async def execute(self, **kwargs) -> Response:
method = (
(kwargs.get("method") or self.args.get("method") or self.method or "")
@@ -38,7 +100,9 @@ class SkillsTool(Tool):
# query = str(kwargs.get("query") or "").strip()
# return Response(message=self._search(query), break_loop=False)
if method == "load":
- skill_name = str(kwargs.get("skill_name") or "").strip()
+ skill_name = self._normalize_skill_name(
+ str(kwargs.get("skill_name") or "")
+ )
return Response(message=self._load(skill_name), break_loop=False)
# if method == "read_file":
# skill_name = str(kwargs.get("skill_name") or "").strip()
@@ -106,9 +170,7 @@ class SkillsTool(Tool):
# return "\n".join(lines)
def _load(self, skill_name: str) -> str:
- skill_name = skill_name.strip()
- if skill_name.startswith("**") and skill_name.endswith("**"):
- skill_name = skill_name[2:-2]
+ skill_name = self._normalize_skill_name(skill_name)
if not skill_name:
return "Error: 'skill_name' is required for method=load."