agent-Specialization/modules/file_manager/path_mixin.py
JOJO f661dc6456 refactor(server,modules,utils,frontend): split oversized modules into packages and fix regressions
- Split server/chat.py, server/status.py, server/tasks.py into sub-packages
- Split utils/context_manager.py, utils/conversation_manager.py into mixin packages
- Split modules/file_manager.py, persistent_terminal.py, terminal_ops.py, mcp_client_manager.py into packages
- Split core/main_terminal_parts/context.py into base + mixins
- Split static/src/app/methods/{ui,message,upload,taskPolling,conversation} into sub-modules
- Fix flattened method exports, dynamic import paths, missing cross-module imports
- Add missing permission/network/execution mode constants and _PERMISSION_MODE_LABEL
- Update AGENTS.md, CLAUDE.md and project memories to reflect new structure
2026-06-20 15:54:48 +08:00

180 lines
6.6 KiB
Python

# modules/file_manager.py - 文件管理模块(添加行编辑功能)
import os
import shutil
from pathlib import Path
import re
from bisect import bisect_right
from typing import Any, Optional, Dict, List, Set, Tuple, TYPE_CHECKING
from datetime import datetime
try:
from config import (
MAX_FILE_SIZE,
FORBIDDEN_PATHS,
FORBIDDEN_ROOT_PATHS,
OUTPUT_FORMATS,
READ_TOOL_MAX_FILE_SIZE,
PROJECT_MAX_STORAGE_BYTES,
TERMINAL_SANDBOX_MODE,
LINUX_SAFETY,
)
except ImportError: # 兼容全局环境中存在同名包的情况
import sys
from pathlib import Path
project_root = Path(__file__).resolve().parents[1]
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
from config import (
MAX_FILE_SIZE,
FORBIDDEN_PATHS,
FORBIDDEN_ROOT_PATHS,
OUTPUT_FORMATS,
READ_TOOL_MAX_FILE_SIZE,
PROJECT_MAX_STORAGE_BYTES,
TERMINAL_SANDBOX_MODE,
LINUX_SAFETY,
)
from modules.container_file_proxy import ContainerFileProxy
from modules.host_sandbox_policy import get_macos_writable_paths, get_macos_readable_paths
from utils.logger import setup_logger
if TYPE_CHECKING:
from modules.user_container_manager import ContainerHandle
# 临时禁用长度检查
DISABLE_LENGTH_CHECK = True
logger = setup_logger(__name__)
class PathMixin:
"""FileManager path mixin 能力 mixin。"""
def _get_project_size(self) -> int:
"""计算项目目录的总大小(字节),遇到异常时记录并抛出。"""
if not self._is_docker_mode():
return 0
total = 0
if not self.project_path.exists():
return 0
for path in self.project_path.rglob('*'):
if not path.is_file():
continue
try:
total += path.stat().st_size
except Exception as exc:
logger.error(
"Failed to stat %s while calculating project size: %s",
path,
exc,
exc_info=True,
)
raise
return total
def _validate_path(self, path: str) -> Tuple[bool, str, Path]:
"""
验证路径安全性
Returns:
(是否有效, 错误信息, 完整路径)
"""
original_path = path
project_root = Path(self.project_path).resolve()
if project_root != self.project_path:
self.project_path = project_root
if self._is_host_mode():
normalized = (path or "").strip()
if normalized == "/workspace":
normalized = ""
elif normalized.startswith("/workspace/"):
normalized = normalized.split("/workspace/", 1)[1]
if not normalized:
return True, "", project_root
if Path(normalized).is_absolute() or (len(normalized) > 1 and normalized[1] == ":"):
full_path = Path(normalized).expanduser().resolve()
else:
full_path = (project_root / normalized).resolve()
return True, "", full_path
# 不允许绝对路径(除非是在项目内的绝对路径)
if path.startswith('/') or path.startswith('\\') or (len(path) > 1 and path[1] == ':'):
# 如果是绝对路径,检查是否指向项目内
try:
test_path = Path(path).resolve()
test_path.relative_to(project_root)
# 如果成功,说明绝对路径在项目内,转换为相对路径
path = str(test_path.relative_to(project_root))
except ValueError:
if str(original_path).replace("\\", "/").startswith("/workspace"):
return False, "路径必须在项目文件夹内。请检查是否使用的是不带/workspace的相对路径。", None
return False, "路径必须在项目文件夹内", None
# 检查是否包含向上遍历
if ".." in path:
return False, "不允许使用../向上遍历", None
# 构建完整路径
full_path = (project_root / path).resolve()
# 检查是否在项目目录内
try:
full_path.relative_to(project_root)
except ValueError:
return False, "路径必须在项目文件夹内", None
# 检查禁止的路径
path_str = str(full_path)
for forbidden_root in FORBIDDEN_ROOT_PATHS:
if path_str == forbidden_root:
return False, f"禁止访问根目录: {forbidden_root}", None
for forbidden in FORBIDDEN_PATHS:
if path_str.startswith(forbidden + os.sep) or path_str == forbidden:
return False, f"禁止访问系统目录: {forbidden}", None
return True, "", full_path
def _relative_path(self, full_path: Path) -> str:
try:
return str(full_path.relative_to(self.project_path))
except ValueError:
return str(full_path)
@staticmethod
def _path_in_allowed_roots(target: Path, roots: List[Path]) -> bool:
for root in roots:
try:
target.relative_to(root)
return True
except Exception:
continue
return False
def _host_allowed_roots(self, access: str) -> List[Path]:
roots: List[Path] = [self.project_path.resolve(), Path("/tmp").resolve(), Path("/private/tmp").resolve()]
raw_items = get_macos_writable_paths() if access == "write" else get_macos_readable_paths()
for raw in raw_items:
try:
p = Path(raw).expanduser().resolve()
except Exception:
continue
if p not in roots:
roots.append(p)
return roots
def _ensure_host_access(self, full_path: Path, access: str) -> Tuple[bool, str]:
if not self._is_host_mode():
return True, ""
check_target = full_path
if access == "write" and not full_path.exists():
check_target = full_path.parent.resolve()
allowed_roots = self._host_allowed_roots(access)
if self._path_in_allowed_roots(check_target.resolve(), allowed_roots):
return True, ""
if access == "write":
return False, "目标路径不在可写授权范围内,请在路径授权中添加后重试。"
return False, "目标路径不在可读授权范围内,请在路径授权中添加后重试。"