feat(workspace): allow browser-only escape-target navigation (#4582)
# Conflicts: # static/workspace.js
This commit is contained in:
1 parent
fa3d612a4e
commit
0f80113c12
9 files changed
+954
-125
No files matched your search
+145
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 ? `💾 ${t('save')}` : `✎ ${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
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user