mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-08-21 06:15:11 +00:00
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.
This commit is contained in:
parent
a304c7665f
commit
81fcc24364
5 changed files with 197 additions and 4 deletions
|
|
@ -214,7 +214,7 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
|
|||
return await cached()
|
||||
|
||||
# Resolve file path for the handler
|
||||
# Try built-in api folder first, then plugin api folders
|
||||
# Try built-in and plugin api folders before the user fallback
|
||||
handler_cls: type[ApiHandler] | None = None
|
||||
|
||||
# Check built-in python/api/<path>.py
|
||||
|
|
@ -239,6 +239,15 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
|
|||
if classes:
|
||||
handler_cls = classes[0]
|
||||
|
||||
# Check user api/<path>.py
|
||||
if handler_cls is None:
|
||||
user_api_dir = files.get_abs_path(files.USER_DIR, files.API_DIR)
|
||||
user_file = files.get_abs_path(user_api_dir, f"{path}.py")
|
||||
if files.is_in_dir(user_file, user_api_dir) and files.exists(user_file):
|
||||
classes = load_classes_from_file(user_file, ApiHandler)
|
||||
if classes:
|
||||
handler_cls = classes[0]
|
||||
|
||||
if handler_cls is None:
|
||||
return Response(f"API endpoint not found: {path}", 404)
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
## Key Concepts
|
||||
|
||||
- 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`.
|
||||
- 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.
|
||||
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
|
||||
|
||||
## Work Guidance
|
||||
|
|
|
|||
|
|
@ -205,6 +205,12 @@ class UiServerRuntime:
|
|||
handlers.serve_extension_asset,
|
||||
methods=["GET"],
|
||||
)
|
||||
self.webapp.add_url_rule(
|
||||
"/usr/extensions/webui/<path:asset_path>",
|
||||
"serve_user_extension_asset",
|
||||
handlers.serve_user_extension_asset,
|
||||
methods=["GET"],
|
||||
)
|
||||
self._routes_registered = True
|
||||
|
||||
def register_transport_handlers(self) -> None:
|
||||
|
|
@ -403,9 +409,19 @@ class UiRouteHandlers:
|
|||
|
||||
@requires_auth
|
||||
async def serve_extension_asset(self, asset_path):
|
||||
exts = files.get_abs_path("extensions/webui")
|
||||
path = files.get_abs_path(exts, asset_path)
|
||||
if not files.is_in_dir(path, exts):
|
||||
return self._serve_extension_asset(
|
||||
files.get_abs_path("extensions/webui"), asset_path
|
||||
)
|
||||
|
||||
@requires_auth
|
||||
async def serve_user_extension_asset(self, asset_path):
|
||||
return self._serve_extension_asset(
|
||||
files.get_abs_path(files.USER_DIR, "extensions/webui"), asset_path
|
||||
)
|
||||
|
||||
def _serve_extension_asset(self, extension_dir, asset_path):
|
||||
path = files.get_abs_path(extension_dir, asset_path)
|
||||
if not files.is_in_dir(path, extension_dir):
|
||||
return Response("Access denied", 403)
|
||||
return send_file(path)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
- `async serve_builtin_plugin_asset(self, plugin_name, asset_path)`
|
||||
- `async serve_plugin_asset(self, plugin_name, asset_path)`
|
||||
- `async serve_extension_asset(self, asset_path)`
|
||||
- `async serve_user_extension_asset(self, asset_path)`
|
||||
- Top-level functions:
|
||||
- `_positive_int_env(name: str, default: int) -> int`
|
||||
- `configure_process_environment() -> None`
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
|
||||
- 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`.
|
||||
- `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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
|
||||
|
|
|
|||
165
tests/test_user_routes.py
Normal file
165
tests/test_user_routes.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import threading
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from helpers import cache, files, login, plugins, subagents
|
||||
from helpers.api import CACHE_AREA, register_api_route
|
||||
from helpers.extension import get_webui_extension_manifest
|
||||
from helpers.ui_server import UiServerRuntime
|
||||
|
||||
|
||||
WEBUI_MANIFEST_CACHE_AREA = "webui_extension_manifest(extensions)(plugins)"
|
||||
|
||||
|
||||
def _new_app(name: str) -> Flask:
|
||||
app = Flask(name, static_folder=None)
|
||||
app.secret_key = "test-secret"
|
||||
return app
|
||||
|
||||
|
||||
def _api_handler_source(source: str) -> str:
|
||||
return f"""from helpers.api import ApiHandler
|
||||
|
||||
|
||||
class Handler(ApiHandler):
|
||||
@classmethod
|
||||
def get_methods(cls):
|
||||
return ["GET"]
|
||||
|
||||
async def process(self, input, request):
|
||||
return {{"source": {source!r}}}
|
||||
"""
|
||||
|
||||
|
||||
def test_http_dispatches_contained_user_api_handler(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
|
||||
user_api_dir = tmp_path / "usr" / "api"
|
||||
user_api_dir.mkdir(parents=True)
|
||||
handler_source = _api_handler_source("user")
|
||||
(user_api_dir / "ping.py").write_text(handler_source, encoding="utf-8")
|
||||
(tmp_path / "usr" / "escaped.py").write_text(
|
||||
handler_source, encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
|
||||
|
||||
cache.clear(CACHE_AREA)
|
||||
try:
|
||||
app = _new_app("test_user_api_route")
|
||||
app.add_url_rule("/", "serve_index", lambda: "")
|
||||
app.add_url_rule("/login", "login_handler", lambda: "")
|
||||
register_api_route(app, threading.RLock())
|
||||
client = app.test_client()
|
||||
|
||||
assert client.get("/api/ping").status_code == 302
|
||||
with client.session_transaction() as session:
|
||||
session["authentication"] = "credential-hash"
|
||||
session["csrf_token"] = "csrf-token"
|
||||
response = client.get("/api/ping", headers={"X-CSRF-Token": "csrf-token"})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"source": "user"}
|
||||
|
||||
with app.test_request_context("/api/../escaped", method="GET"):
|
||||
denied = app.ensure_sync(app.view_functions["api_dispatch"])("../escaped")
|
||||
assert denied.status_code == 404
|
||||
finally:
|
||||
cache.clear(CACHE_AREA)
|
||||
|
||||
|
||||
def test_existing_api_sources_keep_precedence(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
|
||||
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
|
||||
|
||||
builtin_file = tmp_path / "api" / "shared.py"
|
||||
builtin_file.parent.mkdir(parents=True)
|
||||
builtin_file.write_text(_api_handler_source("builtin"), encoding="utf-8")
|
||||
|
||||
user_api_dir = tmp_path / "usr" / "api"
|
||||
(user_api_dir / "plugins" / "demo").mkdir(parents=True)
|
||||
(user_api_dir / "shared.py").write_text(
|
||||
_api_handler_source("user"), encoding="utf-8"
|
||||
)
|
||||
(user_api_dir / "plugins" / "demo" / "ping.py").write_text(
|
||||
_api_handler_source("user"), encoding="utf-8"
|
||||
)
|
||||
|
||||
plugin_dir = tmp_path / "plugins" / "demo"
|
||||
(plugin_dir / "api").mkdir(parents=True)
|
||||
(plugin_dir / "api" / "ping.py").write_text(
|
||||
_api_handler_source("plugin"), encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins,
|
||||
"find_plugin_dir",
|
||||
lambda name: str(plugin_dir) if name == "demo" else None,
|
||||
)
|
||||
|
||||
cache.clear(CACHE_AREA)
|
||||
try:
|
||||
app = _new_app("test_existing_api_precedence")
|
||||
app.add_url_rule("/", "serve_index", lambda: "")
|
||||
app.add_url_rule("/login", "login_handler", lambda: "")
|
||||
register_api_route(app, threading.RLock())
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as session:
|
||||
session["authentication"] = "credential-hash"
|
||||
session["csrf_token"] = "csrf-token"
|
||||
headers = {"X-CSRF-Token": "csrf-token"}
|
||||
|
||||
assert client.get("/api/shared", headers=headers).get_json() == {
|
||||
"source": "builtin"
|
||||
}
|
||||
assert client.get("/api/plugins/demo/ping", headers=headers).get_json() == {
|
||||
"source": "plugin"
|
||||
}
|
||||
finally:
|
||||
cache.clear(CACHE_AREA)
|
||||
|
||||
|
||||
def test_user_webui_manifest_asset_is_served_from_its_declared_url(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
|
||||
extension_root = tmp_path / "usr" / "extensions" / "webui"
|
||||
extension_file = extension_root / "route-probe" / "probe.js"
|
||||
extension_file.parent.mkdir(parents=True)
|
||||
extension_file.write_text("export default true;", encoding="utf-8")
|
||||
builtin_extension_file = (
|
||||
tmp_path / "extensions" / "webui" / "route-probe" / "probe.js"
|
||||
)
|
||||
builtin_extension_file.parent.mkdir(parents=True)
|
||||
builtin_extension_file.write_text("export default false;", encoding="utf-8")
|
||||
(extension_root.parent / "escaped.js").write_text("secret", encoding="utf-8")
|
||||
monkeypatch.setattr(subagents, "get_paths", lambda *_args, **_kwargs: [str(extension_root)])
|
||||
|
||||
cache.clear(WEBUI_MANIFEST_CACHE_AREA)
|
||||
try:
|
||||
manifest = get_webui_extension_manifest(agent=None)
|
||||
asset_url = manifest["js"]["route-probe"][0]
|
||||
assert asset_url == "/usr/extensions/webui/route-probe/probe.js"
|
||||
|
||||
app = _new_app("test_user_webui_extension_route")
|
||||
runtime = UiServerRuntime(
|
||||
app, None, None, threading.RLock(), {} # type: ignore[arg-type]
|
||||
)
|
||||
runtime.register_http_routes()
|
||||
|
||||
client = app.test_client()
|
||||
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
|
||||
assert client.get(asset_url).status_code == 302
|
||||
|
||||
monkeypatch.setattr(login, "get_credentials_hash", lambda: None)
|
||||
builtin_response = client.get("/extensions/webui/route-probe/probe.js")
|
||||
assert builtin_response.status_code == 200
|
||||
assert builtin_response.get_data(as_text=True) == "export default false;"
|
||||
|
||||
response = client.get(asset_url)
|
||||
assert response.status_code == 200
|
||||
assert response.get_data(as_text=True) == "export default true;"
|
||||
|
||||
with app.test_request_context("/usr/extensions/webui/../escaped.js"):
|
||||
denied = app.ensure_sync(
|
||||
app.view_functions["serve_user_extension_asset"]
|
||||
)("../escaped.js")
|
||||
assert denied.status_code == 403
|
||||
finally:
|
||||
cache.clear(WEBUI_MANIFEST_CACHE_AREA)
|
||||
Loading…
Add table
Add a link
Reference in a new issue