Refactor plugin discovery and asset serving

Add basic plugin manifests and refactor plugin/file helpers to support flexible plugin resolution and secure asset serving. - Add placeholder plugin.json for example_agent and memory plugins. - Introduce constants and small cleanup in python/helpers/files.py (AGENTS_DIR, PLUGINS_DIR, PROJECTS_DIR, USER_DIR) and add helpers: is_file, is_dir, get_abs_path_dockerized, plus minor formatting/style fixes. - Refactor plugin metadata code in python/helpers/plugins.py: replace Plugin dataclass with a lightweight PluginListItem, centralize plugin roots, and implement find_plugin_dir, find_plugin_file, get_plugin_settings, save_plugin_settings and determine_plugin_save_file_path to resolve plugin files across usr/, plugins/, projects and agent profiles with correct precedence. - Update run_ui.py to use plugin_name (instead of plugin_id), resolve plugin directories via new helpers, enforce directory traversal/security checks using files.is_in_dir and files.is_file, and namespace API routes by plugin.name. These changes enable per-user/project/agent plugin overrides, add settings load/save support, and harden static asset serving.

frdel committed Feb 18, 2026 at 21:41 UTC 07c128130b38f922b49ce1f091b67d1ec4b80ac4
5 files changed +139 -35
plugins/example_agent/plugin.json
plugins/memory/plugin.json
python/helpers/files.py
+35 -12
@@ -17,6 +17,11 @@ import glob
17 import mimetypes
18 from simpleeval import simple_eval
19
20 +AGENTS_DIR = "agents"
21 +PLUGINS_DIR = "plugins"
22 +PROJECTS_DIR = "projects"
23 +USER_DIR = "usr"
24 +
25
26 class VariablesPlugin(ABC):
27 @abstractmethod
@@ -246,11 +251,7 @@ def is_probably_binary_bytes(data: bytes, threshold: float = 0.3) -> bool:
251
252 # Count suspicious control bytes
253 allowed = {8, 9, 10, 12, 13} # \b \t \n \f \r
249 - suspicious = sum(
250 - 1
251 - for b in data
252 - if ((b < 32 and b not in allowed) or b == 127)
253 - )
254 + suspicious = sum(1 for b in data if ((b < 32 and b not in allowed) or b == 127))
255 return (suspicious / len(data)) > threshold
256
257
@@ -352,7 +353,11 @@ def find_file_in_dirs(_filename: str, _directories: list[str]):
353 )
354
355
355 -def get_unique_filenames_in_dirs(dir_paths: list[str], pattern: str = "*", type: Literal["file", "dir", "any"] = "file"):
356 +def get_unique_filenames_in_dirs(
357 + dir_paths: list[str],
358 + pattern: str = "*",
359 + type: Literal["file", "dir", "any"] = "file",
360 +):
361 # returns absolute paths for unique filenames, priority by order in dir_paths
362 seen = set()
363 result = []
@@ -360,7 +365,11 @@ def get_unique_filenames_in_dirs(dir_paths: list[str], pattern: str = "*", type:
365 full_dir = get_abs_path(dir_path)
366 for file_path in glob.glob(os.path.join(full_dir, pattern)):
367 fname = os.path.basename(file_path)
363 - if fname not in seen and (type == "any" or (type == "file" and os.path.isfile(file_path)) or (type == "dir" and os.path.isdir(file_path))):
368 + if fname not in seen and (
369 + type == "any"
370 + or (type == "file" and os.path.isfile(file_path))
371 + or (type == "dir" and os.path.isdir(file_path))
372 + ):
373 seen.add(fname)
374 result.append(get_abs_path(file_path))
375 # sort by filename (basename), not the full path
@@ -456,10 +465,10 @@ def move_dir(old_path: str, new_path: str):
465 abs_new = get_abs_path(new_path)
466 if not os.path.isdir(abs_old):
467 return # nothing to rename
459 -
468 +
469 # ensure parent directory exists
470 os.makedirs(os.path.dirname(abs_new), exist_ok=True)
462 -
471 +
472 try:
473 os.rename(abs_old, abs_new)
474 except Exception:
@@ -509,14 +518,17 @@ def get_abs_path(*relative_paths):
518 "Convert relative paths to absolute paths based on the base directory."
519 return os.path.join(get_base_dir(), *relative_paths)
520
521 +
522 def get_abs_path_dockerized(*relative_paths):
523 "Ensures the abs path is dockerized (i.e. /a0/... path)"
524 abs = get_abs_path(*relative_paths)
525 from python.helpers import runtime
526 +
527 if runtime.is_dockerized():
528 return abs
529 return normalize_a0_path(abs)
530
531 +
532 def get_abs_path_development(*relative_paths):
533 "Ensures the abs path is relevant for dev environment"
534 abs = get_abs_path(*relative_paths)
@@ -551,6 +563,16 @@ def exists(*relative_paths):
563 return os.path.exists(path)
564
565
566 +def is_file(*relative_paths):
567 + path = get_abs_path(*relative_paths)
568 + return os.path.isfile(path)
569 +
570 +
571 +def is_dir(*relative_paths):
572 + path = get_abs_path(*relative_paths)
573 + return os.path.isdir(path)
574 +
575 +
576 def get_base_dir():
577 # Get the base directory from the current file path
578 base_dir = os.path.dirname(os.path.abspath(os.path.join(__file__, "../../")))
@@ -568,10 +590,10 @@ def dirname(path: str):
590
591
592 def is_in_base_dir(path: str):
571 - return is_in_dir(path,get_base_dir())
593 + return is_in_dir(path, get_base_dir())
594
595
574 -def is_in_dir(path:str,dir:str):
596 +def is_in_dir(path: str, dir: str):
597 # check if the given path is within the directory
598 abs_path = os.path.abspath(path)
599 abs_dir = os.path.abspath(dir)
@@ -621,6 +643,7 @@ def move_file(relative_path: str, new_path: str):
643 except OSError:
644 # fallback to copy and delete
645 import shutil
646 +
647 shutil.copy2(abs_path, new_abs_path)
648 try:
649 os.unlink(abs_path)
@@ -659,6 +682,7 @@ def read_text_files_in_dir(
682 continue
683 return result
684
685 +
686 def list_files_in_dir_recursively(relative_path: str) -> list[str]:
687 abs_path = get_abs_path(relative_path)
688 if not os.path.exists(abs_path):
@@ -671,4 +695,3 @@ def list_files_in_dir_recursively(relative_path: str) -> list[str]:
695 rel_path = os.path.relpath(file_path, abs_path)
696 result.append(rel_path)
697 return result
674 -
\ No newline at end of file
python/helpers/plugins.py
+96 -15
@@ -1,22 +1,26 @@
1 from __future__ import annotations
2
3 -import re
3 +import re, json
4 from dataclasses import dataclass
5 from pathlib import Path
6 -from typing import Any, Dict, List, Optional
6 +from typing import Any, Dict, List, Optional, TYPE_CHECKING
7
8 from python.helpers import files, print_style
9
10 +if TYPE_CHECKING:
11 + from agent import Agent
12 +
13 # Extracts target selector from <meta name="plugin-target" content="...">
14 _META_TARGET_RE = re.compile(
15 r'<meta\s+name=["\']plugin-target["\']\s+content=["\']([^"\']+)["\']',
16 re.IGNORECASE,
17 )
18
19 +META_FILE_NAME = "plugin.json"
20 +SETTINGS_FILE_NAME = "settings.json"
21
22 @dataclass(slots=True)
18 -class Plugin:
19 - id: str
23 +class PluginListItem:
24 name: str
25 path: Path
26
@@ -27,14 +31,14 @@ def get_plugin_roots() -> List[str]:
31 # projects = files.find_existing_paths_by_pattern("usr/projects/*/.a0proj/plugins")
32 return [
33 # *projects,
30 - files.get_abs_path("usr/plugins"),
31 - files.get_abs_path("plugins"),
34 + files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR),
35 + files.get_abs_path(files.PLUGINS_DIR),
36 ]
37
38
35 -def list_plugins() -> List[Plugin]:
39 +def list_plugins() -> List[PluginListItem]:
40 """Discover plugins by directory convention. First root wins on ID conflict."""
37 - by_id: Dict[str, Plugin] = {}
41 + by_id: Dict[str, PluginListItem] = {}
42 for root in get_plugin_roots():
43 root_path = Path(root)
44 if not root_path.exists():
@@ -43,17 +47,25 @@ def list_plugins() -> List[Plugin]:
47 if not d.is_dir() or d.name.startswith("."):
48 continue
49 if d.name not in by_id:
46 - by_id[d.name] = Plugin(id=d.name, name=d.name, path=d)
50 + by_id[d.name] = PluginListItem(name=d.name, path=d)
51 return list(by_id.values())
52
53
50 -def find_plugin(plugin_id: str) -> Optional[Plugin]:
54 +def find_plugin_dir(plugin_name: str):
55 """Find a single plugin by ID."""
52 - if not plugin_id:
56 + if not plugin_name:
57 return None
54 - for p in list_plugins():
55 - if p.id == plugin_id:
56 - return p
58 +
59 + # check if the plugin is in the user directory
60 + user_plugin_path = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name, META_FILE_NAME)
61 + if files.exists(user_plugin_path):
62 + return files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name)
63 +
64 + # check if the plugin is in the default directory
65 + default_plugin_path = files.get_abs_path(files.PLUGINS_DIR, plugin_name, META_FILE_NAME)
66 + if files.exists(default_plugin_path):
67 + return files.get_abs_path(files.PLUGINS_DIR, plugin_name)
68 +
69 return None
70
71
@@ -97,10 +109,79 @@ def get_webui_extensions(extension_point:str, filters:List[str]|None=None) -> Li
109 try:
110 rel_path = files.deabsolute_path(str(ext_file))
111 entry: Dict[str, Any] = {
100 - "plugin_id": plugin.id,
112 + "plugin_name": plugin.name,
113 "path": rel_path,
114 }
115 entries.append(entry)
116 except Exception:
117 print_style.PrintStyle.error(f"Failed to load frontend extension file {ext_file}")
118 return entries
119 +
120 +def get_plugin_settings(plugin_name:str, agent:Agent|None):
121 + file_path = find_plugin_file(plugin_name, SETTINGS_FILE_NAME, agent=agent)
122 + if file_path:
123 + return json.loads(files.read_file(file_path))
124 + return None
125 +
126 +def save_plugin_settings(plugin_name:str, project_name:str, agent_profile:str, settings:dict):
127 + file_path = determine_plugin_save_file_path(plugin_name, project_name, agent_profile, SETTINGS_FILE_NAME)
128 + if file_path:
129 + files.write_file(file_path, json.dumps(settings))
130 +
131 +def find_plugin_file(plugin_name:str, *subpaths:str, agent:Agent|None=None):
132 + profile_name = agent.config.profile if agent and agent.config.profile else ""
133 + project_name = ""
134 +
135 + if agent:
136 + from python.helpers import projects
137 + project_name = projects.get_context_project_name(agent.context) or ""
138 +
139 + if project_name and profile_name:
140 + # project/.a0proj/agents/<profile>/plugins/<plugin_name>/...
141 + project_agent_file = projects.get_project_meta_folder(
142 + project_name, files.AGENTS_DIR, profile_name, files.PLUGINS_DIR, plugin_name, *subpaths
143 + )
144 + if files.exists(project_agent_file):
145 + return project_agent_file
146 +
147 + if project_name:
148 + # project/.a0proj/plugins/<plugin_name>/...
149 + project_file = projects.get_project_meta_folder(project_name, files.PLUGINS_DIR, plugin_name, *subpaths)
150 + if files.exists(project_file):
151 + return project_file
152 +
153 + if profile_name:
154 + from python.helpers import subagents
155 + # usr/agents/<profile>/plugins/<plugin_name>/...
156 + path = files.get_abs_path(subagents.USER_AGENTS_DIR, profile_name, files.PLUGINS_DIR, plugin_name, *subpaths)
157 + if files.exists(path):
158 + return path
159 +
160 + # agents/<profile>/plugins/<plugin_name>/...
161 + path = files.get_abs_path(subagents.DEFAULT_AGENTS_DIR, profile_name, files.PLUGINS_DIR, plugin_name, *subpaths)
162 + if files.exists(path):
163 + return path
164 +
165 + # usr/plugins/<plugin_name>/...
166 + path = files.get_abs_path(files.USER_DIR, files.PLUGINS_DIR, plugin_name, *subpaths)
167 + if files.exists(path):
168 + return path
169 +
170 + # plugins/<plugin_name>/...
171 + path = files.get_abs_path(files.PLUGINS_DIR, plugin_name, *subpaths)
172 + if files.exists(path):
173 + return path
174 +
175 + return None
176 +
177 +def determine_plugin_save_file_path(plugin_name:str, project_name:str, agent_profile:str, *subpaths:str):
178 + base_path = files.get_abs_path(files.USER_DIR)
179 +
180 + if project_name:
181 + from python.helpers import projects
182 + base_path = projects.get_project_meta_folder(project_name)
183 +
184 + if agent_profile:
185 + base_path = files.get_abs_path(base_path, files.AGENTS_DIR, agent_profile)
186 +
187 + return files.get_abs_path(base_path, files.PLUGINS_DIR, plugin_name, *subpaths)
\ No newline at end of file
run_ui.py
+8 -8
@@ -238,9 +238,9 @@ async def serve_index():
238
239
240 # Serve plugin assets
241 -@webapp.route("/plugins/<plugin_id>/<path:asset_path>", methods=["GET"])
241 +@webapp.route("/plugins/<plugin_name>/<path:asset_path>", methods=["GET"])
242 @requires_auth
243 -async def serve_plugin_asset(plugin_id, asset_path):
243 +async def serve_plugin_asset(plugin_name, asset_path):
244 """
245 Serve static assets from plugin directories.
246 Resolves using the plugin system (with overrides).
@@ -249,21 +249,21 @@ async def serve_plugin_asset(plugin_id, asset_path):
249 from flask import send_file
250
251 # Use the new find_plugin helper
252 - plugin = plugins.find_plugin(plugin_id)
253 - if not plugin:
252 + plugin_dir = plugins.find_plugin_dir(plugin_name)
253 + if not plugin_dir:
254 return Response("Plugin not found", 404)
255
256 # Resolve the plugin asset path with security checks
257 try:
258 # Construct path using plugin root
259 - asset_file = (plugin.path / asset_path).resolve()
260 - plugin_root = plugin.path.resolve()
259 + asset_file = files.get_abs_path(plugin_dir, asset_path)
260 + plugin_root = plugin_dir
261
262 # Security: ensure the resolved path is within the plugin directory
263 if not files.is_in_dir(str(asset_file), str(plugin_root)):
264 return Response("Access denied", 403)
265
266 - if not asset_file.is_file():
266 + if not files.is_file(asset_file):
267 return Response("Asset not found", 404)
268
269 return send_file(str(asset_file))
@@ -507,7 +507,7 @@ def run():
507 plugin_handlers = load_classes_from_folder(str(api_path), "*.py", ApiHandler)
508 for handler in plugin_handlers:
509 # prefixed route for explicit namespacing
510 - register_api_handler(webapp, handler, url_prefix=f"/api/plugins/{plugin.id}")
510 + register_api_handler(webapp, handler, url_prefix=f"/api/plugins/{plugin.name}")
511
512 handlers_by_namespace = _build_websocket_handlers_by_namespace(socketio_server, lock)
513 configure_websocket_namespaces(