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)