mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:11:58 +00:00
db328efd21
- R-a12 create_bytes:硬链接不了的文件系统(exFAT、一部分 SMB / FUSE,EPERM / ENOTSUP / EXDEV)不再首次启动就崩,退回 O_CREAT | O_EXCL 直接建(仍然只建一次,说明里写了写的那一会儿读的一方可能读到不完整);建成后 fsync 目录(同 raw_store) - R-a13 file_write 的追加改回原地追加写(O_APPEND,core/atomic.append_bytes):不换文件(inode、权限、开着的句柄不变),原有内容不动,文件大了也不用整份读出来重写;取舍写在函数说明里——读的一方最多看到半截新内容。覆盖照旧先写临时文件再改名,并沿用原文件的权限 - R-a11 deploy.sh 改属主前认数据目录收紧:只认 agentlab.db、.secret_key、.agentlab-data 三样之一(不再认 uploads/ 这种普通目录名);.agentlab-data 是服务启动时(Settings.ensure_dirs)建的标记,只建一次不覆盖,库在 PostgreSQL、密钥由环境变量给的部署也认得出;老部署有 agentlab.db 或 .secret_key。deploy/README 写明这个文件别删 - 测试:模拟不能硬链接仍只建一次、权限 0600、不留临时文件;覆盖沿用原权限;追加不换 inode、权限不变;file_write 追加不换文件、覆盖照常;数据目录里有标记、重复启动不覆盖
114 lines
3.8 KiB
Python
114 lines
3.8 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.core import atomic
|
|
from app.core.config import settings
|
|
from app.tools.registry import ToolContext, register
|
|
|
|
_MAX_READ = 200_000
|
|
|
|
|
|
def _resolve(rel: str, ctx: ToolContext) -> Path:
|
|
"""把相对路径锁死在这次运行自己的工作目录里。
|
|
|
|
两道关都要过:session_dir() 先净化 session_id 再定根目录,然后 rel
|
|
先 resolve 再比对根。少了第一道,`../../etc/passwd` 里的越界检查就是
|
|
在一个被挪到 /etc 的"根"下面做的,检查照样通过——这正是之前的漏洞。
|
|
"""
|
|
root = settings.session_dir(ctx.sandbox_session)
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
target = (root / rel.lstrip("/")).resolve()
|
|
if target != root and root not in target.parents:
|
|
raise ValueError(f"路径越界:{rel}")
|
|
return target
|
|
|
|
|
|
class FileReadArgs(BaseModel):
|
|
path: str = Field(description="相对工作目录的文件路径")
|
|
|
|
|
|
@register(
|
|
name="file_read",
|
|
category="文件",
|
|
description="读取工作目录下的文件内容。",
|
|
args_schema=FileReadArgs,
|
|
)
|
|
async def file_read(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
|
|
args = FileReadArgs(**kwargs)
|
|
try:
|
|
target = _resolve(args.path, ctx)
|
|
except ValueError as e:
|
|
return {"error": str(e)}
|
|
if not target.exists():
|
|
return {"error": f"文件不存在:{args.path}"}
|
|
if target.is_dir():
|
|
return {"error": f"{args.path} 是目录,请使用 file_list"}
|
|
data = target.read_bytes()[:_MAX_READ]
|
|
return {
|
|
"path": args.path,
|
|
"size": target.stat().st_size,
|
|
"content": data.decode("utf-8", errors="replace"),
|
|
"truncated": target.stat().st_size > _MAX_READ,
|
|
}
|
|
|
|
|
|
class FileWriteArgs(BaseModel):
|
|
path: str = Field(description="相对工作目录的文件路径")
|
|
content: str = Field(description="要写入的内容")
|
|
append: bool = Field(default=False, description="追加而不是覆盖")
|
|
|
|
|
|
@register(
|
|
name="file_write",
|
|
category="文件",
|
|
description="写入文件到工作目录。父目录会自动创建。",
|
|
args_schema=FileWriteArgs,
|
|
dangerous=True,
|
|
)
|
|
async def file_write(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
|
|
args = FileWriteArgs(**kwargs)
|
|
try:
|
|
target = _resolve(args.path, ctx)
|
|
except ValueError as e:
|
|
return {"error": str(e)}
|
|
# 覆盖:先写临时文件再改名(core/atomic),读的一方看到的要么是旧的整份、要么是新的整份,权限照旧。追加:原地追加写
|
|
# (O_APPEND),不整份重写——文件的 inode、权限不变,原有内容不动,读的一方最多看到半截新内容
|
|
if args.append:
|
|
atomic.append_bytes(target, args.content.encode("utf-8"))
|
|
else:
|
|
atomic.write_bytes(target, args.content.encode("utf-8"))
|
|
return {"path": args.path, "bytes": target.stat().st_size, "ok": True}
|
|
|
|
|
|
class FileListArgs(BaseModel):
|
|
path: str = Field(default=".", description="要列出的目录")
|
|
|
|
|
|
@register(
|
|
name="file_list",
|
|
category="文件",
|
|
description="列出工作目录下的文件。",
|
|
args_schema=FileListArgs,
|
|
)
|
|
async def file_list(ctx: ToolContext, **kwargs: Any) -> dict[str, Any]:
|
|
args = FileListArgs(**kwargs)
|
|
try:
|
|
target = _resolve(args.path, ctx)
|
|
except ValueError as e:
|
|
return {"error": str(e)}
|
|
if not target.exists():
|
|
return {"path": args.path, "entries": []}
|
|
entries = [
|
|
{
|
|
"name": p.name,
|
|
"type": "dir" if p.is_dir() else "file",
|
|
"size": p.stat().st_size if p.is_file() else None,
|
|
}
|
|
for p in sorted(target.iterdir())[:500]
|
|
]
|
|
return {"path": args.path, "entries": entries}
|