Add Editor downloads and archive extraction
Add Download to Editor file actions and safe File Browser extraction for ZIP, TAR, RAR, 7z, and compressed archives. Include archive safety checks, focused regressions, and 7zip in the base image.
Alessandro committed
Jul 16, 2026 at 15:14 UTC
98589c6357fd34f1ffafcf51b9796b021ee1923b
11 files changed
+295
-4
api/extract_work_dir_archive.py
new
+154
@@ -0,0 +1,154 @@
1
+from __future__ import annotations
2
+
3
+from pathlib import Path
4
+import shutil
5
+import stat
6
+import subprocess
7
+import tarfile
8
+import zipfile
9
+
10
+from helpers import extension, files, runtime
11
+from helpers.api import ApiHandler, Input, Output, Request
12
+from api import get_work_dir_files
13
+
14
+
15
+ARCHIVE_SUFFIXES = (
16
+ ".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar", ".tgz", ".tbz", ".tbz2", ".txz",
17
+ ".zip", ".rar", ".7z", ".gz", ".bz2", ".xz", ".zst",
18
+)
19
+TAR_SUFFIXES = (".tar.gz", ".tar.bz2", ".tar.xz", ".tar", ".tgz", ".tbz", ".tbz2", ".txz")
20
+
21
+
22
+class ExtractWorkDirArchive(ApiHandler):
23
+ async def process(self, input: Input, request: Request) -> Output:
24
+ path = str(input.get("path") or "").strip()
25
+ if not path:
26
+ return {"error": "Archive path is required"}
27
+ if not path.startswith("/"):
28
+ path = f"/{path}"
29
+
30
+ try:
31
+ extracted_path = await runtime.call_development_function(extract_archive, path)
32
+ except (OSError, ValueError) as exc:
33
+ return {"error": str(exc)}
34
+
35
+ current_path = str(input.get("currentPath") or "")
36
+ await extension.call_extensions_async(
37
+ "workdir_file_mutation_after",
38
+ agent=None,
39
+ data={
40
+ "action": "extract",
41
+ "path": extracted_path,
42
+ "paths": [path, extracted_path],
43
+ "current_path": current_path,
44
+ },
45
+ )
46
+ listing = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
47
+ return {"data": listing, "extracted_path": extracted_path}
48
+
49
+
50
+def extract_archive(path: str) -> str:
51
+ source = resolve_archive_path(path)
52
+ target = create_target_directory(source)
53
+ try:
54
+ kind = archive_kind(source)
55
+ if kind == "zip":
56
+ extract_zip(source, target)
57
+ elif kind == "tar":
58
+ extract_tar(source, target)
59
+ else:
60
+ extract_with_7zip(source, target)
61
+ except Exception:
62
+ shutil.rmtree(target, ignore_errors=True)
63
+ raise
64
+ return str(target)
65
+
66
+
67
+def resolve_archive_path(path: str) -> Path:
68
+ base = Path(files.get_base_dir()).resolve()
69
+ candidate = Path(path)
70
+ resolved = candidate.resolve() if candidate.is_absolute() else (base / candidate).resolve()
71
+ try:
72
+ resolved.relative_to(base)
73
+ except ValueError as exc:
74
+ raise ValueError("Invalid archive path") from exc
75
+ if not resolved.is_file():
76
+ raise ValueError("Archive file was not found")
77
+ return resolved
78
+
79
+
80
+def archive_kind(path: Path) -> str:
81
+ name = path.name.lower()
82
+ if name.endswith(".zip"):
83
+ return "zip"
84
+ if name.endswith(TAR_SUFFIXES):
85
+ return "tar"
86
+ if name.endswith(ARCHIVE_SUFFIXES):
87
+ return "7zip"
88
+ raise ValueError("Unsupported archive format")
89
+
90
+
91
+def create_target_directory(source: Path) -> Path:
92
+ name = source.name
93
+ for suffix in ARCHIVE_SUFFIXES:
94
+ if name.lower().endswith(suffix):
95
+ name = name[:-len(suffix)]
96
+ break
97
+ name = name or "extracted"
98
+ target = source.parent / name
99
+ index = 2
100
+ while target.exists():
101
+ target = source.parent / f"{name}-{index}"
102
+ index += 1
103
+ target.mkdir()
104
+ return target
105
+
106
+
107
+def safe_member_path(target: Path, name: str) -> Path:
108
+ if not name or name.startswith(("/", "\\")) or "\\" in name or ".." in Path(name).parts:
109
+ raise ValueError("Archive contains an unsafe path")
110
+ destination = (target / name).resolve(strict=False)
111
+ try:
112
+ destination.relative_to(target.resolve())
113
+ except ValueError as exc:
114
+ raise ValueError("Archive contains an unsafe path") from exc
115
+ return destination
116
+
117
+
118
+def extract_zip(source: Path, target: Path) -> None:
119
+ with zipfile.ZipFile(source) as archive:
120
+ for member in archive.infolist():
121
+ safe_member_path(target, member.filename)
122
+ if stat.S_ISLNK(member.external_attr >> 16):
123
+ raise ValueError("Archive contains a symbolic link")
124
+ archive.extractall(target)
125
+
126
+
127
+def extract_tar(source: Path, target: Path) -> None:
128
+ with tarfile.open(source, "r:*") as archive:
129
+ for member in archive.getmembers():
130
+ safe_member_path(target, member.name)
131
+ if member.issym() or member.islnk() or member.isdev():
132
+ raise ValueError("Archive contains a symbolic link or device")
133
+ archive.extractall(target, filter="data")
134
+
135
+
136
+def extract_with_7zip(source: Path, target: Path) -> None:
137
+ binary = shutil.which("7z") or shutil.which("7zz")
138
+ if not binary:
139
+ raise ValueError("This archive format requires 7-Zip in the runtime image")
140
+ listing = subprocess.run(
141
+ [binary, "l", "-slt", str(source)],
142
+ check=True,
143
+ capture_output=True,
144
+ text=True,
145
+ ).stdout
146
+ marker = "----------"
147
+ if marker not in listing:
148
+ raise ValueError("Could not inspect archive safely")
149
+ for line in listing.split(marker, 1)[1].splitlines():
150
+ if line.startswith("Path = "):
151
+ safe_member_path(target, line.removeprefix("Path = "))
152
+ subprocess.run([binary, "x", "-y", f"-o{target}", str(source)], check=True, capture_output=True)
153
+ if any(path.is_symlink() for path in target.rglob("*")):
154
+ raise ValueError("Archive contains a symbolic link")
api/extract_work_dir_archive.py.dox.md
new
+21
@@ -0,0 +1,21 @@
1
+# extract_work_dir_archive.py DOX
2
+
3
+## Purpose
4
+
5
+- Own the authenticated, CSRF-protected archive extraction endpoint for File Browser.
6
+- Extract supported archives into a new sibling folder without overwriting existing content.
7
+
8
+## Ownership
9
+
10
+- `ExtractWorkDirArchive` receives a file `path` and optional listing `currentPath`.
11
+- `extract_archive` validates the source, creates the destination, and removes partial output on failure.
12
+
13
+## Runtime Contracts
14
+
15
+- ZIP and TAR-family archives use the Python standard library; RAR, 7z, and single-file compression formats use the image `7zip` binary.
16
+- Archive members must remain below the new destination and cannot be links or devices.
17
+- Successful extraction emits `workdir_file_mutation_after` and returns a refreshed file listing plus `extracted_path`.
18
+
19
+## Verification
20
+
21
+- Run `pytest tests/test_file_browser_archives.py tests/test_file_browser_navigation.py`.
docker/base/fs/ins/install_base_packages1.sh
+1
-1
@@ -6,6 +6,6 @@ echo "====================BASE PACKAGES1 START===================="
6
apt-get update && apt-get upgrade -y
7
8
apt-get install -y --no-install-recommends \
9
- sudo curl wget git cron unzip
9
+ sudo curl wget git cron unzip 7zip
10
11
echo "====================BASE PACKAGES1 END===================="
plugins/_editor/AGENTS.md
+1
@@ -18,6 +18,7 @@
18
- Do not expose unsaved content or local paths beyond intended chat/context surfaces.
19
- Keep the floating Editor modal on the shared surface modal chrome so the header remains draggable while existing Focus mode continues to work.
20
- Keep Editor Open wired through the File Browser text picker so users can open one or more Markdown or plain text files with an obvious confirmation action.
21
+- Keep Download in the Editor file-actions menu and save dirty text before downloading it.
22
- Keep Save As distinct from Rename: Save As writes the current editor text to a chosen `.md` or `.txt` path and retargets the active session without removing the original file.
23
- Preserve source chat context ids when opening Markdown files from tool-result canvas handoffs.
24
plugins/_editor/webui/editor-panel.html
+4
@@ -174,6 +174,10 @@
174
<span class="material-symbols-outlined">more_vert</span>
175
</button>
176
<div class="editor-new-menu editor-file-menu" role="menu" x-show="open" @click.stop>
177
+ <button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.saving" @click="open = false; $store.editor.downloadActiveFile()">
178
+ <span class="material-symbols-outlined" aria-hidden="true">download</span>
179
+ <span>Download</span>
180
+ </button>
181
<button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.saving" @click="open = false; $store.editor.renameActiveFile()">
182
<span class="material-symbols-outlined" aria-hidden="true">edit</span>
183
<span>Rename</span>
plugins/_editor/webui/editor-store.js
+8
@@ -1303,6 +1303,14 @@ const model = {
1303
}
1304
},
1305
1306
+ async downloadActiveFile() {
1307
+ if (!this.session || this.saving || !this.isTextDocument()) return;
1308
+ if (this.dirty) await this.save();
1309
+ if (this.dirty) return;
1310
+ const path = this.session.path || this.session.document?.path;
1311
+ if (path) fileBrowserStore.downloadFile({ path, name: this.tabTitle() });
1312
+ },
1313
+
1314
async saveAs() {
1315
if (!this.session || this.saving || !this.isTextDocument()) return;
1316
this.applyPreviewEdit({ silent: true });
tests/test_file_browser_archives.py
new
+53
@@ -0,0 +1,53 @@
1
+from pathlib import Path
2
+import sys
3
+import tarfile
4
+import zipfile
5
+
6
+import pytest
7
+
8
+
9
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
10
+if str(PROJECT_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(PROJECT_ROOT))
12
+
13
+
14
+from api.extract_work_dir_archive import extract_archive
15
+from helpers import files
16
+
17
+
18
+def test_extract_archive_creates_unique_zip_destination(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
19
+ monkeypatch.setattr(files, "_base_dir", str(tmp_path))
20
+ archive = tmp_path / "notes.zip"
21
+ with zipfile.ZipFile(archive, "w") as bundle:
22
+ bundle.writestr("nested/note.txt", "hello")
23
+
24
+ first = Path(extract_archive(str(archive)))
25
+ second = Path(extract_archive(str(archive)))
26
+
27
+ assert (first / "nested" / "note.txt").read_text() == "hello"
28
+ assert second.name == "notes-2"
29
+
30
+
31
+def test_extract_archive_handles_tar_gz(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
32
+ monkeypatch.setattr(files, "_base_dir", str(tmp_path))
33
+ source = tmp_path / "readme.txt"
34
+ source.write_text("hello")
35
+ archive = tmp_path / "bundle.tar.gz"
36
+ with tarfile.open(archive, "w:gz") as bundle:
37
+ bundle.add(source, arcname="readme.txt")
38
+
39
+ destination = Path(extract_archive(str(archive)))
40
+
41
+ assert (destination / "readme.txt").read_text() == "hello"
42
+
43
+
44
+def test_extract_archive_rejects_zip_path_traversal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
45
+ monkeypatch.setattr(files, "_base_dir", str(tmp_path))
46
+ archive = tmp_path / "unsafe.zip"
47
+ with zipfile.ZipFile(archive, "w") as bundle:
48
+ bundle.writestr("../escape.txt", "nope")
49
+
50
+ with pytest.raises(ValueError, match="unsafe path"):
51
+ extract_archive(str(archive))
52
+
53
+ assert not (tmp_path / "unsafe").exists()
tests/test_file_browser_navigation.py
+17
@@ -128,6 +128,23 @@ def test_file_browser_editor_picker_modes_have_primary_footer_actions() -> None:
128
assert 'x-show="$store.fileBrowser.canOpenInActionMenu(file)"' in html
129
130
131
+def test_file_browser_extract_and_editor_download_actions() -> None:
132
+ browser_html = read("webui", "components", "modals", "file-browser", "file-browser.html")
133
+ browser_store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
134
+ editor_html = read("plugins", "_editor", "webui", "editor-panel.html")
135
+ editor_store = read("plugins", "_editor", "webui", "editor-store.js")
136
+
137
+ assert 'x-show="!file.is_dir && $store.fileBrowser.isArchive(file.name)"' in browser_html
138
+ assert '$store.fileBrowser.extractArchive(file)' in browser_html
139
+ assert "ARCHIVE_SUFFIXES" in browser_store
140
+ assert 'fetchApi("/extract_work_dir_archive"' in browser_store
141
+ assert "async extractArchive(file = {})" in browser_store
142
+ assert "<span>Extract</span>" in browser_html
143
+ assert "downloadActiveFile()" in editor_store
144
+ assert "$store.editor.downloadActiveFile()" in editor_html
145
+ assert "<span>Download</span>" in editor_html
146
+
147
+
148
def test_file_browser_dropdown_escapes_scroll_container_and_header_is_opaque() -> None:
149
html = read("webui", "components", "modals", "file-browser", "file-browser.html")
150
store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
webui/components/modals/file-browser/AGENTS.md
+1
@@ -19,6 +19,7 @@
19
- Empty mounted startup states must self-heal to the `$WORK_DIR` default instead of rendering a blank path and empty list.
20
- Preserve picker modes for Editor Open and Save As: Editor Open selects one or more Markdown or plain text files with a pinned primary action, and Save As selects the current folder plus a `.md` or `.txt` file name.
21
- Keep the row-level Open in Editor action visible outside the overflow menu for Editor-owned `.md` and `.txt` files.
22
+- Keep Extract available for supported archive files; extraction must create a new sibling folder and reject unsafe member paths and links.
23
- Keep row action menus visible without disabling file-list scrolling; menus may float outside the scroll container but must still close on outside click, Escape, action click, and list scroll.
24
- Keep the file list readable in narrow canvas/modal containers by hiding the Modified date column before sacrificing the Name or Size columns.
25
- Keep New file and New folder controls icon-only across canvas and modal modes while preserving accessible labels.
webui/components/modals/file-browser/file-browser-store.js
+25
-3
@@ -30,6 +30,7 @@ const BROWSER_EXTENSIONS = new Set([
30
"bmp",
31
"ico",
32
]);
33
+const ARCHIVE_SUFFIXES = [".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar", ".tgz", ".tbz", ".tbz2", ".txz", ".zip", ".rar", ".7z", ".gz", ".bz2", ".xz", ".zst"];
34
35
const SURFACE_ACTIONS = {
36
editor: {
@@ -324,9 +325,7 @@ const model = {
325
},
326
327
isArchive(filename) {
327
- const archiveExts = ["zip", "tar", "gz", "rar", "7z"];
328
- const ext = filename.split(".").pop().toLowerCase();
329
- return archiveExts.includes(ext);
328
+ return ARCHIVE_SUFFIXES.some((suffix) => String(filename || "").toLowerCase().endsWith(suffix));
329
},
330
331
saveScrollPosition() {
@@ -1118,6 +1117,29 @@ const model = {
1117
},
1118
1119
// --- File actions --------------------------------------------------------
1120
+ async extractArchive(file = {}) {
1121
+ if (!file?.path || !this.isArchive(file.name) || this.isBulkBusy) return;
1122
+ this.isBulkBusy = true;
1123
+ this.closeDropdown();
1124
+ try {
1125
+ const resp = await fetchApi("/extract_work_dir_archive", {
1126
+ method: "POST",
1127
+ headers: { "Content-Type": "application/json" },
1128
+ body: JSON.stringify({ path: file.path, currentPath: this.browser.currentPath }),
1129
+ });
1130
+ const data = await resp.json().catch(() => ({}));
1131
+ if (!resp.ok || data.error) throw new Error(data.error || "Archive extraction failed");
1132
+ this.browser.entries = this.decorateEntries(data.data?.entries || []);
1133
+ this.browser.currentPath = data.data?.current_path || this.browser.currentPath;
1134
+ this.browser.parentPath = data.data?.parent_path || this.browser.parentPath;
1135
+ window.toastFrontendSuccess(`Extracted to ${data.extracted_path || "a new folder"}`, "Archive Extracted");
1136
+ } catch (error) {
1137
+ window.toastFrontendError(error?.message || "Archive extraction failed", "Archive Extract Error");
1138
+ } finally {
1139
+ this.isBulkBusy = false;
1140
+ }
1141
+ },
1142
+
1143
async deleteFile(file) {
1144
try {
1145
const resp = await fetchApi("/delete_work_dir_file", {
webui/components/modals/file-browser/file-browser.html
+10
@@ -300,6 +300,16 @@
300
<span>Download ZIP</span>
301
</button>
302
303
+ <button
304
+ type="button"
305
+ class="dropdown-item"
306
+ x-show="!file.is_dir && $store.fileBrowser.isArchive(file.name)"
307
+ @click="$store.fileBrowser.extractArchive(file)"
308
+ >
309
+ <span class="material-symbols-outlined">unarchive</span>
310
+ <span>Extract</span>
311
+ </button>
312
+
313
<button
314
type="button"
315
class="dropdown-item"