Coverage for agentos/system/file_ops.py: 20%
192 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 10:59 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-08 10:59 +0800
1"""
2文件操作模块 — 带权限检查的文件系统读写。
4设计原则:
5- 所有操作前检查权限
6- 读操作可穿透任意路径
7- 写操作区分沙箱/全盘
8- 操作结果统一为 FileOpResult
9"""
11from __future__ import annotations
13import mimetypes
14import os
15import shutil
16from dataclasses import dataclass, field
17from datetime import datetime
19from agentos.system.permissions import (
20 PermissionDenied,
21 PermissionTier,
22 SystemPermissionManager,
23)
26@dataclass
27class FileListing:
28 """文件/目录条目。"""
30 name: str
31 path: str
32 is_dir: bool
33 size_bytes: int = 0
34 modified_at: str = ""
35 mime_type: str = ""
38@dataclass
39class FileOpResult:
40 """文件操作结果。"""
42 success: bool
43 action: str # read/write/delete/move/copy/mkdir/list
44 path: str
45 content: str = "" # 读取的内容
46 listing: list[FileListing] = field(default_factory=list)
47 error: str = ""
48 bytes_written: int = 0
51class FileOperator:
52 """文件操作器 — 带权限检查的文件系统接口。"""
54 def __init__(self, perm_manager: SystemPermissionManager, session_id: str):
55 self._pm = perm_manager
56 self._sid = session_id
58 # ── 读取操作 ──
60 def read(self, file_path: str) -> FileOpResult:
61 """读取文件内容。"""
62 path = os.path.abspath(os.path.expanduser(file_path))
63 try:
64 self._pm.require(self._sid, PermissionTier.READ, path)
65 except PermissionDenied as e:
66 return FileOpResult(success=False, action="read", path=path, error=str(e))
68 try:
69 # 自动检测是否为文本文件
70 mime, _ = mimetypes.guess_type(path)
71 if (
72 mime
73 and mime.startswith("text/")
74 or path.endswith(
75 (
76 ".py",
77 ".md",
78 ".txt",
79 ".json",
80 ".yaml",
81 ".yml",
82 ".toml",
83 ".cfg",
84 ".ini",
85 ".log",
86 ".csv",
87 ".xml",
88 ".html",
89 ".css",
90 ".js",
91 ".ts",
92 ".sh",
93 ".bash",
94 ".env",
95 ".gitignore",
96 )
97 )
98 ):
99 with open(path, encoding="utf-8", errors="replace") as f:
100 content = f.read()
101 return FileOpResult(success=True, action="read", path=path, content=content)
102 else:
103 # 二进制文件返回预览
104 size = os.path.getsize(path)
105 return FileOpResult(
106 success=True,
107 action="read",
108 path=path,
109 content=f"[Binary file, {self._format_size(size)}]",
110 )
111 except Exception as e:
112 return FileOpResult(success=False, action="read", path=path, error=str(e))
114 def read_bytes(self, file_path: str, max_bytes: int = 1024 * 1024) -> FileOpResult:
115 """读取二进制文件(限制大小)。"""
116 path = os.path.abspath(os.path.expanduser(file_path))
117 try:
118 self._pm.require(self._sid, PermissionTier.READ, path)
119 except PermissionDenied as e:
120 return FileOpResult(success=False, action="read", path=path, error=str(e))
122 try:
123 with open(path, "rb") as f:
124 data = f.read(max_bytes)
125 # Base64 编码返回
126 import base64
128 encoded = base64.b64encode(data).decode("ascii")
129 return FileOpResult(
130 success=True,
131 action="read",
132 path=path,
133 content=encoded,
134 bytes_written=len(data),
135 )
136 except Exception as e:
137 return FileOpResult(success=False, action="read", path=path, error=str(e))
139 # ── 列表操作 ──
141 def list_dir(self, dir_path: str, show_hidden: bool = False) -> FileOpResult:
142 """列出目录内容。"""
143 path = os.path.abspath(os.path.expanduser(dir_path))
144 try:
145 self._pm.require(self._sid, PermissionTier.READ, path)
146 except PermissionDenied as e:
147 return FileOpResult(success=False, action="list", path=path, error=str(e))
149 if not os.path.isdir(path):
150 return FileOpResult(success=False, action="list", path=path, error=f"不是目录: {path}")
152 try:
153 entries = []
154 for name in sorted(os.listdir(path)):
155 if not show_hidden and name.startswith("."):
156 continue
157 full = os.path.join(path, name)
158 stat = os.stat(full)
159 mime, _ = mimetypes.guess_type(full)
160 entries.append(
161 FileListing(
162 name=name,
163 path=full,
164 is_dir=os.path.isdir(full),
165 size_bytes=stat.st_size,
166 modified_at=datetime.fromtimestamp(stat.st_mtime).isoformat(),
167 mime_type=mime
168 or (
169 "inode/directory" if os.path.isdir(full) else "application/octet-stream"
170 ),
171 )
172 )
173 return FileOpResult(success=True, action="list", path=path, listing=entries)
174 except Exception as e:
175 return FileOpResult(success=False, action="list", path=path, error=str(e))
177 def search(self, root_dir: str, pattern: str, max_depth: int = 5) -> FileOpResult:
178 """递归搜索文件(类似 find + glob)。"""
179 import fnmatch
181 path = os.path.abspath(os.path.expanduser(root_dir))
182 try:
183 self._pm.require(self._sid, PermissionTier.READ, path)
184 except PermissionDenied as e:
185 return FileOpResult(success=False, action="list", path=path, error=str(e))
187 results: list[FileListing] = []
188 try:
189 for dirpath, dirnames, filenames in os.walk(path):
190 depth = dirpath[len(path) :].count(os.sep)
191 if depth >= max_depth:
192 dirnames.clear()
193 continue
194 # 跳过隐藏目录
195 dirnames[:] = [d for d in dirnames if not d.startswith(".")]
196 for fname in filenames:
197 if fnmatch.fnmatch(fname, pattern):
198 full = os.path.join(dirpath, fname)
199 stat = os.stat(full)
200 results.append(
201 FileListing(
202 name=fname,
203 path=full,
204 is_dir=False,
205 size_bytes=stat.st_size,
206 modified_at=datetime.fromtimestamp(stat.st_mtime).isoformat(),
207 )
208 )
209 return FileOpResult(success=True, action="list", path=path, listing=results)
210 except Exception as e:
211 return FileOpResult(success=False, action="list", path=path, error=str(e))
213 # ── 写入操作 ──
215 def write(self, file_path: str, content: str) -> FileOpResult:
216 """写入文本文件。"""
217 path = os.path.abspath(os.path.expanduser(file_path))
218 # 判断需要沙箱还是全盘权限
219 sandbox_paths = ["/tmp/agentos/", "/home/marvis/Marvis/"]
220 needs_full = not any(path.startswith(sp) for sp in sandbox_paths)
221 tier = PermissionTier.WRITE_ALL if needs_full else PermissionTier.WRITE_SANDBOX
223 try:
224 self._pm.require(self._sid, tier, path)
225 except PermissionDenied as e:
226 return FileOpResult(success=False, action="write", path=path, error=str(e))
228 try:
229 os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
230 with open(path, "w", encoding="utf-8") as f:
231 f.write(content)
232 return FileOpResult(
233 success=True,
234 action="write",
235 path=path,
236 bytes_written=len(content.encode("utf-8")),
237 )
238 except Exception as e:
239 return FileOpResult(success=False, action="write", path=path, error=str(e))
241 def write_bytes(self, file_path: str, data: bytes) -> FileOpResult:
242 """写入二进制文件。"""
243 path = os.path.abspath(os.path.expanduser(file_path))
244 sandbox_paths = ["/tmp/agentos/", "/home/marvis/Marvis/"]
245 needs_full = not any(path.startswith(sp) for sp in sandbox_paths)
246 tier = PermissionTier.WRITE_ALL if needs_full else PermissionTier.WRITE_SANDBOX
248 try:
249 self._pm.require(self._sid, tier, path)
250 except PermissionDenied as e:
251 return FileOpResult(success=False, action="write", path=path, error=str(e))
253 try:
254 os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
255 with open(path, "wb") as f:
256 f.write(data)
257 return FileOpResult(success=True, action="write", path=path, bytes_written=len(data))
258 except Exception as e:
259 return FileOpResult(success=False, action="write", path=path, error=str(e))
261 # ── 删除/移动 ──
263 def delete(self, target_path: str) -> FileOpResult:
264 """删除文件或目录(高风险,需 WRITE_ALL 权限)。"""
265 path = os.path.abspath(os.path.expanduser(target_path))
266 try:
267 self._pm.require(self._sid, PermissionTier.WRITE_ALL, path)
268 except PermissionDenied as e:
269 return FileOpResult(success=False, action="delete", path=path, error=str(e))
271 try:
272 if os.path.isdir(path):
273 shutil.rmtree(path)
274 else:
275 os.remove(path)
276 return FileOpResult(success=True, action="delete", path=path)
277 except Exception as e:
278 return FileOpResult(success=False, action="delete", path=path, error=str(e))
280 def move(self, src: str, dst: str) -> FileOpResult:
281 """移动/重命名文件。"""
282 src_path = os.path.abspath(os.path.expanduser(src))
283 dst_path = os.path.abspath(os.path.expanduser(dst))
284 try:
285 self._pm.require(self._sid, PermissionTier.WRITE_ALL, src_path)
286 self._pm.require(self._sid, PermissionTier.WRITE_ALL, dst_path)
287 except PermissionDenied as e:
288 return FileOpResult(success=False, action="move", path=src_path, error=str(e))
290 try:
291 shutil.move(src_path, dst_path)
292 return FileOpResult(success=True, action="move", path=dst_path)
293 except Exception as e:
294 return FileOpResult(success=False, action="move", path=src_path, error=str(e))
296 def mkdir(self, dir_path: str) -> FileOpResult:
297 """创建目录。"""
298 path = os.path.abspath(os.path.expanduser(dir_path))
299 sandbox_paths = ["/tmp/agentos/", "/home/marvis/Marvis/"]
300 needs_full = not any(path.startswith(sp) for sp in sandbox_paths)
301 tier = PermissionTier.WRITE_ALL if needs_full else PermissionTier.WRITE_SANDBOX
303 try:
304 self._pm.require(self._sid, tier, path)
305 except PermissionDenied as e:
306 return FileOpResult(success=False, action="mkdir", path=path, error=str(e))
308 try:
309 os.makedirs(path, exist_ok=True)
310 return FileOpResult(success=True, action="mkdir", path=path)
311 except Exception as e:
312 return FileOpResult(success=False, action="mkdir", path=path, error=str(e))
314 # ── 文件信息 ──
316 def stat(self, file_path: str) -> FileOpResult:
317 """获取文件/目录详细信息。"""
318 path = os.path.abspath(os.path.expanduser(file_path))
319 try:
320 self._pm.require(self._sid, PermissionTier.READ, path)
321 except PermissionDenied as e:
322 return FileOpResult(success=False, action="read", path=path, error=str(e))
324 try:
325 st = os.stat(path)
326 info_lines = [
327 f"路径: {path}",
328 f"类型: {'目录' if os.path.isdir(path) else '文件'}",
329 f"大小: {self._format_size(st.st_size)}",
330 f"权限: {oct(st.st_mode)[-3:]}",
331 f"修改时间: {datetime.fromtimestamp(st.st_mtime).isoformat()}",
332 f"创建时间: {datetime.fromtimestamp(st.st_ctime).isoformat()}",
333 f"inode: {st.st_ino}",
334 ]
335 return FileOpResult(
336 success=True, action="read", path=path, content="\n".join(info_lines)
337 )
338 except Exception as e:
339 return FileOpResult(success=False, action="read", path=path, error=str(e))
341 # ── 工具 ──
343 @staticmethod
344 def _format_size(size: int) -> str:
345 for unit in ["B", "KB", "MB", "GB", "TB"]:
346 if size < 1024:
347 return f"{size:.1f} {unit}"
348 size /= 1024
349 return f"{size:.1f} PB"