fix(api): align downloads with file browser root
Resolve individual and bulk download paths from the filesystem root so authenticated operators can download every path exposed by the File Browser and editor.\n\nKeep the existing authentication, CSRF, path normalization, and archive behavior, and cover downloads outside the Agent Zero runtime directory with a regression test.
Alessandro committed
Aug 12, 2026 at 04:43 UTC
d541a942b089c3c29d608cd76642d7623dd979de
5 files changed
+30
-5
api/download_work_dir_file.py
+2
-2
@@ -87,8 +87,8 @@ def make_disposition(download_name: str) -> str:
87
88
89
def resolve_download_path(path: str) -> str:
90
- """Resolve a requested download path and keep it within the runtime base dir."""
91
- base_dir = Path(files.get_base_dir()).resolve()
90
+ """Resolve a requested download path from the File Browser root."""
91
+ base_dir = Path("/")
92
candidate = Path(path)
93
94
if candidate.is_absolute():
api/download_work_dir_file.py.dox.md
+3
-1
@@ -17,7 +17,7 @@
17
- Top-level functions:
18
- `stream_file_download(file_source, download_name, chunk_size=...)`: Create a streaming response for file downloads that shows progress in browser.
19
- `make_disposition(download_name: str) -> str`
20
-- `resolve_download_path(path: str) -> str`: Resolve a requested download path and keep it within the runtime base dir.
20
+- `resolve_download_path(path: str) -> str`: Resolve a requested download path from the File Browser root.
21
- `async fetch_file(path)`
22
23
## Runtime Contracts
@@ -27,6 +27,8 @@
27
- `DownloadFile` is an `ApiHandler`.
28
- `DownloadFile` defines `process(...)`.
29
- `DownloadFile` defines `get_methods(...)`.
30
+- The endpoint retains the default authenticated and CSRF-protected browser contract.
31
+- Download paths use `/` as their root, matching the authenticated File Browser and editor filesystem scope.
32
- Observed side-effect areas: filesystem reads, network calls.
33
- Imported dependency areas include: `api`, `base64`, `flask`, `helpers`, `helpers.api`, `io`, `mimetypes`, `os`, `pathlib`, `urllib.parse`.
34
api/download_work_dir_files.py
+2
-2
@@ -8,7 +8,7 @@ import zipfile
8
from flask import Response
9
10
from helpers.api import ApiHandler, Input, Output, Request
11
-from helpers import files, runtime
11
+from helpers import runtime
12
from helpers.localization import Localization
13
from api.download_work_dir_file import fetch_file, stream_file_download
14
@@ -70,7 +70,7 @@ def selected_archive_name(count: int) -> str:
70
71
72
def create_selected_zip(paths: list[str], current_path: str = "") -> str:
73
- base_dir = Path(files.get_base_dir()).resolve()
73
+ base_dir = Path("/")
74
current_dir = resolve_download_path(current_path, base_dir) if current_path else None
75
if current_dir and current_dir.is_file():
76
current_dir = current_dir.parent
api/download_work_dir_files.py.dox.md
+2
@@ -29,6 +29,8 @@
29
- Update this file whenever request payloads, authentication or CSRF requirements, response shapes, route side effects, or WebSocket event contracts change.
30
- `DownloadFiles` is an `ApiHandler`.
31
- `DownloadFiles` defines `process(...)`.
32
+- The endpoint retains the default authenticated and CSRF-protected browser contract.
33
+- Selected paths use `/` as their root, matching the authenticated File Browser and editor filesystem scope.
34
- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion.
35
- Imported dependency areas include: `api.download_work_dir_file`, `base64`, `flask`, `helpers`, `helpers.api`, `helpers.localization`, `io`, `os`, `pathlib`, `tempfile`, `zipfile`.
36
tests/test_download_work_dir_paths.py
new
+21
@@ -0,0 +1,21 @@
1
+from pathlib import Path
2
+import zipfile
3
+
4
+from api.download_work_dir_file import resolve_download_path
5
+from api.download_work_dir_files import create_selected_zip
6
+
7
+
8
+def test_download_resolvers_match_file_browser_root(tmp_path: Path) -> None:
9
+ file_path = tmp_path / "outside-a0.txt"
10
+ file_path.write_text("downloadable", encoding="utf-8")
11
+
12
+ assert resolve_download_path(str(file_path)) == str(file_path.resolve())
13
+
14
+ archive_path = Path(create_selected_zip([str(file_path)], "/"))
15
+ try:
16
+ with zipfile.ZipFile(archive_path) as archive:
17
+ name = str(file_path).lstrip("/")
18
+ assert archive.namelist() == [name]
19
+ assert archive.read(name) == b"downloadable"
20
+ finally:
21
+ archive_path.unlink(missing_ok=True)