Support standalone user API and WebUI routes

Add usr/api as a contained fallback after existing built-in and plugin handlers, preserving current route precedence and security gates. Serve usr/extensions/webui assets from an authenticated, root-contained namespace while keeping built-in extension paths unchanged. Cover source precedence, authentication, path traversal, and manifest-to-asset routing with focused regressions.

Alessandro committed Aug 19, 2026 at 12:14 UTC 81fcc24364ea0d9d36e734e126115bff3a217bf7
5 files changed +197 -4
helpers/api.py
+10 -1
@@ -214,7 +214,7 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
214 return await cached()
215
216 # Resolve file path for the handler
217 - # Try built-in api folder first, then plugin api folders
217 + # Try built-in and plugin api folders before the user fallback
218 handler_cls: type[ApiHandler] | None = None
219
220 # Check built-in python/api/<path>.py
@@ -239,6 +239,15 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
239 if classes:
240 handler_cls = classes[0]
241
242 + # Check user api/<path>.py
243 + if handler_cls is None:
244 + user_api_dir = files.get_abs_path(files.USER_DIR, files.API_DIR)
245 + user_file = files.get_abs_path(user_api_dir, f"{path}.py")
246 + if files.is_in_dir(user_file, user_api_dir) and files.exists(user_file):
247 + classes = load_classes_from_file(user_file, ApiHandler)
248 + if classes:
249 + handler_cls = classes[0]
250 +
251 if handler_cls is None:
252 return Response(f"API endpoint not found: {path}", 404)
253
helpers/api.py.dox.md
+1
@@ -45,6 +45,7 @@
45 ## Key Concepts
46
47 - Important called helpers/classes observed in the source: `wraps`, `app.add_url_rule`, `watchdog.add_watchdog`, `cls.requires_auth`, `_use_context`, `login.get_credentials_hash`, `files.get_abs_path`, `handler_cls.requires_csrf`, `handler_cls.requires_api_key`, `handler_cls.requires_auth`, `handler_cls.requires_loopback`, `cache.add`, `PrintStyle.debug`, `cache.clear`, `get_settings`, `f`, `is_loopback_address`, `Response`, `redirect`, `files.is_in_dir`.
48 +- HTTP handlers retain built-in `api/` and explicit plugin API precedence, then fall back to standalone `usr/api/`; built-in and user roots are containment-checked, and every loaded handler keeps its declared authentication, CSRF, API-key, loopback, and method gates.
49 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
50
51 ## Work Guidance
helpers/ui_server.py
+19 -3
@@ -205,6 +205,12 @@ class UiServerRuntime:
205 handlers.serve_extension_asset,
206 methods=["GET"],
207 )
208 + self.webapp.add_url_rule(
209 + "/usr/extensions/webui/<path:asset_path>",
210 + "serve_user_extension_asset",
211 + handlers.serve_user_extension_asset,
212 + methods=["GET"],
213 + )
214 self._routes_registered = True
215
216 def register_transport_handlers(self) -> None:
@@ -403,9 +409,19 @@ class UiRouteHandlers:
409
410 @requires_auth
411 async def serve_extension_asset(self, asset_path):
406 - exts = files.get_abs_path("extensions/webui")
407 - path = files.get_abs_path(exts, asset_path)
408 - if not files.is_in_dir(path, exts):
412 + return self._serve_extension_asset(
413 + files.get_abs_path("extensions/webui"), asset_path
414 + )
415 +
416 + @requires_auth
417 + async def serve_user_extension_asset(self, asset_path):
418 + return self._serve_extension_asset(
419 + files.get_abs_path(files.USER_DIR, "extensions/webui"), asset_path
420 + )
421 +
422 + def _serve_extension_asset(self, extension_dir, asset_path):
423 + path = files.get_abs_path(extension_dir, asset_path)
424 + if not files.is_in_dir(path, extension_dir):
425 return Response("Access denied", 403)
426 return send_file(path)
427
helpers/ui_server.py.dox.md
+2
@@ -28,6 +28,7 @@
28 - `async serve_builtin_plugin_asset(self, plugin_name, asset_path)`
29 - `async serve_plugin_asset(self, plugin_name, asset_path)`
30 - `async serve_extension_asset(self, asset_path)`
31 + - `async serve_user_extension_asset(self, asset_path)`
32 - Top-level functions:
33 - `_positive_int_env(name: str, default: int) -> int`
34 - `configure_process_environment() -> None`
@@ -45,6 +46,7 @@
46
47 - Important called helpers/classes observed in the source: `logging.getLogger.setLevel`, `Localization.get.apply_process_timezone`, `_positive_int_env`, `field`, `Flask`, `threading.RLock`, `socketio.AsyncServer`, `WsManager`, `set_shared_ws_manager`, `cls`, `server_runtime.refresh_runtime_settings`, `settings_helper.get_settings`, `settings_helper.set_runtime_settings_snapshot`, `self.ws_manager.set_server_restart_broadcast`, `UiRouteHandlers`, `self.webapp.add_url_rule`, `register_api_route`, `register_ws_namespace`, `files.read_file`, `render_template_string`, `session.pop`.
48 - `serve_index()` bootstraps the normalized UI control visibility map, timezone and time-format preferences, and the complete enabled WebUI extension manifest so startup extension discovery requires no per-surface API requests.
49 +- Authenticated extension asset routes serve root-contained files from both `extensions/webui/` and `usr/extensions/webui/`, matching the URLs emitted by the WebUI extension manifest.
50 - The authenticated `/` route uses `serve_splash()` to return the no-store, self-contained bootstrap document. The authenticated extensionless `/ui/index` route renders the existing index and runtime/user placeholders for the splash to install into the current document without navigation; `/index.html` remains a direct fallback for the same rendering path. The authenticated `/safe` route first returns a no-store, self-contained document that unregisters all origin service workers, then renders the existing index through `serve_index()` when its internal `__direct=1` marker is present; it never initializes the asset bundle or a worker. The authenticated `serve_ui_asset_bundle()` endpoint passes the application entry URL to the generic recursive bundler and supports gzip transfer and payload-specific ETag revalidation while component, extension, and Alpine lifecycles remain unchanged.
51 - The Starlette HTTP branch applies negotiated gzip to responses of at least 1 KiB at compression level 6 while preserving already encoded responses; Socket.IO remains outside that middleware branch.
52 - Keep request/response, tool, or helper semantics documented here at the same time as source changes.
tests/test_user_routes.py new
+165
@@ -0,0 +1,165 @@
1 +import threading
2 +
3 +from flask import Flask
4 +
5 +from helpers import cache, files, login, plugins, subagents
6 +from helpers.api import CACHE_AREA, register_api_route
7 +from helpers.extension import get_webui_extension_manifest
8 +from helpers.ui_server import UiServerRuntime
9 +
10 +
11 +WEBUI_MANIFEST_CACHE_AREA = "webui_extension_manifest(extensions)(plugins)"
12 +
13 +
14 +def _new_app(name: str) -> Flask:
15 + app = Flask(name, static_folder=None)
16 + app.secret_key = "test-secret"
17 + return app
18 +
19 +
20 +def _api_handler_source(source: str) -> str:
21 + return f"""from helpers.api import ApiHandler
22 +
23 +
24 +class Handler(ApiHandler):
25 + @classmethod
26 + def get_methods(cls):
27 + return ["GET"]
28 +
29 + async def process(self, input, request):
30 + return {{"source": {source!r}}}
31 +"""
32 +
33 +
34 +def test_http_dispatches_contained_user_api_handler(tmp_path, monkeypatch) -> None:
35 + monkeypatch.setattr(files, "_base_dir", str(tmp_path))
36 + user_api_dir = tmp_path / "usr" / "api"
37 + user_api_dir.mkdir(parents=True)
38 + handler_source = _api_handler_source("user")
39 + (user_api_dir / "ping.py").write_text(handler_source, encoding="utf-8")
40 + (tmp_path / "usr" / "escaped.py").write_text(
41 + handler_source, encoding="utf-8"
42 + )
43 + monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
44 +
45 + cache.clear(CACHE_AREA)
46 + try:
47 + app = _new_app("test_user_api_route")
48 + app.add_url_rule("/", "serve_index", lambda: "")
49 + app.add_url_rule("/login", "login_handler", lambda: "")
50 + register_api_route(app, threading.RLock())
51 + client = app.test_client()
52 +
53 + assert client.get("/api/ping").status_code == 302
54 + with client.session_transaction() as session:
55 + session["authentication"] = "credential-hash"
56 + session["csrf_token"] = "csrf-token"
57 + response = client.get("/api/ping", headers={"X-CSRF-Token": "csrf-token"})
58 + assert response.status_code == 200
59 + assert response.get_json() == {"source": "user"}
60 +
61 + with app.test_request_context("/api/../escaped", method="GET"):
62 + denied = app.ensure_sync(app.view_functions["api_dispatch"])("../escaped")
63 + assert denied.status_code == 404
64 + finally:
65 + cache.clear(CACHE_AREA)
66 +
67 +
68 +def test_existing_api_sources_keep_precedence(tmp_path, monkeypatch) -> None:
69 + monkeypatch.setattr(files, "_base_dir", str(tmp_path))
70 + monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
71 +
72 + builtin_file = tmp_path / "api" / "shared.py"
73 + builtin_file.parent.mkdir(parents=True)
74 + builtin_file.write_text(_api_handler_source("builtin"), encoding="utf-8")
75 +
76 + user_api_dir = tmp_path / "usr" / "api"
77 + (user_api_dir / "plugins" / "demo").mkdir(parents=True)
78 + (user_api_dir / "shared.py").write_text(
79 + _api_handler_source("user"), encoding="utf-8"
80 + )
81 + (user_api_dir / "plugins" / "demo" / "ping.py").write_text(
82 + _api_handler_source("user"), encoding="utf-8"
83 + )
84 +
85 + plugin_dir = tmp_path / "plugins" / "demo"
86 + (plugin_dir / "api").mkdir(parents=True)
87 + (plugin_dir / "api" / "ping.py").write_text(
88 + _api_handler_source("plugin"), encoding="utf-8"
89 + )
90 + monkeypatch.setattr(
91 + plugins,
92 + "find_plugin_dir",
93 + lambda name: str(plugin_dir) if name == "demo" else None,
94 + )
95 +
96 + cache.clear(CACHE_AREA)
97 + try:
98 + app = _new_app("test_existing_api_precedence")
99 + app.add_url_rule("/", "serve_index", lambda: "")
100 + app.add_url_rule("/login", "login_handler", lambda: "")
101 + register_api_route(app, threading.RLock())
102 + client = app.test_client()
103 + with client.session_transaction() as session:
104 + session["authentication"] = "credential-hash"
105 + session["csrf_token"] = "csrf-token"
106 + headers = {"X-CSRF-Token": "csrf-token"}
107 +
108 + assert client.get("/api/shared", headers=headers).get_json() == {
109 + "source": "builtin"
110 + }
111 + assert client.get("/api/plugins/demo/ping", headers=headers).get_json() == {
112 + "source": "plugin"
113 + }
114 + finally:
115 + cache.clear(CACHE_AREA)
116 +
117 +
118 +def test_user_webui_manifest_asset_is_served_from_its_declared_url(
119 + tmp_path, monkeypatch
120 +) -> None:
121 + monkeypatch.setattr(files, "_base_dir", str(tmp_path))
122 + extension_root = tmp_path / "usr" / "extensions" / "webui"
123 + extension_file = extension_root / "route-probe" / "probe.js"
124 + extension_file.parent.mkdir(parents=True)
125 + extension_file.write_text("export default true;", encoding="utf-8")
126 + builtin_extension_file = (
127 + tmp_path / "extensions" / "webui" / "route-probe" / "probe.js"
128 + )
129 + builtin_extension_file.parent.mkdir(parents=True)
130 + builtin_extension_file.write_text("export default false;", encoding="utf-8")
131 + (extension_root.parent / "escaped.js").write_text("secret", encoding="utf-8")
132 + monkeypatch.setattr(subagents, "get_paths", lambda *_args, **_kwargs: [str(extension_root)])
133 +
134 + cache.clear(WEBUI_MANIFEST_CACHE_AREA)
135 + try:
136 + manifest = get_webui_extension_manifest(agent=None)
137 + asset_url = manifest["js"]["route-probe"][0]
138 + assert asset_url == "/usr/extensions/webui/route-probe/probe.js"
139 +
140 + app = _new_app("test_user_webui_extension_route")
141 + runtime = UiServerRuntime(
142 + app, None, None, threading.RLock(), {} # type: ignore[arg-type]
143 + )
144 + runtime.register_http_routes()
145 +
146 + client = app.test_client()
147 + monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
148 + assert client.get(asset_url).status_code == 302
149 +
150 + monkeypatch.setattr(login, "get_credentials_hash", lambda: None)
151 + builtin_response = client.get("/extensions/webui/route-probe/probe.js")
152 + assert builtin_response.status_code == 200
153 + assert builtin_response.get_data(as_text=True) == "export default false;"
154 +
155 + response = client.get(asset_url)
156 + assert response.status_code == 200
157 + assert response.get_data(as_text=True) == "export default true;"
158 +
159 + with app.test_request_context("/usr/extensions/webui/../escaped.js"):
160 + denied = app.ensure_sync(
161 + app.view_functions["serve_user_extension_asset"]
162 + )("../escaped.js")
163 + assert denied.status_code == 403
164 + finally:
165 + cache.clear(WEBUI_MANIFEST_CACHE_AREA)