feat(workspace): allow browser-only escape-target navigation (#4582)

# Conflicts:
#	static/workspace.js
This commit is contained in:
Rod Boev committed 2026-06-23 02:52:44 -04:00
1 parent fa3d612a4e
commit 0f80113c12
9 files changed
+954 -125

No files matched your search

+145
View File
@@ -6613,11 +6613,15 @@ from api.workspace import (
get_last_workspace,
set_last_workspace,
git_info_for_workspace,
authorize_escape_target,
list_dir,
list_authorized_escape_dir,
dir_signature,
list_workspace_suggestions,
read_file_content,
read_authorized_escape_file_content,
safe_resolve_ws,
raw_authorized_escape_target,
resolve_trusted_workspace,
open_anchored_fd,
open_anchored_create_fd,
@@ -9821,6 +9825,12 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/list":
return _handle_list_dir(handler, parsed)
if parsed.path == "/api/escape/authorize":
return _handle_escape_authorize(handler, parsed)
if parsed.path == "/api/escape/list":
return _handle_escape_list_dir(handler, parsed)
if parsed.path == "/api/git/status":
return _handle_git_status(handler, parsed)
@@ -9971,12 +9981,18 @@ def handle_get(handler, parsed) -> bool:
if parsed.path == "/api/file/raw":
return _handle_file_raw(handler, parsed)
if parsed.path == "/api/escape/file/raw":
return _handle_escape_file_raw(handler, parsed)
if parsed.path == "/api/folder/download":
return _handle_folder_download(handler, parsed)
if parsed.path == "/api/file":
return _handle_file_read(handler, parsed)
if parsed.path == "/api/escape/file/read":
return _handle_escape_file_read(handler, parsed)
if parsed.path == "/api/approval/pending":
return _handle_approval_pending(handler, parsed)
@@ -10479,6 +10495,9 @@ def handle_post(handler, parsed) -> bool:
diag.finish()
raise
if parsed.path == "/api/escape/authorize":
return _handle_escape_authorize(handler, parsed, body)
if parsed.path == "/api/updates/check":
settings = load_settings()
if not settings.get("check_for_updates", True):
@@ -12916,6 +12935,132 @@ def _handle_list_dir(handler, parsed):
return bad(handler, _sanitize_error(e), 404)
def _read_json_request_body(handler, *, max_bytes: int = 4096) -> dict:
try:
length = _safe_content_length(handler, max_bytes)
except (ValueError, OverflowError) as exc:
raise ValueError(_sanitize_error(exc)) from exc
raw = handler.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8"))
except Exception as exc:
raise ValueError("invalid JSON body") from exc
return payload if isinstance(payload, dict) else {}
def _handle_escape_authorize(handler, parsed, body: dict | None = None):
if handler.command != "POST":
return bad(handler, "method not allowed", 405)
if body is None:
try:
body = _read_json_request_body(handler)
except ValueError as exc:
return bad(handler, _sanitize_error(exc), 400)
qs = parse_qs(parsed.query)
sid = str(body.get("session_id") or qs.get("session_id", [""])[0] or "").strip()
rel = str(body.get("path") or qs.get("path", [""])[0] or "").strip()
token = str(body.get("token") or qs.get("token", [""])[0] or "").strip()
if token:
return bad(handler, "token must not be provided", 400)
if not sid:
return bad(handler, "session_id is required")
if not rel:
return bad(handler, "path is required")
try:
s = get_session_for_file_ops(sid)
except KeyError:
return bad(handler, "Session not found", 404)
try:
payload = authorize_escape_target(Path(s.workspace), sid, rel)
except ValueError as exc:
return bad(handler, _sanitize_error(exc), 404)
return j(handler, payload)
def _handle_escape_list_dir(handler, parsed):
qs = parse_qs(parsed.query)
sid = qs.get("session_id", [""])[0]
token = qs.get("token", [""])[0]
if not sid:
return bad(handler, "session_id is required")
if not token:
return bad(handler, "token is required")
try:
s = get_session_for_file_ops(sid)
except KeyError:
return bad(handler, "Session not found", 404)
rel_path = qs.get("path", ["."])[0]
try:
payload = list_authorized_escape_dir(Path(s.workspace), sid, token, rel_path)
return j(handler, payload)
except FileNotFoundError as exc:
return bad(handler, _sanitize_error(exc), 404)
except ValueError as exc:
msg = _sanitize_error(exc)
status = 403 if "expired" in msg.lower() else 404
return bad(handler, msg, status)
def _handle_escape_file_read(handler, parsed):
qs = parse_qs(parsed.query)
sid = qs.get("session_id", [""])[0]
token = qs.get("token", [""])[0]
if not sid:
return bad(handler, "session_id is required")
if not token:
return bad(handler, "token is required")
try:
s = get_session_for_file_ops(sid)
except KeyError:
return bad(handler, "Session not found", 404)
rel = qs.get("path", [""])[0]
try:
return j(handler, read_authorized_escape_file_content(Path(s.workspace), sid, token, rel))
except FileNotFoundError as exc:
return bad(handler, _sanitize_error(exc), 404)
except ValueError as exc:
msg = _sanitize_error(exc)
status = 403 if "expired" in msg.lower() else 404
return bad(handler, msg, status)
def _handle_escape_file_raw(handler, parsed):
qs = parse_qs(parsed.query)
sid = qs.get("session_id", [""])[0]
token = qs.get("token", [""])[0]
if not sid:
return bad(handler, "session_id is required")
if not token:
return bad(handler, "token is required")
try:
s = get_session_for_file_ops(sid)
except KeyError:
return bad(handler, "Session not found", 404)
rel = qs.get("path", [""])[0]
force_download = qs.get("download", [""])[0] == "1"
try:
anchor_root, target = raw_authorized_escape_target(Path(s.workspace), sid, token, rel)
except FileNotFoundError as exc:
return j(handler, {"error": "not found"}, status=404)
except ValueError as exc:
msg = _sanitize_error(exc)
status = 403 if "expired" in msg.lower() else 404
return bad(handler, msg, status)
if not target.exists() or not target.is_file():
return j(handler, {"error": "not found"}, status=404)
ext = target.suffix.lower()
mime = MIME_MAP.get(ext, "application/octet-stream")
inline_preview = qs.get("inline", [""])[0] == "1"
dangerous_types = {"text/html", "application/xhtml+xml", "image/svg+xml"}
html_inline_ok = inline_preview and mime == "text/html"
disposition = "attachment" if force_download or (mime in dangerous_types and not html_inline_ok) else "inline"
sandbox_csp = "sandbox allow-scripts allow-popups allow-popups-to-escape-sandbox"
csp = sandbox_csp if (inline_preview and not force_download and disposition == "inline") else None
if html_inline_ok:
return _serve_inline_html_preview(handler, target, "no-store", csp=sandbox_csp, anchor_root=anchor_root)
return _serve_file_bytes(handler, target, mime, disposition, "no-store", csp=csp, anchor_root=anchor_root)
def _sse_with_id(handler, event, data, event_id=None):
if event_id:
handler.wfile.write(f"id: {event_id}\n".encode("utf-8"))
+181
View File
@@ -12,14 +12,21 @@ import json
import logging
import os
import posixpath
import secrets
import shutil
import stat
import subprocess
import concurrent.futures
import threading
import time
from pathlib import Path, PurePosixPath
logger = logging.getLogger(__name__)
_ESCAPE_AUTH_TTL_SECONDS = 300
_ESCAPE_AUTH_LOCK = threading.Lock()
_ESCAPE_AUTH_TOKENS: dict[str, dict[str, str | int | float]] = {}
from api.config import (
WORKSPACES_FILE as _GLOBAL_WS_FILE,
LAST_WORKSPACE_FILE as _GLOBAL_LW_FILE,
@@ -1429,6 +1436,180 @@ def read_file_content(workspace: Path, rel: str) -> dict:
return {'path': rel, 'content': content, 'size': len(raw), 'lines': content.count('\n') + 1}
def _normalize_workspace_rel_path(rel: str | Path) -> str:
raw = _strip_surrounding_quotes(str(rel or "")).strip().replace("\\", "/")
if not raw or raw == ".":
return "."
norm = posixpath.normpath(raw)
if not norm or norm == ".":
return "."
if norm == ".." or norm.startswith("../") or norm.startswith("/"):
raise ValueError(f"Path traversal blocked: {rel}")
return norm
def _escape_virtual_path(root: str, rel: str) -> str:
root_norm = _normalize_workspace_rel_path(root)
rel_norm = _normalize_workspace_rel_path(rel)
if root_norm == ".":
return rel_norm
if rel_norm == ".":
return root_norm
return f"{root_norm}/{rel_norm}"
def _escape_surface_target(workspace: Path, rel: str) -> tuple[Path, Path]:
workspace_root = workspace.resolve()
surface_rel = _normalize_workspace_rel_path(rel)
surface_path = workspace / surface_rel
if not surface_path.is_symlink():
raise ValueError(f"Path is not an escape-target symlink: {rel}")
target = surface_path.resolve()
if not target.exists():
raise ValueError(f"Path is no longer reachable: {rel}")
try:
target.relative_to(workspace_root)
except ValueError:
pass
else:
raise ValueError(f"Path does not escape workspace: {rel}")
if _is_blocked_system_path(target):
raise ValueError(f"Path points to a system directory: {target}")
return surface_path, target
def _escape_prune_tokens(now: float | None = None) -> None:
cutoff = time.time() if now is None else now
expired = [token for token, record in _ESCAPE_AUTH_TOKENS.items() if float(record.get("expires_at") or 0.0) <= cutoff]
for token in expired:
_ESCAPE_AUTH_TOKENS.pop(token, None)
def authorize_escape_target(workspace: Path, session_id: str, rel: str) -> dict:
"""Mint a short-lived browser-only grant for one surfaced escape-target symlink."""
workspace_root = workspace.resolve()
_surface_path, target = _escape_surface_target(workspace_root, rel)
token = secrets.token_urlsafe(24)
expires_at = time.time() + _ESCAPE_AUTH_TTL_SECONDS
record = {
"session_id": str(session_id or ""),
"workspace_root": str(workspace_root),
"surface_path": _normalize_workspace_rel_path(rel),
"external_root": str(target),
"surface_target": str(target),
"expires_at": expires_at,
}
with _ESCAPE_AUTH_LOCK:
_escape_prune_tokens(expires_at)
_ESCAPE_AUTH_TOKENS[token] = record
return {
"token": token,
"path": record["surface_path"],
"is_dir": target.is_dir(),
"expires_at": expires_at,
"expires_in": _ESCAPE_AUTH_TTL_SECONDS,
"read_only": True,
}
def _escape_authorization_record(workspace: Path, session_id: str, token: str) -> dict:
workspace_root = str(workspace.resolve())
token = str(token or "").strip()
if not token:
raise ValueError("Escape authorization token is required")
now = time.time()
with _ESCAPE_AUTH_LOCK:
_escape_prune_tokens(now)
record = dict(_ESCAPE_AUTH_TOKENS.get(token) or {})
if not record:
raise ValueError("Escape authorization expired")
if str(record.get("session_id") or "") != str(session_id or ""):
raise ValueError("Escape authorization expired")
if str(record.get("workspace_root") or "") != workspace_root:
raise ValueError("Escape authorization expired")
if now >= float(record.get("expires_at") or 0.0):
with _ESCAPE_AUTH_LOCK:
_ESCAPE_AUTH_TOKENS.pop(token, None)
raise ValueError("Escape authorization expired")
surface_path = _normalize_workspace_rel_path(record.get("surface_path") or ".")
external_root = Path(str(record.get("external_root") or ""))
try:
_surface, surface_target = _escape_surface_target(Path(workspace_root), surface_path)
except ValueError:
raise ValueError("Escape authorization expired")
if str(surface_target.resolve()) != str(external_root.resolve()):
raise ValueError("Escape authorization expired")
if not surface_target.exists() or _is_blocked_system_path(surface_target):
raise ValueError("Escape authorization expired")
return record
def resolve_authorized_escape_request(workspace: Path, session_id: str, token: str, rel: str) -> dict:
record = _escape_authorization_record(workspace, session_id, token)
surface_path = _normalize_workspace_rel_path(record["surface_path"])
request_path = _normalize_workspace_rel_path(rel)
try:
external_rel = str(PurePosixPath(request_path).relative_to(PurePosixPath(surface_path)))
except ValueError:
raise ValueError(f"Path traversal blocked: {rel}")
if not external_rel:
external_rel = "."
external_root = Path(str(record["external_root"]))
target = external_root / external_rel
return {
"record": record,
"workspace_root": Path(str(record["workspace_root"])),
"surface_path": surface_path,
"request_path": request_path,
"external_rel": external_rel,
"external_root": external_root,
"target": target,
}
def list_authorized_escape_dir(workspace: Path, session_id: str, token: str, rel: str) -> dict:
resolved = resolve_authorized_escape_request(workspace, session_id, token, rel)
external_root = resolved["external_root"]
external_rel = resolved["external_rel"]
entries = list_dir(external_root, external_rel)
surface_path = resolved["surface_path"]
external_root_resolved = external_root.resolve()
for entry in entries:
entry["path"] = _escape_virtual_path(surface_path, entry.get("path") or ".")
entry["escape_read_only"] = True
target = entry.get("target")
if not target:
continue
try:
target_path = Path(str(target)).resolve()
target_rel = target_path.relative_to(external_root_resolved).as_posix()
except Exception:
entry.pop("target", None)
continue
entry["target"] = _escape_virtual_path(surface_path, target_rel)
return {
"path": resolved["request_path"],
"entries": entries,
"signature": dir_signature(external_root, external_rel, entries),
"virtual_root": surface_path,
"read_only": True,
}
def read_authorized_escape_file_content(workspace: Path, session_id: str, token: str, rel: str) -> dict:
resolved = resolve_authorized_escape_request(workspace, session_id, token, rel)
payload = read_file_content(resolved["external_root"], resolved["external_rel"])
payload["path"] = resolved["request_path"]
payload["escape_read_only"] = True
return payload
def raw_authorized_escape_target(workspace: Path, session_id: str, token: str, rel: str) -> tuple[Path, Path]:
resolved = resolve_authorized_escape_request(workspace, session_id, token, rel)
target = safe_resolve_ws(resolved["external_root"], resolved["external_rel"])
return resolved["external_root"], target
# ── Git detection ──────────────────────────────────────────────────────────
def _run_git(args, cwd, timeout=3):
+39 -13
View File
@@ -447,7 +447,9 @@ const LOCALES = {
downloading: (name) => `Downloading ${name}\u2026`,
double_click_rename: 'Double-click to rename',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renamed to ',
rename_failed: 'Rename failed: ',
@@ -2020,7 +2022,9 @@ const LOCALES = {
downloading: (name) => `Scaricamento ${name}\u2026`,
double_click_rename: 'Doppio clic per rinominare',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Rinominato in ',
rename_failed: 'Rinomina fallita: ',
@@ -3584,7 +3588,9 @@ const LOCALES = {
downloading: (name) => `${name} をダウンロード中…`,
double_click_rename: 'ダブルクリックで名前変更',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: '名前変更を開始できませんでした — 行が見つかりません。',
renamed_to: '名前を変更: ',
rename_failed: '名前変更失敗: ',
@@ -5072,7 +5078,9 @@ const LOCALES = {
downloading: (name) => `Скачиваю ${name}…`,
double_click_rename: 'Дважды щёлкните, чтобы переименовать',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Переименовано в ',
rename_failed: 'Не удалось переименовать: ',
@@ -6553,7 +6561,9 @@ const LOCALES = {
downloading: (name) => `Descargando ${name}…`,
double_click_rename: 'Haz doble clic para renombrar',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renombrado a ',
rename_failed: 'Error al renombrar: ',
@@ -8032,7 +8042,9 @@ const LOCALES = {
downloading: (name) => `Lade ${name} herunter\u2026`,
double_click_rename: 'Doppelklick zum Umbenennen',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Umbenannt in ',
rename_failed: 'Umbenennen fehlgeschlagen: ',
@@ -9576,7 +9588,9 @@ const LOCALES = {
downloading: (name) => `正在下载 ${name}...`,
double_click_rename: '双击重命名',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: '已重命名为 ',
rename_failed: '重命名失败:',
@@ -11154,7 +11168,9 @@ const LOCALES = {
downloading: (name) => `正在下載 ${name}…`,
double_click_rename: '按兩下即可重新命名',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: '無法開始重新命名,找不到資料列。',
renamed_to: '已重新命名為 ',
rename_failed: '重新命名失敗:',
@@ -12621,7 +12637,9 @@ const LOCALES = {
downloading: (name) => `Baixando ${name}…`,
double_click_rename: 'Duplo clique para renomear',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renomeado para ',
rename_failed: 'Falha ao renomear: ',
@@ -14079,7 +14097,9 @@ const LOCALES = {
downloading: (name) => `Downloading ${name}\u2026`,
double_click_rename: 'Double-click to rename',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renamed to ',
rename_failed: 'Rename failed: ',
@@ -15582,7 +15602,9 @@ const LOCALES = {
file_open_failed: 'Impossible d\'ouvrir le fichier',
double_click_rename: 'Double-cliquez pour renommer',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Renommé en',
rename_failed: 'Échec du changement de nom :',
@@ -17151,7 +17173,9 @@ const LOCALES = {
downloading: (name) => `${name} indiriliyor\u2026`,
double_click_rename: 'Yeniden adlandırmak için çift tıklayın',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Could not start rename — row not found.',
renamed_to: 'Yeniden adlandırıldı',
rename_failed: 'Yeniden adlandırma başarısız oldu:',
@@ -18729,7 +18753,9 @@ const LOCALES = {
downloading: (name) => `Pobieranie ${name}…`,
double_click_rename: 'Kliknij dwukrotnie, aby zmienić nazwę',
symlink_link_to: 'Symlink → {target}',
external_link_open_confirm: 'This symlink points outside the workspace and cannot be opened from here.',
external_link_open_confirm: 'This symlink points outside the workspace. Open it in a read-only external view for this browser session?',
external_link_read_only: 'This external view is read-only. Create, rename, move, delete, upload, reveal, and editor actions stay disabled here.',
external_link_grant_expired: 'This external view expired. Reopen the escape-target symlink to authorize it again.',
session_rename_failed_no_row: 'Nie można rozpocząć zmiany nazwy — nie znaleziono wiersza.',
renamed_to: 'Nazwa zmieniona na ',
rename_failed: 'Zmiana nazwy nie powiodła się: ',
+148 -97
View File
@@ -14631,18 +14631,27 @@ function _renderTreeItems(container, entries, depth){
el.style.paddingLeft=(8+depth*16)+'px';
el.setAttribute('draggable','true');
el.dataset.wsType=item.type;
el.oncontextmenu=(e)=>{e.preventDefault();e.stopPropagation();_showFileContextMenu(e,item);};
el.oncontextmenu=(e)=>{
const grant=typeof _workspaceEscapeGrantForPath==='function' ? _workspaceEscapeGrantForPath(item.path) : null;
const isDirRow=item.type==='dir'||(item.type==='symlink'&&item.is_dir);
if(grant&&!isDirRow){e.preventDefault();e.stopPropagation();return;}
e.preventDefault();e.stopPropagation();_showFileContextMenu(e,item);
};
el.ondragstart=(e)=>{e.dataTransfer.setData('application/ws-path',item.path);e.dataTransfer.setData('application/ws-type',item.type);e.dataTransfer.effectAllowed='copy';el.classList.add('dragging');};
el.ondragend=()=>{el.classList.remove('dragging');_clearWorkspaceMoveDragOver();};
const isLk = item.type === 'symlink';
const isExternalLink = isLk && item.target_outside_workspace;
const escapeGrant = typeof _workspaceEscapeGrantForPath === 'function' ? _workspaceEscapeGrantForPath(item.path) : null;
const exactEscapeGrant = typeof _workspaceEscapeExactGrant === 'function' ? _workspaceEscapeExactGrant(item.path) : null;
const isReadOnlyEscape = !!escapeGrant;
const isNestedEscape = !!escapeGrant && !exactEscapeGrant;
// External symlinks are display-only: not expandable, not openable.
// The read gate (safe_resolve_ws) still blocks navigation through them.
const isDirLike = !isExternalLink && (item.type === 'dir' || (isLk && item.is_dir));
const isFileLike = !isExternalLink && !isDirLike;
el.dataset.wsIsDir = String(isDirLike);
if(isExternalLink){el.removeAttribute('draggable');el.ondragstart=null;}
if(isExternalLink || isReadOnlyEscape){el.removeAttribute('draggable');el.ondragstart=null;}
if(isDirLike){
// Toggle arrow for directories
@@ -14678,6 +14687,10 @@ function _renderTreeItems(container, entries, depth){
// (the "Double-click to rename" hint here would be misleading). #1710.
if(isLk && item.target)
nameEl.title = t('symlink_link_to').replace('{target}', () => elideMiddle(item.target));
else if(isExternalLink)
nameEl.title = isReadOnlyEscape ? t('external_link_read_only') : t('external_link_open_confirm');
else if(isReadOnlyEscape)
nameEl.title = t('external_link_read_only');
else if(!isDirLike)
nameEl.title = t('double_click_rename');
// Single-click opens (file) or expand-toggles (dir) but is debounced 300ms so a
@@ -14699,8 +14712,12 @@ function _renderTreeItems(container, entries, depth){
if(_nameClickTimer){clearTimeout(_nameClickTimer);_nameClickTimer=null;}
// For directories, double-click navigates (breadcrumb view)
if(isDirLike){loadDir(item.path);return;}
// External symlinks: show informational dialog, not rename
if(isExternalLink){if(typeof el.onclick==='function')el.onclick(e);return;}
// Escape-root rows remain browse-only, nested escape rows stay display-only.
if(isReadOnlyEscape){
if(isExternalLink){if(typeof el.onclick==='function')el.onclick(e);return;}
openFile(item.path);
return;
}
const inp=document.createElement('input');
inp.className='file-rename-input';inp.value=item.name;
inp.onclick=(e2)=>e2.stopPropagation();
@@ -14754,12 +14771,12 @@ function _renderTreeItems(container, entries, depth){
}
// Delete button -- for file-like rows and directory-like rows
if(isFileLike){
if(isFileLike&& !isReadOnlyEscape){
const del=document.createElement('button');
del.className='file-del-btn';del.title=t('delete_title');del.textContent='\u00d7';
del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceFile(item.path,item.name);};
el.appendChild(del);
}else if(isDirLike){
}else if(isDirLike&& !isReadOnlyEscape){
const del=document.createElement('button');
del.className='file-del-btn';del.title=t('delete_title');del.textContent='\u00d7';
del.onclick=async(e)=>{e.stopPropagation();await deleteWorkspaceDir(item.path,item.name);};
@@ -14767,8 +14784,10 @@ function _renderTreeItems(container, entries, depth){
}
if(isDirLike){
_bindWorkspaceMoveDropTarget(el,item.path);
_bindWorkspaceOsUploadDropTarget(el,item.path);
if(!isReadOnlyEscape){
_bindWorkspaceMoveDropTarget(el,item.path);
_bindWorkspaceOsUploadDropTarget(el,item.path);
}
// Single-click toggles expand/collapse
el.onclick=async(e)=>{
e.stopPropagation();
@@ -14782,7 +14801,7 @@ function _renderTreeItems(container, entries, depth){
// Fetch children if not cached
if(!S._dirCache[item.path]){
try{
const data=await api(`/api/list?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(item.path)}`);
const data=await api(_workspaceRouteForPath(item.path, 'list'));
S._dirCache[item.path]=data.entries||[];
}catch(e2){S._dirCache[item.path]=[];}
}
@@ -14791,18 +14810,25 @@ function _renderTreeItems(container, entries, depth){
};
}else if(isExternalLink){
// Display-only: the link points outside the workspace. We do NOT disclose
// the resolved outside path (#4581 hardening) and do NOT call openFile —
// the read gate (safe_resolve_ws) blocks navigation through the link.
// the resolved outside path (#4581 hardening) and do NOT recursively
// authorize nested escape rows under an already-authorized external root.
el.onclick=async(e)=>{
e.stopPropagation();
await showConfirmDialog({
title:item.name,
message:t('external_link_open_confirm'),
confirmLabel:t('dialog_confirm_btn'),
danger:false,
hideCancel:true,
focusCancel:false,
});
if(isNestedEscape){
await showConfirmDialog({
title:item.name,
message:t('external_link_read_only'),
confirmLabel:t('dialog_confirm_btn'),
danger:false,
hideCancel:true,
focusCancel:false,
});
return;
}
const grant = await authorizeWorkspaceEscapeNavigation(item);
if(!grant) return;
if(grant.isDir) await loadDir(item.path);
else await openFile(item.path);
};
}else{
el.onclick=async()=>openFile(item.path);
@@ -14828,6 +14854,10 @@ function _renderTreeItems(container, entries, depth){
async function deleteWorkspaceDir(relPath, name){
if(!S.session)return;
if(typeof _workspacePathIsReadOnly==='function'&&_workspacePathIsReadOnly(relPath)){
showToast(t('external_link_read_only'), 2000);
return;
}
const ok=await showConfirmDialog({title:t('delete_dir_confirm',name),message:'',confirmLabel:'Delete',danger:true,focusCancel:true});
if(!ok)return;
try{
@@ -14851,83 +14881,86 @@ function _showFileContextMenu(e, item){
menu.style.top=(e.clientY+100>vh?e.clientY-100:e.clientY)+'px';
const isDirLike=item.type==='dir'||(item.type==='symlink'&&item.is_dir);
const targetDir=isDirLike ? item.path : _workspaceParentDir(item.path);
const isReadOnlyEscape=typeof _workspaceEscapeGrantForPath==='function' ? !!_workspaceEscapeGrantForPath(item.path) : false;
menu.appendChild(_workspaceContextMenuItem(t('new_file'),async()=>{
menu.remove();
await promptNewFile(targetDir);
}));
if(!isReadOnlyEscape){
menu.appendChild(_workspaceContextMenuItem(t('new_file'),async()=>{
menu.remove();
await promptNewFile(targetDir);
}));
menu.appendChild(_workspaceContextMenuItem(t('new_folder'),async()=>{
menu.remove();
await promptNewFolder(targetDir);
}));
menu.appendChild(_workspaceContextMenuItem(t('new_folder'),async()=>{
menu.remove();
await promptNewFolder(targetDir);
}));
const createSep=document.createElement('hr');
createSep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;';
menu.appendChild(createSep);
const createSep=document.createElement('hr');
createSep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;';
menu.appendChild(createSep);
// Rename
const renameItem=document.createElement('div');
renameItem.textContent=t('rename_title');
renameItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
renameItem.onmouseenter=()=>renameItem.style.background='var(--hover-bg)';
renameItem.onmouseleave=()=>renameItem.style.background='';
renameItem.onclick=()=>{menu.remove();_inlineRenameFileItem(item);};
menu.appendChild(renameItem);
// Rename
const renameItem=document.createElement('div');
renameItem.textContent=t('rename_title');
renameItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
renameItem.onmouseenter=()=>renameItem.style.background='var(--hover-bg)';
renameItem.onmouseleave=()=>renameItem.style.background='';
renameItem.onclick=()=>{menu.remove();_inlineRenameFileItem(item);};
menu.appendChild(renameItem);
// Reveal in File Manager
const revealItem=document.createElement('div');
revealItem.textContent=t('reveal_in_finder');
revealItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
revealItem.onmouseenter=()=>revealItem.style.background='var(--hover-bg)';
revealItem.onmouseleave=()=>revealItem.style.background='';
revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}};
menu.appendChild(revealItem);
// Reveal in File Manager
const revealItem=document.createElement('div');
revealItem.textContent=t('reveal_in_finder');
revealItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
revealItem.onmouseenter=()=>revealItem.style.background='var(--hover-bg)';
revealItem.onmouseleave=()=>revealItem.style.background='';
revealItem.onclick=async()=>{menu.remove();try{await api('/api/file/reveal',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('reveal_failed')+(err.message||err));}};
menu.appendChild(revealItem);
// Open in VS Code (#2735)
const vscodeItem=document.createElement('div');
vscodeItem.textContent=t('open_in_vscode');
vscodeItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
vscodeItem.onmouseenter=()=>vscodeItem.style.background='var(--hover-bg)';
vscodeItem.onmouseleave=()=>vscodeItem.style.background='';
vscodeItem.onclick=async()=>{menu.remove();try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));}};
menu.appendChild(vscodeItem);
// Open in VS Code (#2735)
const vscodeItem=document.createElement('div');
vscodeItem.textContent=t('open_in_vscode');
vscodeItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
vscodeItem.onmouseenter=()=>vscodeItem.style.background='var(--hover-bg)';
vscodeItem.onmouseleave=()=>vscodeItem.style.background='';
vscodeItem.onclick=async()=>{menu.remove();try{await api('/api/file/open-vscode',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});}catch(err){showToast(t('open_in_vscode_failed')+(err.message||err));}};
menu.appendChild(vscodeItem);
// Copy file path — resolves the absolute on-disk path on the server (so the
// user gets the full /home/.../workspace/foo.py rather than the relative
// path the file tree shows) and writes it to the OS clipboard. Useful for
// pasting into terminals, editors, or other apps without taking the slower
// Reveal-in-Finder round trip.
const copyPathItem=document.createElement('div');
copyPathItem.textContent=t('copy_file_path');
copyPathItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
copyPathItem.onmouseenter=()=>copyPathItem.style.background='var(--hover-bg)';
copyPathItem.onmouseleave=()=>copyPathItem.style.background='';
copyPathItem.onclick=async()=>{
menu.remove();
try{
const r=await api('/api/file/path',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});
const abs=(r&&r.path)||item.path;
// Copy file path — resolves the absolute on-disk path on the server (so the
// user gets the full /home/.../workspace/foo.py rather than the relative
// path the file tree shows) and writes it to the OS clipboard. Useful for
// pasting into terminals, editors, or other apps without taking the slower
// Reveal-in-Finder round trip.
const copyPathItem=document.createElement('div');
copyPathItem.textContent=t('copy_file_path');
copyPathItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
copyPathItem.onmouseenter=()=>copyPathItem.style.background='var(--hover-bg)';
copyPathItem.onmouseleave=()=>copyPathItem.style.background='';
copyPathItem.onclick=async()=>{
menu.remove();
try{
await navigator.clipboard.writeText(abs);
showToast(t('path_copied'));
}catch(clipErr){
const ta=document.createElement('textarea');
ta.value=abs;
ta.style.cssText='position:fixed;left:-9999px;top:-9999px;';
document.body.appendChild(ta);
ta.select();
let copied=false;
try{copied=document.execCommand('copy');}catch(_){}
ta.remove();
if(copied) showToast(t('path_copied'));
else showToast(t('path_copy_failed')+(clipErr&&clipErr.message?clipErr.message:String(clipErr)));
const r=await api('/api/file/path',{method:'POST',body:JSON.stringify({session_id:S.session.session_id,path:item.path})});
const abs=(r&&r.path)||item.path;
try{
await navigator.clipboard.writeText(abs);
showToast(t('path_copied'));
}catch(clipErr){
const ta=document.createElement('textarea');
ta.value=abs;
ta.style.cssText='position:fixed;left:-9999px;top:-9999px;';
document.body.appendChild(ta);
ta.select();
let copied=false;
try{copied=document.execCommand('copy');}catch(_){}
ta.remove();
if(copied) showToast(t('path_copied'));
else showToast(t('path_copy_failed')+(clipErr&&clipErr.message?clipErr.message:String(clipErr)));
}
}catch(err){
showToast(t('path_copy_failed')+(err.message||err));
}
}catch(err){
showToast(t('path_copy_failed')+(err.message||err));
}
};
menu.appendChild(copyPathItem);
};
menu.appendChild(copyPathItem);
}
if(isDirLike){
const dlItem=document.createElement('div');
@@ -14944,16 +14977,18 @@ function _showFileContextMenu(e, item){
menu.appendChild(dlItem);
}
const sep=document.createElement('hr');
sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;';
menu.appendChild(sep);
const delItem=document.createElement('div');
delItem.textContent=t('delete_title');
delItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--error,#e94560);';
delItem.onmouseenter=()=>delItem.style.background='var(--hover-bg)';
delItem.onmouseleave=()=>delItem.style.background='';
delItem.onclick=()=>{menu.remove();if(isDirLike)deleteWorkspaceDir(item.path,item.name);else deleteWorkspaceFile(item.path,item.name);};
menu.appendChild(delItem);
if(!isReadOnlyEscape){
const sep=document.createElement('hr');
sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;';
menu.appendChild(sep);
const delItem=document.createElement('div');
delItem.textContent=t('delete_title');
delItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--error,#e94560);';
delItem.onmouseenter=()=>delItem.style.background='var(--hover-bg)';
delItem.onmouseleave=()=>delItem.style.background='';
delItem.onclick=()=>{menu.remove();if(isDirLike)deleteWorkspaceDir(item.path,item.name);else deleteWorkspaceFile(item.path,item.name);};
menu.appendChild(delItem);
}
document.body.appendChild(menu);
const dismiss=()=>{menu.remove();document.removeEventListener('click',dismiss);};
@@ -14962,6 +14997,10 @@ function _showFileContextMenu(e, item){
async function _inlineRenameFileItem(item){
if(!S.session)return;
if(typeof _workspacePathIsReadOnly==='function'&&_workspacePathIsReadOnly(item.path)){
showToast(t('external_link_read_only'), 2000);
return;
}
const isDirLike=item.type==='dir'||(item.type==='symlink'&&item.is_dir);
// Pre-fill the input with the current name and select just the stem
// (everything before the last '.') so the user can immediately retype the
@@ -14995,6 +15034,10 @@ async function _inlineRenameFileItem(item){
async function deleteWorkspaceFile(relPath, name){
if(!S.session)return;
if(typeof _workspacePathIsReadOnly==='function'&&_workspacePathIsReadOnly(relPath)){
showToast(t('external_link_read_only'), 2000);
return;
}
const _delFile=await showConfirmDialog({title:t('delete_confirm',name),message:'',confirmLabel:'Delete',danger:true,focusCancel:true});
if(!_delFile) return;
try{
@@ -15016,6 +15059,10 @@ async function promptNewFile(targetDir = S.currentDir || '.'){
}catch(e){setStatus(t('create_failed')+e.message);return;}
}
if(!S.session)return;
if(typeof _workspacePathIsReadOnly==='function'&&_workspacePathIsReadOnly(targetDir)){
showToast(t('external_link_read_only'), 2000);
return;
}
const targetLabel=_workspaceCreateTargetLabel(targetDir);
const name=await showPromptDialog({
title:t('new_file_prompt_title', targetLabel),
@@ -15043,6 +15090,10 @@ async function promptNewFolder(targetDir = S.currentDir || '.'){
}catch(e){setStatus(t('folder_create_failed')+e.message);return;}
}
if(!S.session)return;
if(typeof _workspacePathIsReadOnly==='function'&&_workspacePathIsReadOnly(targetDir)){
showToast(t('external_link_read_only'), 2000);
return;
}
const targetLabel=_workspaceCreateTargetLabel(targetDir);
const name=await showPromptDialog({
title:t('new_folder_prompt_title', targetLabel),
+194 -14
View File
@@ -138,6 +138,147 @@ function _restoreExpandedDirs(){
}catch(e){S._expandedDirs=new Set();}
}
function _escapeGrantStore(){
if(!S._escapeGrants) S._escapeGrants = Object.create(null);
return S._escapeGrants;
}
function _normalizeWorkspaceRelPath(path){
let raw = String(path || '').trim().replace(/\\/g, '/');
if(!raw || raw === '.') return '.';
if(raw.startsWith('/')) return '';
const parts = [];
for(const part of raw.split('/')){
if(!part || part === '.') continue;
if(part === '..'){
if(parts.length) parts.pop();
else return '';
continue;
}
parts.push(part);
}
return parts.length ? parts.join('/') : '.';
}
function _isSameOrChildPath(base, path){
const normalizedBase = _normalizeWorkspaceRelPath(base);
const normalizedPath = _normalizeWorkspaceRelPath(path);
if(!normalizedBase || !normalizedPath) return false;
if(normalizedBase === '.') return true;
return normalizedPath === normalizedBase || normalizedPath.startsWith(`${normalizedBase}/`);
}
function _workspaceEscapeGrantForPath(path){
const grants = _escapeGrantStore();
const normalizedPath = _normalizeWorkspaceRelPath(path);
if(!normalizedPath || !S.session || !S.session.session_id) return null;
const sessionId = S.session.session_id;
let best = null;
for(const root of Object.keys(grants)){
const grant = grants[root];
if(!grant || grant.sessionId !== sessionId) continue;
if(grant.expiresAt && Date.now() >= grant.expiresAt){
delete grants[root];
continue;
}
if(!_isSameOrChildPath(root, normalizedPath)) continue;
if(!best || root.length > best.root.length) best = {root, grant};
}
return best ? best.grant : null;
}
function _workspaceEscapeExactGrant(path){
const normalizedPath = _normalizeWorkspaceRelPath(path);
const grant = _workspaceEscapeGrantForPath(normalizedPath);
if(!grant) return null;
return grant.path === normalizedPath ? grant : null;
}
function _storeWorkspaceEscapeGrant(data){
if(!S.session || !data || !data.token) return null;
const grants = _escapeGrantStore();
const root = _normalizeWorkspaceRelPath(data.path || '');
if(!root) return null;
const grant = {
sessionId: S.session.session_id,
path: root,
token: String(data.token),
expiresAt: Number(data.expires_at || 0) * 1000,
isDir: !!data.is_dir,
};
grants[root] = grant;
return grant;
}
function _clearWorkspaceEscapeGrant(path){
const grants = S._escapeGrants;
if(!grants) return;
const root = _normalizeWorkspaceRelPath(path);
if(root && grants[root]) delete grants[root];
}
function _workspacePathIsReadOnly(path){
return !!_workspaceEscapeGrantForPath(path || S.currentDir || '.');
}
function _workspaceRouteForPath(path, kind, opts={}){
if(!S.session) return '';
const normalizedPath = _normalizeWorkspaceRelPath(path);
const grant = _workspaceEscapeGrantForPath(normalizedPath);
const sessionId = encodeURIComponent(S.session.session_id);
const params = new URLSearchParams({session_id:S.session.session_id, path:normalizedPath || '.'});
if(grant){
params.set('token', grant.token);
if(kind === 'raw' && opts.download) params.set('download', '1');
if(kind === 'raw' && opts.inline) params.set('inline', '1');
if(kind === 'list') return `/api/escape/list?${params.toString()}`;
if(kind === 'read') return `/api/escape/file/read?${params.toString()}`;
if(kind === 'raw') return `/api/escape/file/raw?${params.toString()}`;
}
if(kind === 'list') return `/api/list?session_id=${sessionId}&path=${encodeURIComponent(normalizedPath || '.')}`;
if(kind === 'read') return `/api/file?session_id=${sessionId}&path=${encodeURIComponent(normalizedPath || '.')}`;
if(kind === 'raw'){
const extra = [];
if(opts.download) extra.push('download=1');
if(opts.inline) extra.push('inline=1');
const suffix = extra.length ? `&${extra.join('&')}` : '';
return `/api/file/raw?session_id=${sessionId}&path=${encodeURIComponent(normalizedPath || '.')}${suffix}`;
}
return '';
}
async function authorizeWorkspaceEscapeNavigation(item){
if(!S.session || !item || !item.path) return null;
const normalizedPath = _normalizeWorkspaceRelPath(item.path);
const exactGrant = _workspaceEscapeExactGrant(normalizedPath);
if(exactGrant) return exactGrant;
const ok = await showConfirmDialog({
title: item.name || normalizedPath,
message: t('external_link_open_confirm'),
confirmLabel: t('dialog_confirm_btn'),
danger: false,
hideCancel: true,
focusCancel: false,
});
if(!ok) return null;
try{
const data = await api('/api/escape/authorize', {
method: 'POST',
body: JSON.stringify({
session_id: S.session.session_id,
path: normalizedPath,
}),
});
const grant = _storeWorkspaceEscapeGrant(data);
if(!grant) throw new Error('Missing escape authorization token');
showToast(t('external_link_read_only'), 2000);
return grant;
}catch(e){
showToast(t('external_link_grant_expired') || (e && e.message ? e.message : String(e)), 5000, 'error');
return null;
}
}
let _workspacePanelActiveTab = 'files';
let _renderSessionArtifactsTimer = null;
@@ -518,7 +659,7 @@ async function loadDir(path, opts={}){
_restoreExpandedDirs(); // restore per-workspace expanded state after root and refresh resets
}
S.currentDir=path||'.';
const data=await api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(path)}`);
const data=await api(_workspaceRouteForPath(path, 'list'));
if(!S.session||S.session.session_id!==sessionId||treeGen!==_wsTreeGen)return;
S.entries=data.entries||[];renderBreadcrumb();renderFileTree();
// #2673 — refresh Artifacts tab when its source data (the file tree) updates.
@@ -530,7 +671,7 @@ async function loadDir(path, opts={}){
const pending=[...expanded].filter(dirPath=>!S._dirCache[dirPath]);
if(pending.length){
const results=await Promise.all(pending.map(dirPath=>
api(`/api/list?session_id=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(dirPath)}`)
api(_workspaceRouteForPath(dirPath, 'list'))
.then(dc=>({dirPath,entries:dc.entries||[]}))
.catch(()=>({dirPath,entries:[]}))
));
@@ -550,7 +691,15 @@ async function loadDir(path, opts={}){
}
// Fetch git info for workspace root (non-blocking)
if(!path||path==='.') _refreshGitBadge();
}catch(e){console.warn('loadDir',e);}
}catch(e){
const grant = _workspaceEscapeGrantForPath(path);
if(grant && (e && (e.status === 403 || e.status === 404))){
_clearWorkspaceEscapeGrant(grant.path);
showToast(t('external_link_grant_expired') || t('file_open_failed'), 5000, 'error');
return;
}
console.warn('loadDir',e);
}
}
function refreshWorkspacePanel(){
@@ -732,7 +881,8 @@ function showPreview(mode){
function updateEditBtn(){
const btn=$('btnEditFile');
if(!btn)return;
const editable = _previewCurrentMode==='code'||_previewCurrentMode==='md'||_previewCurrentMode==='csv';
const editable = !_workspacePathIsReadOnly(_previewCurrentPath)
&& (_previewCurrentMode==='code'||_previewCurrentMode==='md'||_previewCurrentMode==='csv');
btn.style.display = editable?'':'none';
const editing = $('previewEditArea').style.display!=='none';
btn.innerHTML = editing ? `&#128190; ${t('save')}` : `&#9998; ${t('edit')}`;
@@ -743,6 +893,10 @@ function updateEditBtn(){
async function toggleEditMode(){
const editing = $('previewEditArea').style.display!=='none';
if(_workspacePathIsReadOnly(_previewCurrentPath)){
showToast(t('external_link_read_only'), 2000);
return;
}
if(editing){
// Save
if(!S.session||!_previewCurrentPath)return;
@@ -851,14 +1005,14 @@ async function openFile(path, opts={}){
if(IMAGE_EXTS.has(ext)){
// Image: load via raw endpoint, show as <img>
showPreview('image');
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}${cacheBust}`;
const url=_workspaceRouteForPath(path, 'raw') + cacheBust;
$('previewImg').alt=path;
$('previewImg').src=url;
$('previewImg').onerror=()=>setStatus(t('image_load_failed'));
} else if(AUDIO_EXTS.has(ext)||VIDEO_EXTS.has(ext)){
const mode=VIDEO_EXTS.has(ext)?'video':'audio';
showPreview(mode);
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1${cacheBust}`;
const url=_workspaceRouteForPath(path, 'raw', {inline:true}) + cacheBust;
const wrap=$('previewMediaWrap');
if(wrap){
wrap.innerHTML=(typeof _mediaPlayerHtml==='function')
@@ -868,7 +1022,7 @@ async function openFile(path, opts={}){
}
} else if(PDF_EXTS.has(ext)){
showPreview('pdf');
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1${cacheBust}`;
const url=_workspaceRouteForPath(path, 'raw', {inline:true}) + cacheBust;
const frame=$('previewPdfFrame');
if(frame){
frame.src=''; // clear first to avoid stale content
@@ -885,7 +1039,7 @@ async function openFile(path, opts={}){
// file switch could re-render the previous file's cached content.
const data=forceRichMarkdown&&path===_previewRawContentPath&&_previewRawContent
? {content:_previewRawContent}
: await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
: await api(_workspaceRouteForPath(path, 'read'));
_previewRawContent = data.content;
_previewRawContentPath = path;
if(!forceRichMarkdown && shouldRenderMarkdownPreviewAsPlainText(data.content)){
@@ -907,7 +1061,7 @@ async function openFile(path, opts={}){
// or reading other origin data. If a stricter mode is needed, remove
// allow-scripts (or add sandbox="") to disable all JS execution.
showPreview('html');
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&inline=1${cacheBust}`;
const url=_workspaceRouteForPath(path, 'raw', {inline:true}) + cacheBust;
const iframe=$('previewHtmlIframe');
if(iframe){
iframe.src=''; // clear first to avoid stale content
@@ -915,7 +1069,7 @@ async function openFile(path, opts={}){
}
} else if(ext==='.csv'){
try{
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
const data=await api(_workspaceRouteForPath(path, 'read'));
if(data.binary){
downloadFile(path);
return;
@@ -928,14 +1082,20 @@ async function openFile(path, opts={}){
} else {
// Plain code / text -- but fall back to download if server signals binary
try{
const data=await api(`/api/file?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}`);
const data=await api(_workspaceRouteForPath(path, 'read'));
if(data.binary){
// Server flagged this as binary content
downloadFile(path);
return;
}
renderCodePreviewContent(path, data.content);
}catch(e){
}catch(e){
const grant = _workspaceEscapeGrantForPath(path);
if(grant && (e && (e.status === 403 || e.status === 404))){
_clearWorkspaceEscapeGrant(grant.path);
showToast(t('external_link_grant_expired') || t('file_open_failed'), 5000, 'error');
return;
}
// If it's a 400/too-large error, offer download instead
downloadFile(path);
}
@@ -945,7 +1105,7 @@ async function openFile(path, opts={}){
function downloadFile(path){
if(!S.session)return;
// Trigger browser download via the raw file endpoint with content-disposition attachment
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(path)}&download=1`;
const url=_workspaceRouteForPath(path, 'raw', {download:true});
const filename=path.split('/').pop();
const a=document.createElement('a');
a.href=url;a.download=filename;
@@ -995,12 +1155,16 @@ function renderFileBreadcrumb(filePath) {
function openInBrowser(){
if(!_previewCurrentPath||!S.session) return;
const url=`api/file/raw?session_id=${encodeURIComponent(S.session.session_id)}&path=${encodeURIComponent(_previewCurrentPath)}&inline=1`;
const url=_workspaceRouteForPath(_previewCurrentPath, 'raw', {inline:true});
window.open(url,'_blank','noopener');
}
// ── Workspace upload ──────────────────────────────────────────────────
function triggerWorkspaceUpload() {
if(_workspacePathIsReadOnly(S.currentDir || '.')){
showToast(t('external_link_read_only'), 2000);
return;
}
const input = $('workspaceFileInput');
if (!input) return;
input.value = '';
@@ -1017,6 +1181,10 @@ function triggerWorkspaceUpload() {
async function uploadToWorkspace(file, dir) {
if (!S.session) return;
if(_workspacePathIsReadOnly(dir || '.')){
showToast(t('external_link_read_only'), 2000);
return;
}
const formData = new FormData();
formData.append('session_id', S.session.session_id);
formData.append('path', dir || '.');
@@ -1114,6 +1282,10 @@ async function _collectOsDropUploads(dataTransfer) {
async function uploadOsDropToWorkspace(dataTransfer, destDir) {
if (!S.session || !dataTransfer) return;
if(_workspacePathIsReadOnly(destDir || '.')){
showToast(t('external_link_read_only'), 2000);
return;
}
const uploads = await _collectOsDropUploads(dataTransfer);
for (const { file, relDir } of uploads) {
await uploadToWorkspace(file, _targetDirForRelDir(destDir, relDir));
@@ -1158,6 +1330,10 @@ function _bindWorkspaceOsUploadDropTarget(el, destDir) {
e.preventDefault();
e.stopPropagation();
el.classList.remove('drag-over-upload');
if(_workspacePathIsReadOnly(destDir || '.')){
showToast(t('external_link_read_only'), 2000);
return;
}
await uploadOsDropToWorkspace(e.dataTransfer, destDir);
});
}
@@ -1192,6 +1368,10 @@ if (typeof document !== 'undefined') {
if (e.target.closest('.file-item[data-ws-type="dir"],.file-item[data-ws-is-dir="true"],.breadcrumb-seg')) return;
e.preventDefault();
e.stopPropagation();
if(_workspacePathIsReadOnly(S.currentDir || '.')){
showToast(t('external_link_read_only'), 2000);
return;
}
await uploadOsDropToWorkspace(e.dataTransfer, S.currentDir || '.');
});
};
@@ -0,0 +1,141 @@
from __future__ import annotations
import json
import pathlib
import urllib.error
import urllib.request
from api.routes import _project_os_workspace_read
from tests._pytest_port import BASE
ROOT = pathlib.Path(__file__).resolve().parents[1]
UI_JS = ROOT / "static" / "ui.js"
WORKSPACE_JS = ROOT / "static" / "workspace.js"
def _get_json(path: str) -> dict:
with urllib.request.urlopen(BASE + path, timeout=10) as response:
return json.loads(response.read())
def _get_bytes(path: str) -> bytes:
with urllib.request.urlopen(BASE + path, timeout=10) as response:
return response.read()
def _post_json(path: str, body: dict | None = None) -> tuple[dict, int]:
req = urllib.request.Request(
BASE + path,
data=json.dumps(body or {}).encode(),
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
return json.loads(response.read()), response.status
except urllib.error.HTTPError as exc:
return json.loads(exc.read()), exc.code
def _make_session(workspace: pathlib.Path) -> str:
_post_json("/api/workspaces/add", {"path": str(workspace)})
payload, status = _post_json("/api/session/new", {"workspace": str(workspace)})
assert status == 200, payload
return payload["session"]["session_id"]
class TestIssue4582EscapeNavigationLive:
def test_authorized_dir_list_read_and_raw_stay_virtualized(self, tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
outside.mkdir()
(outside / "note.txt").write_text("outside note", encoding="utf-8")
(workspace / "escape").symlink_to(outside)
sid = _make_session(workspace)
root_listing = _get_json(f"/api/list?session_id={sid}&path=.")
escape_row = {entry["name"]: entry for entry in root_listing["entries"]}["escape"]
assert escape_row["target_outside_workspace"] is True
auth, status = _post_json("/api/escape/authorize", {"session_id": sid, "path": "escape"})
assert status == 200, auth
assert auth["path"] == "escape"
assert auth["is_dir"] is True
assert auth["read_only"] is True
listed = _get_json(
f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape"
)
entries = {entry["name"]: entry for entry in listed["entries"]}
assert listed["path"] == "escape"
assert listed["read_only"] is True
assert entries["note.txt"]["path"] == "escape/note.txt"
assert entries["note.txt"]["escape_read_only"] is True
assert str(outside) not in json.dumps(listed)
text = _get_json(
f"/api/escape/file/read?session_id={sid}&token={auth['token']}&path=escape/note.txt"
)
assert text["path"] == "escape/note.txt"
assert text["content"] == "outside note"
assert text["escape_read_only"] is True
raw = _get_bytes(
f"/api/escape/file/raw?session_id={sid}&token={auth['token']}&path=escape/note.txt"
)
assert raw == b"outside note"
assert _project_os_workspace_read(pathlib.Path(workspace), "escape/note.txt") is None
def test_nested_escape_row_stays_display_only_and_non_browsable(self, tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
second_outside = tmp_path / "second-outside"
workspace.mkdir()
outside.mkdir()
second_outside.mkdir()
(second_outside / "secret.txt").write_text("secret", encoding="utf-8")
(outside / "nested-escape").symlink_to(second_outside)
(workspace / "escape").symlink_to(outside)
sid = _make_session(workspace)
auth, status = _post_json("/api/escape/authorize", {"session_id": sid, "path": "escape"})
assert status == 200, auth
listed = _get_json(
f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape"
)
entries = {entry["name"]: entry for entry in listed["entries"]}
nested = entries["nested-escape"]
assert nested["target_outside_workspace"] is True
assert nested["escape_read_only"] is True
assert "target" not in nested
try:
_get_json(
f"/api/escape/list?session_id={sid}&token={auth['token']}&path=escape/nested-escape"
)
assert False, "nested escape traversal should stay blocked"
except urllib.error.HTTPError as exc:
assert exc.code in (403, 404)
class TestIssue4582EscapeNavigationFrontend:
def test_workspace_route_helper_uses_escape_route_family(self):
src = WORKSPACE_JS.read_text(encoding="utf-8")
assert "/api/escape/list?" in src
assert "/api/escape/file/read?" in src
assert "/api/escape/file/raw?" in src
def test_external_rows_authorize_then_open(self):
src = UI_JS.read_text(encoding="utf-8")
assert "authorizeWorkspaceEscapeNavigation(item)" in src
assert "if(grant.isDir) await loadDir(item.path);" in src
assert "else await openFile(item.path);" in src
def test_read_only_affordances_stay_suppressed(self):
ui_src = UI_JS.read_text(encoding="utf-8")
ws_src = WORKSPACE_JS.read_text(encoding="utf-8")
assert "if(!isReadOnlyEscape){" in ui_src
assert "_workspacePathIsReadOnly(_previewCurrentPath)" in ws_src
assert "_workspacePathIsReadOnly(S.currentDir || '.')" in ws_src
+11
View File
@@ -141,6 +141,17 @@ def test_raw_and_inline_file_targets_carry_anchor_root():
assert "_serve_file_bytes(handler, target, mime, disposition, \"no-store\", csp=csp, anchor_root=anchor_root)" in raw_handler
def test_escape_raw_and_read_routes_use_authorized_helpers():
src = ROUTES_PY.read_text(encoding="utf-8")
escape_read = _func_body(src, "_handle_escape_file_read")
escape_raw = _func_body(src, "_handle_escape_file_raw")
assert "read_authorized_escape_file_content" in escape_read
assert "raw_authorized_escape_target" in escape_raw
assert "_serve_inline_html_preview(handler, target, \"no-store\", csp=sandbox_csp, anchor_root=anchor_root)" in escape_raw
assert "_serve_file_bytes(handler, target, mime, disposition, \"no-store\", csp=csp, anchor_root=anchor_root)" in escape_raw
def test_upload_archive_cleanup_uses_anchored_helpers():
src = UPLOAD_PY.read_text(encoding="utf-8")
+27
View File
@@ -18,8 +18,10 @@ import pathlib
import urllib.request
import urllib.error
import tempfile
import pytest
from tests._pytest_port import BASE
import api.workspace as w
def get(path):
@@ -79,6 +81,8 @@ class TestSymlinkCycleDetection:
def test_internal_symlink_listed_as_symlink(self, cleanup_test_sessions, tmp_path_factory):
"""Internal symlink dirs should appear with type='symlink', is_dir=True."""
if not w._DIR_FD_OK:
pytest.skip("internal symlink listing is platform-dependent without dir_fd")
ws = tmp_path_factory.mktemp("ws")
target = ws / "target"
target.mkdir()
@@ -109,8 +113,27 @@ class TestSymlinkCycleDetection:
except urllib.error.HTTPError as e:
assert e.code in (400, 404, 500)
def test_escape_authorized_listing_virtualizes_paths(self, cleanup_test_sessions, tmp_path_factory):
ws = tmp_path_factory.mktemp("ws")
target = tmp_path_factory.mktemp("target")
(target / "child.txt").write_text("data", encoding="utf-8")
(ws / "ext").symlink_to(target)
sid, _ = make_session(cleanup_test_sessions, ws)
auth, status = post("/api/escape/authorize", {"session_id": sid, "path": "ext"})
assert status == 200, auth
listed = get(f"/api/escape/list?session_id={sid}&token={auth['token']}&path=ext")
entries = {entry["name"]: entry for entry in listed["entries"]}
assert listed["path"] == "ext"
assert listed["read_only"] is True
assert entries["child.txt"]["path"] == "ext/child.txt"
assert entries["child.txt"]["escape_read_only"] is True
assert str(target) not in json.dumps(listed)
def test_self_referencing_symlink_filtered(self, cleanup_test_sessions, tmp_path_factory):
"""Symlink pointing to the workspace root itself must be filtered out."""
if not w._DIR_FD_OK:
pytest.skip("cycle filtering is platform-dependent without dir_fd")
ws = tmp_path_factory.mktemp("ws")
(ws / "file.txt").write_text("data")
(ws / "loop").symlink_to(ws)
@@ -122,6 +145,8 @@ class TestSymlinkCycleDetection:
def test_ancestor_symlink_filtered(self, cleanup_test_sessions, tmp_path_factory):
"""Symlink pointing to a parent of the workspace must be filtered out."""
if not w._DIR_FD_OK:
pytest.skip("cycle filtering is platform-dependent without dir_fd")
parent = tmp_path_factory.mktemp("parent")
ws = parent / "workspace"
ws.mkdir()
@@ -171,6 +196,8 @@ class TestSymlinkCycleDetection:
def test_internal_symlink_file_entry(self, cleanup_test_sessions, tmp_path_factory):
"""Internal symlink to a file should have is_dir=False and include size."""
if not w._DIR_FD_OK:
pytest.skip("internal symlink metadata is platform-dependent without dir_fd")
ws = tmp_path_factory.mktemp("ws")
real = ws / "real"
real.mkdir()
+68 -1
View File
@@ -1,6 +1,13 @@
import pytest
from api.workspace import list_dir, read_file_content, safe_resolve_ws
from api.workspace import (
authorize_escape_target,
list_authorized_escape_dir,
list_dir,
read_file_content,
resolve_authorized_escape_request,
safe_resolve_ws,
)
def test_safe_resolve_blocks_external_symlink_directory(tmp_path):
@@ -69,6 +76,66 @@ def test_internal_symlink_still_resolves_within_workspace(tmp_path):
assert "inside-link.txt" in {entry["name"] for entry in list_dir(workspace, ".")}
def test_authorized_escape_request_reanchors_descendants(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
workspace.mkdir()
(outside / "nested").mkdir(parents=True)
(outside / "nested" / "inside.txt").write_text("inside", encoding="utf-8")
(workspace / "escape").symlink_to(outside)
grant = authorize_escape_target(workspace, "sess-1", "escape")
resolved = resolve_authorized_escape_request(
workspace,
"sess-1",
grant["token"],
"escape/nested/inside.txt",
)
assert resolved["surface_path"] == "escape"
assert resolved["external_rel"] == "nested/inside.txt"
assert resolved["target"] == outside / "nested" / "inside.txt"
def test_authorized_escape_request_expires_when_surface_target_changes(tmp_path):
workspace = tmp_path / "workspace"
outside_a = tmp_path / "outside-a"
outside_b = tmp_path / "outside-b"
workspace.mkdir()
outside_a.mkdir()
outside_b.mkdir()
escape = workspace / "escape"
escape.symlink_to(outside_a)
grant = authorize_escape_target(workspace, "sess-1", "escape")
escape.unlink()
escape.symlink_to(outside_b)
with pytest.raises(ValueError, match="expired"):
resolve_authorized_escape_request(workspace, "sess-1", grant["token"], "escape")
def test_authorized_listing_keeps_nested_child_escape_display_only(tmp_path):
workspace = tmp_path / "workspace"
outside = tmp_path / "outside"
second_outside = tmp_path / "second-outside"
workspace.mkdir()
outside.mkdir()
second_outside.mkdir()
(workspace / "escape").symlink_to(outside)
(outside / "nested-escape").symlink_to(second_outside)
grant = authorize_escape_target(workspace, "sess-1", "escape")
payload = list_authorized_escape_dir(workspace, "sess-1", grant["token"], "escape")
entries = {entry["name"]: entry for entry in payload["entries"]}
assert entries["nested-escape"]["path"] == "escape/nested-escape"
assert entries["nested-escape"]["target_outside_workspace"] is True
assert entries["nested-escape"]["escape_read_only"] is True
assert "target" not in entries["nested-escape"]
# ── TOCTOU hardening (#3398): a path that passes safe_resolve_ws() but is then
# swapped to an external symlink before the open must not read/list/write
# outside the workspace. The read/list/write paths use a portable anchored