feat: containerize terminals and add resource controls

This commit is contained in:
JOJO 2025-11-23 18:49:35 +08:00
parent f3206357e9
commit b81d6760bd
25 changed files with 1177 additions and 180 deletions

49
.env.example Normal file
View File

@ -0,0 +1,49 @@
# === API Keys ===============================================================
# 基础推理模型(必填)
AGENT_API_BASE_URL=https://api.example.com
AGENT_API_KEY=your-api-key
AGENT_MODEL_ID=deepseek-chat
# 智能思考模型(可选,留空则回退到基础模型)
AGENT_THINKING_API_BASE_URL=
AGENT_THINKING_API_KEY=
AGENT_THINKING_MODEL_ID=
# 第三方搜索(可选)
AGENT_TAVILY_API_KEY=
# 每轮最大响应 token可选
AGENT_DEFAULT_RESPONSE_MAX_TOKENS=32768
# === 管理员账户 ===============================================================
# 使用 `python - <<'PY' ... generate_password_hash` 生成哈希
AGENT_ADMIN_USERNAME=admin
AGENT_ADMIN_PASSWORD_HASH=
# === Web Session =============================================================
WEB_SECRET_KEY=replace-with-random-hex
# === 终端容器沙箱 =============================================================
# 模式docker / host
TERMINAL_SANDBOX_MODE=docker
# 容器镜像及挂载路径
TERMINAL_SANDBOX_IMAGE=python:3.11-slim
TERMINAL_SANDBOX_MOUNT_PATH=/workspace
TERMINAL_SANDBOX_SHELL=/bin/bash
# 资源与网络限制
TERMINAL_SANDBOX_NETWORK=none
TERMINAL_SANDBOX_CPUS=0.5
TERMINAL_SANDBOX_MEMORY=1g
# 附加绑定目录(逗号分隔,可留空)
TERMINAL_SANDBOX_BINDS=
# 运行时路径及容器名称前缀
TERMINAL_SANDBOX_BIN=docker
TERMINAL_SANDBOX_NAME_PREFIX=agent-term
# 启动失败时是否强制报错1=强制容器0=允许回退到宿主机)
TERMINAL_SANDBOX_REQUIRE=0
# 注入到容器的额外环境变量(以 TERMINAL_SANDBOX_ENV_* 命名)
# 例如TERMINAL_SANDBOX_ENV_HTTP_PROXY=http://127.0.0.1:7890
# === 资源控制 ================================================================
PROJECT_MAX_STORAGE_MB=2048
MAX_ACTIVE_USER_CONTAINERS=8

View File

@ -95,29 +95,27 @@
pip install -r requirements.txt
```
2. **配置模型与限额**
`config/api.py` 设置大模型 API 信息,在 `config/limits.py` 调整工具阈值;常用示例:
```python
# config/api.py
API_BASE_URL = "https://api.deepseek.com"
API_KEY = "sk-..."
MODEL_ID = "deepseek-chat"
TAVILY_API_KEY = "tvly-..." # 可选
# config/limits.py
READ_TOOL_DEFAULT_MAX_CHARS = 30000
READ_TOOL_DEFAULT_CONTEXT_BEFORE = 1
READ_TOOL_DEFAULT_CONTEXT_AFTER = 1
READ_TOOL_MAX_MATCHES = 50
2. **配置环境变量(敏感信息)**
```bash
cp .env.example .env
# 编辑 .env填写 API Key / 管理员哈希 / 终端沙箱配置
```
其他常用配置:
- `config/paths.py`:项目根目录、数据/日志路径
- `config/terminal.py`:多终端缓冲与超时
- `config/security.py`:敏感命令、路径黑名单、需要确认的操作
- `config/conversation.py`:对话存储、索引与备份
- `config/ui.py`:日志格式与版本号
关键项说明:
- `AGENT_API_BASE_URL` / `AGENT_API_KEY` / `AGENT_MODEL_ID`:模型服务入口(必要)。
- `AGENT_THINKING_*`:可选的“思考模式”专用模型,不填则回退到基础模型。
- `AGENT_ADMIN_USERNAME` / `AGENT_ADMIN_PASSWORD_HASH`:管理员账号(使用 `generate_password_hash` 生成 PBKDF2 哈希)。
- `WEB_SECRET_KEY`Flask Session 密钥,建议使用 `secrets.token_hex(32)`
- `TERMINAL_SANDBOX_*`:终端容器镜像、挂载路径、资源额度(默认 0.5 核心 + 1GB 内存),若本地无 Docker可将 `TERMINAL_SANDBOX_REQUIRE` 置为 `0`,系统会自动退回宿主机终端。
- `PROJECT_MAX_STORAGE_MB`:单个项目允许的最大磁盘占用(默认 2GB超限写入会被拒绝
- `MAX_ACTIVE_USER_CONTAINERS`:并发允许的用户容器数量,超过后会提示“资源繁忙”。
3. **运行方式**
3. **(可选)调整 Python 配置**
- `config/limits.py`:读写/搜索等工具上限。
- `config/paths.py`:项目、日志、数据目录。
- `config/terminal.py`:终端并发数、缓冲大小(环境变量会覆盖默认值)。
- `config/security.py`:命令/路径黑名单及需要二次确认的工具。
4. **运行方式**
```bash
# CLI推荐快速试验
python main.py
@ -128,7 +126,7 @@
```
启动时可选择“快速模式”或“思考模式”,并指定项目路径(默认为 `./project`)。
4. **子智能体测试服务(可选)**
5. **子智能体测试服务(可选)**
```bash
python sub_agent/server.py
# 默认监听 8092 端口,供 create_sub_agent/wait_sub_agent 工具调用
@ -183,6 +181,9 @@
## 开发与测试建议
- **本地运行**`python main.py` / `python web_server.py`;调试 Web 端建议同时开启浏览器控制台观察 WebSocket 日志。
- **终端隔离**:默认通过 `TERMINAL_SANDBOX_*` 配置启动 Docker 容器执行命令,每个终端挂载独立工作区;若本地无容器运行时,可暂时将 `TERMINAL_SANDBOX_REQUIRE=0` 退回宿主机模式。
- **快捷命令环境**`run_command` / `run_python` 会在专用的“工具容器”中执行,复用同一虚拟环境与依赖,减少对宿主机的影响;空闲超过 `TOOLBOX_TERMINAL_IDLE_SECONDS` 会自动释放。
- **资源保护**:容器默认限制为 0.5 vCPU / 1GB 内存,项目磁盘超过 2GB 会拒绝写入;当活跃用户容器达到 `MAX_ACTIVE_USER_CONTAINERS` 时系统会返回“资源繁忙”提示,避免服务器过载。
- **日志**CLI 模式下输出使用 `OUTPUT_FORMATS` 定义的 EmojiWeb 模式所有工具事件都会写入 `logs/debug_stream.log`
- **数据隔离**:多用户目录位于 `users/<username>/`;请避免将真实密钥提交到仓库,必要时扩展 `.gitignore`
- **测试**:暂未配置自动化测试,可参考 `test/` 目录(如 `api_interceptor_server.py`)编写自定义脚本。

View File

@ -1,5 +1,33 @@
"""Config package initializer保持对旧 `from config import ...` 的兼容。"""
import os
from pathlib import Path
def _load_dotenv():
"""在未显式导入 python-dotenv 的情况下,尽量从仓库根目录加载 .env。"""
env_path = Path(__file__).resolve().parents[1] / ".env"
if not env_path.exists():
return
try:
for raw_line in env_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
if key and key not in os.environ:
os.environ[key] = value
except Exception:
# 加载失败时静默继续,保持兼容
pass
_load_dotenv()
from . import api as _api
from . import paths as _paths
from . import limits as _limits

View File

@ -1,19 +1,29 @@
"""API 和外部服务配置。"""
API_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3"
API_KEY = "3e96a682-919d-45c1-acb2-53bc4e9660d3"
MODEL_ID = "kimi-k2-250905"
import os
def _env(name: str, default: str = "", required: bool = False) -> str:
value = os.environ.get(name, default)
if required and not value:
raise RuntimeError(f"缺少必要环境变量: {name}")
return value
API_BASE_URL = _env("AGENT_API_BASE_URL", "https://api.example.com")
API_KEY = _env("AGENT_API_KEY", required=True)
MODEL_ID = _env("AGENT_MODEL_ID", "deepseek-chat")
# 推理模型配置(智能思考模式使用)
THINKING_API_BASE_URL = "https://api.moonshot.cn/v1"
THINKING_API_KEY = "sk-xW0xjfQM6Mp9ZCWMLlnHiRJcpEOIZPTkXcN0dQ15xpZSuw2y"
THINKING_MODEL_ID = "kimi-k2-thinking"
THINKING_API_BASE_URL = _env("AGENT_THINKING_API_BASE_URL", API_BASE_URL)
THINKING_API_KEY = _env("AGENT_THINKING_API_KEY", API_KEY)
THINKING_MODEL_ID = _env("AGENT_THINKING_MODEL_ID", MODEL_ID)
# Tavily 搜索
TAVILY_API_KEY = "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"
# Tavily 搜索(可选)
TAVILY_API_KEY = _env("AGENT_TAVILY_API_KEY", "")
# 默认响应 token 限制
DEFAULT_RESPONSE_MAX_TOKENS = 32768
DEFAULT_RESPONSE_MAX_TOKENS = int(os.environ.get("AGENT_DEFAULT_RESPONSE_MAX_TOKENS", "32768"))
__all__ = [
"API_BASE_URL",
@ -25,9 +35,3 @@ __all__ = [
"THINKING_API_KEY",
"THINKING_MODEL_ID",
]
'''
API_BASE_URL = "https://api.moonshot.cn/v1"
API_KEY = "sk-xW0xjfQM6Mp9ZCWMLlnHiRJcpEOIZPTkXcN0dQ15xpZSuw2y",
MODEL_ID = "kimi-k2-0905-preview"
'''

View File

@ -1,7 +1,9 @@
"""认证与后台账户配置。"""
ADMIN_USERNAME = "jojo"
ADMIN_PASSWORD_HASH = "pbkdf2:sha256:600000$FSNAVncPXW6CBtfj$b7f093f4256de9d1a16d588565d4b1e108a9c66b2901884dd118c515258d78c7"
import os
ADMIN_USERNAME = os.environ.get("AGENT_ADMIN_USERNAME", "")
ADMIN_PASSWORD_HASH = os.environ.get("AGENT_ADMIN_PASSWORD_HASH", "")
__all__ = [
"ADMIN_USERNAME",

View File

@ -1,5 +1,7 @@
"""全局额度与工具限制配置。"""
import os
# 上下文与文件
MAX_CONTEXT_SIZE = 100000
MAX_FILE_SIZE = 10 * 1024 * 1024
@ -36,6 +38,9 @@ READ_TOOL_MAX_CONTEXT_AFTER = 5
READ_TOOL_DEFAULT_MAX_MATCHES = 5
READ_TOOL_MAX_MATCHES = 50
PROJECT_MAX_STORAGE_MB = int(os.environ.get("PROJECT_MAX_STORAGE_MB", "2048"))
PROJECT_MAX_STORAGE_BYTES = PROJECT_MAX_STORAGE_MB * 1024 * 1024
__all__ = [
"MAX_CONTEXT_SIZE",
"MAX_FILE_SIZE",
@ -63,4 +68,6 @@ __all__ = [
"READ_TOOL_MAX_CONTEXT_AFTER",
"READ_TOOL_DEFAULT_MAX_MATCHES",
"READ_TOOL_MAX_MATCHES",
"PROJECT_MAX_STORAGE_MB",
"PROJECT_MAX_STORAGE_BYTES",
]

View File

@ -1,5 +1,7 @@
"""终端与会话管理配置。"""
import os
MAX_TERMINALS = 3
TERMINAL_BUFFER_SIZE = 100000
TERMINAL_DISPLAY_SIZE = 50000
@ -10,6 +12,37 @@ TERMINAL_SNAPSHOT_MAX_LINES = 200
TERMINAL_SNAPSHOT_MAX_CHARS = 6000000
TERMINAL_INPUT_MAX_CHARS = 20000
def _parse_bindings(raw_value: str):
items = []
for chunk in raw_value.split(","):
chunk = chunk.strip()
if not chunk:
continue
items.append(chunk)
return items
_env_prefix = "TERMINAL_SANDBOX_ENV_"
TERMINAL_SANDBOX_MODE = os.environ.get("TERMINAL_SANDBOX_MODE", "docker").lower()
TERMINAL_SANDBOX_IMAGE = os.environ.get("TERMINAL_SANDBOX_IMAGE", "python:3.11-slim")
TERMINAL_SANDBOX_MOUNT_PATH = os.environ.get("TERMINAL_SANDBOX_MOUNT_PATH", "/workspace")
TERMINAL_SANDBOX_SHELL = os.environ.get("TERMINAL_SANDBOX_SHELL", "/bin/bash")
TERMINAL_SANDBOX_NETWORK = os.environ.get("TERMINAL_SANDBOX_NETWORK", "none")
TERMINAL_SANDBOX_CPUS = os.environ.get("TERMINAL_SANDBOX_CPUS", "")
TERMINAL_SANDBOX_MEMORY = os.environ.get("TERMINAL_SANDBOX_MEMORY", "")
TERMINAL_SANDBOX_BINDS = _parse_bindings(os.environ.get("TERMINAL_SANDBOX_BINDS", ""))
TERMINAL_SANDBOX_BIN = os.environ.get("TERMINAL_SANDBOX_BIN", "docker")
TERMINAL_SANDBOX_NAME_PREFIX = os.environ.get("TERMINAL_SANDBOX_NAME_PREFIX", "agent-term")
TERMINAL_SANDBOX_ENV = {
key[len(_env_prefix):]: value
for key, value in os.environ.items()
if key.startswith(_env_prefix)
}
TERMINAL_SANDBOX_REQUIRE = os.environ.get("TERMINAL_SANDBOX_REQUIRE", "0") not in {"0", "false", "False"}
TOOLBOX_TERMINAL_IDLE_SECONDS = int(os.environ.get("TOOLBOX_TERMINAL_IDLE_SECONDS", "900"))
MAX_ACTIVE_USER_CONTAINERS = int(os.environ.get("MAX_ACTIVE_USER_CONTAINERS", "8"))
__all__ = [
"MAX_TERMINALS",
"TERMINAL_BUFFER_SIZE",
@ -20,4 +53,18 @@ __all__ = [
"TERMINAL_SNAPSHOT_MAX_LINES",
"TERMINAL_SNAPSHOT_MAX_CHARS",
"TERMINAL_INPUT_MAX_CHARS",
"TERMINAL_SANDBOX_MODE",
"TERMINAL_SANDBOX_IMAGE",
"TERMINAL_SANDBOX_MOUNT_PATH",
"TERMINAL_SANDBOX_SHELL",
"TERMINAL_SANDBOX_NETWORK",
"TERMINAL_SANDBOX_CPUS",
"TERMINAL_SANDBOX_MEMORY",
"TERMINAL_SANDBOX_BINDS",
"TERMINAL_SANDBOX_BIN",
"TERMINAL_SANDBOX_NAME_PREFIX",
"TERMINAL_SANDBOX_ENV",
"TERMINAL_SANDBOX_REQUIRE",
"TOOLBOX_TERMINAL_IDLE_SECONDS",
"MAX_ACTIVE_USER_CONTAINERS",
]

View File

@ -15,7 +15,11 @@ try:
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
)
except ImportError:
import sys
@ -30,7 +34,11 @@ except ImportError:
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
)
from modules.file_manager import FileManager
from modules.search_engine import SearchEngine
@ -69,6 +77,10 @@ class MainTerminal:
self.api_client = DeepSeekClient(thinking_mode=thinking_mode)
self.context_manager = ContextManager(project_path, data_dir=str(self.data_dir))
self.context_manager.main_terminal = self
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
self.container_cpu_limit = TERMINAL_SANDBOX_CPUS or "未限制"
self.container_memory_limit = TERMINAL_SANDBOX_MEMORY or "未限制"
self.project_storage_limit = f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制"
self.memory_manager = MemoryManager(data_dir=str(self.data_dir))
self.file_manager = FileManager(project_path)
self.search_engine = SearchEngine()
@ -2143,8 +2155,16 @@ class MainTerminal:
system_prompt = self.load_prompt("main_system")
# 格式化系统提示
container_path = self.container_mount_path or "/workspace"
container_cpus = self.container_cpu_limit
container_memory = self.container_memory_limit
project_storage = self.project_storage_limit
system_prompt = system_prompt.format(
project_path=self.project_path,
project_path=container_path,
container_path=container_path,
container_cpus=container_cpus,
container_memory=container_memory,
project_storage=project_storage,
file_tree=context["project_info"]["file_tree"],
memory=context["memory"],
current_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")

45
doc/phase1_summary.md Normal file
View File

@ -0,0 +1,45 @@
# Phase 1 Summary 环境变量与容器隔离
## 已完成的关键改动
1. **配置去硬编码**
- `config/api.py` / `config/auth.py` 读取 `.env` 中的密钥和凭证,`config/__init__.py` 自动加载 `.env`,彻底清理仓库里的明文 key。
- `.env.example` 提供可复制的模板,`.env` 记录当前部署所用值,防止环境遗漏。
2. **终端容器化**
- 所有实时终端、`run_command`、`run_python` 全部运行在 `my-agent-shell` 镜像(基于 `python:3.11-slim`)中;镜像预装常用系统工具与 Python 依赖,并内置虚拟环境。
- 工具容器懒加载,可复用同一个会话,空闲超时自动回收,日志可追踪容器启动/释放。
3. **资源配额**
- `.env` 控制 0.5 vCPU / 1GB RAM 的容器配额,项目目录写入前会进行 2GB 存储配额检查。
- `TERMINAL_SANDBOX_*`/`PROJECT_MAX_STORAGE_MB`/`TOOLBOX_TERMINAL_IDLE_SECONDS`/`MAX_ACTIVE_USER_CONTAINERS` 等可在环境中统一调整。
4. **并发保护与体验**
- 新增 `active_users` 计数,限制同时在线账户总数;超限时 `/login` 与 Ajax 登录都会跳转到 `resource_busy` 页面,提示“资源繁忙,请稍后重试”。
- 登出/会话释放时及时归还 slot确保超过配额不会导致服务器崩溃。
5. **系统提示同步**
- main prompt 中不再暴露宿主机路径,只告知模型“你在隔离容器(挂载目录 `/workspace`)中工作”,附带 CPU/内存/磁盘配额说明,减少对真实环境的暴露。
6. **安全检查**
- `doc/security_review.md` 更新了第一阶段完成项:环境变量治理、终端容器化、资源限制、繁忙提示等,便于下一阶段延续。
## 后续建议
1. **用户级容器**
- 当前仍是“多终端共享多个容器”。下一阶段可收敛为“每用户一个长生命周期容器”,集中处理终端/文件/工具请求,进一步简化资源管理。
2. **文件操作代理**
- 目前 `FileManager` 仍在宿主机写入。可以考虑在用户容器内提供文件服务/API让 append/modify 等也走容器,宿主机只负责挂载和备份。
3. **监控与预警**
- 接入 Docker/系统 metrics记录容器 CPU/Mem/IO 使用情况,并在触达阈值前告警;后续可结合日志汇报高危命令。
4. **CI / 镜像自动构建**
- 将 `docker/terminal.Dockerfile``toolbox-requirements.txt` 纳入 CI pipeline自动构建并推送 `my-agent-shell`,避免手工 build。
5. **权限最小化**
- 研究 rootless Docker 或容器内非 root 用户,进一步降低 /etc/shadow 等容器内敏感文件被误读的风险。
如需接手下一阶段,请先阅读同目录下的 `security_review.md`,了解之前的风险总览及尚未推进的条目。祝顺利!

86
doc/security_review.md Normal file
View File

@ -0,0 +1,86 @@
# AI Agent 系统安全评审
- **评审日期**2024-xx-xx
- **评审范围**:当前开源仓库中的 CLI/Web 入口(`main.py`、`web_server.py`)、核心模块(`core/`, `modules/`)、配置(`config/`)、静态前端(`static/`)及数据目录(`data/`, `users/`)。
- **评审目标**:识别阻碍产品化落地的安全缺口,明确优先级与改进路径,作为后续全面加固(尤其是“终端隔离 + 多租户环境”)的依据。
---
## 1. 系统与数据现状
### 1.1 主要角色 & 组件
- **CLI 入口 (`main.py`)**:默认启动 Web 模式,直接在宿主机 `DEFAULT_PROJECT_PATH` 内读写文件。
- **Web 服务 (`web_server.py`)**:基于 Flask + Socket.IO整合登录、文件操作、终端调度、任务工具调用所有状态保存在单进程内存 + 本地文件夹。
- **用户与工作区 (`modules/user_manager.py`)**:账号、邀请码、工作目录全部持久化到 `./data/*.json`,未引入数据库或权限控制。
- **终端/命令执行 (`modules/terminal_manager.py`, `modules/persistent_terminal.py`)**:调度宿主机 shell 进程,缺乏容器或 cgroup多用户共享一台机器的真实文件系统。
- **静态前端 (`static/index.html`, `static/app.js`)**:大体量单文件脚本,所有逻辑在浏览器中运行,没有打包、模块划分或安全加固。
### 1.3 第一阶段整改进展
- ✅ **Secrets 与配置**:所有 API key/账号信息已迁移至 `.env``config/__init__.py` 启动时自动加载并校验,仓库不再存在明文 key。
- ✅ **终端容器化**:实时终端与 `run_command/run_python` 默认运行在 `my-agent-shell` Docker 容器中,配额为 0.5 vCPU / 1GB 内存,项目目录通过卷挂载;宿主机仅负责持久化。
- ✅ **资源与并发控制**:新增 `PROJECT_MAX_STORAGE_MB`、`MAX_ACTIVE_USER_CONTAINERS` 等限制,写入前会进行配额检查,活跃用户达到上限时提供 `resource_busy` 页面提示。
- ✅ **系统提示**:模型接收的环境信息仅包含容器挂载路径与资源上限,不再暴露宿主机真实路径。
### 1.2 关键数据资产
| 资产 | 存储位置 | 备注 |
| --- | --- | --- |
| 模型/API 密钥 | `config/api.py`, 环境变量(未来) | 目前以明文写在仓库 |
| 管理员凭据 | `config/auth.py` | 包含固定用户名与 PBKDF2 哈希 |
| 用户账号/工作区索引 | `data/users.json`, `data/invite_codes.json`, `users/<uid>/` | JSON 文件无加密、无访问控制 |
| 对话与工具日志 | `data/conversations/*`, `logs/` | 包含终端输出、上传文件、任务上下文 |
| 用户上传文件 | `users/<uid>/project/user_upload` | 与终端共享同一路径 |
---
## 2. 攻击面概览
- **HTTP API**Flask 路由与 REST 接口缺乏 CSRF、防爆破、速率限制`CORS(app)` 与 `cors_allowed_origins="*"` 允许任意来源发起请求。
- **WebSocket/Socket.IO**:所有事件透过会话 cookie 鉴权,没有额外的 token 或设备指纹,连接转发逻辑在内存字典 `connection_users` 中维护。
- **文件上传/下载**`/api/upload`、`/api/gui/files/upload` 接受任意类型文件,未做恶意内容扫描,仅依赖 `MAX_UPLOAD_SIZE=50MB`
- **终端命令执行**:浏览器或 API 调用可直接在宿主机 shell 运行命令,代码运行结果与项目文件落在真实文件系统。
- **配置与日志**:所有 secrets、日志、备份位于仓库根目录可被具备 shell 访问权的任意人读取。
---
## 3. 主要安全发现(按严重度排序)
| # | 严重度 | 问题 & 证据 | 影响 | 建议 |
| --- | --- | --- | --- | --- |
| 1 | Critical | **执行环境无隔离**`modules/persistent_terminal.py:87-178` 直接在宿主机上 `subprocess.Popen` shell所有命令与文件操作共享真实系统。 | 任意 Web 用户可读取/修改宿主机文件、横向移动、破坏系统。 | 必须引入容器/VM 沙箱将项目、依赖与命令执行限制在受控环境并对资源设置限额CPU/Mem/IO。 |
| 2 | Critical | **明文 API Key / Secret**`config/api.py:3-25` 存在硬编码模型 key`config/auth.py:1-7` 暴露管理员用户名 + 哈希。 | 仓库一旦共享即泄漏密钥;攻击者可伪造管理员账户或重放 API 请求。 | 将所有 secrets 挪到环境变量 / Secret Manager删除仓库中的明文在启动时校验缺省值并阻止运行。 |
| 3 | High | **Flask SECRET_KEY 固定且公开**`web_server.py:54-58` 将 `SECRET_KEY='your-secret-key-here'` 写死,且默认启用 `CORS(*)`。 | 攻击者可伪造 session cookie、冒充任意用户、解密/篡改会话。 | 将 SECRET_KEY 存储于环境变量,启用 `SESSION_COOKIE_SECURE/HTTPONLY/SAMESITE`,并限制 CORS 源。 |
| 4 | High | **鉴权与速率限制缺失**:登录接口没有 CAPTCHA/速率限制;`api_login_required` 仅检查 sessionSocket.IO 连接未绑定 IP/指纹。 | 暴力破解、会话劫持、重放攻击均无防护;一旦 cookie 泄漏即可接管终端。 | 引入 Flask-Limiter 等中间件记录失败次数必要时强制多因子或设备锁定WebSocket 握手附带一次性 token。 |
| 5 | High | **多租户数据无物理隔离**:虽然 `UserManager.ensure_user_workspace` 为每个用户创建子目录,但同一进程拥有全部路径读写权限,且 FileManager (`modules/file_manager.py`) 仅按“项目根目录”限制,无法阻止管理员会话访问他人目录。 | 横向越权风险高,任何被攻破的账号都可以遍历/窃取其他租户数据。 | 将每个用户放入独立容器/VM并由调度服务负责映射API 层使用数据库/ACL 校验 user_id 与路径的对应关系。 |
| 6 | Medium | **用户与会话数据存储在本地 JSON**`modules/user_manager.py:167-195` 将账号、密码哈希、邀请码写入 `data/users.json`,没有备份策略、并发安全或加密。 | 易被本地用户读取/篡改;当并发写入时有数据损坏风险,也无法满足审计/恢复。 | 引入关系型数据库或托管身份服务;对敏感字段做透明加密,提供备份与迁移策略。 |
| 7 | Medium | **缺乏 CSRF、防重放与安全头部**:所有 POST/PUT 接口(如 `/api/gui/files/*`, `/api/upload`)均未校验 CSRF token。 | 登陆态用户可被恶意网站诱导发起操作(上传/删除/运行命令)。 | 在 Flask 层加入 CSRF 保护WTF-CSRF 或自定义 token并添加 Strict-Transport-Security、Content-Security-Policy 等响应头。 |
| 8 | Medium | **文件上传仅做文件名校验**`web_server.py:841-907, 985-1069` 只调用 `sanitize_filename_preserve_unicode`,但未检测 MIME、内容或执行权限。 | 可上传脚本并经终端执行;针对 Windows/Unix 的路径和符号链接处理也未覆盖。 | 引入内容扫描ClamAV/自建规则)、限制文件类型/数量,并将上传目录与执行目录隔离。 |
| 9 | Medium | **日志/终端信息缺乏审计与脱敏**`logs/`、`data/conversations/` 中保留所有指令和模型输出,没有访问控制。 | 可能泄漏用户代码、密钥;出现安全事件时也很难追踪。 | 将日志写入集中式系统ELK/ClickHouse对敏感字段脱敏建立查询与保留策略。 |
| 10 | Low | **配置默认值缺乏环境检测**:如 `DEFAULT_PROJECT_PATH="./project"`、`MAX_UPLOAD_SIZE=50MB` 固定写入;`main.py` 未检查当前用户权限。 | 误配置可能导致数据写入到未知磁盘或权限不足引发异常。 | 在启动阶段校验运行环境(磁盘权限、必需目录、环境变量),并提供友好报错与文档。 |
---
## 4. 优先修复路线(建议迭代 0 → 2
1. **密钥治理与配置基线**
- 移除所有硬编码 secrets提供 `.env.example`;启动脚本在缺失密钥时直接失败。
- 配置 Flask session 安全选项、限制 CORS 来源、添加基本安全 HTTP 头。
2. **执行环境隔离 PoC**
- 选定容器技术Docker/LXC/Firecracker`TerminalManager` 改造为“容器调度器”,所有命令走容器内的 `/workspace` 目录。
- 引入任务代理服务FastAPI + Celery/RabbitMQ分离 Web API 与执行层,给每个用户/对话绑定独立容器生命周期。
3. **身份/鉴权与数据持久化升级**
- 使用数据库管理用户、会话与权限加入账号锁定、审计日志、API rate limit。
- 为日志、对话、上传文件建立访问控制与加密策略,提供备份/恢复。
4. **安全运维体系**
- 落地集中日志、异常告警、指标监控;在部署脚本中加入 CIS/Baseline 检查。
- 定期执行依赖扫描pip-audit/Dependabot、容器镜像漏洞检测并把密钥轮换流程流程化。
---
## 5. 后续工作与协作建议
- 建议在 `docs/` 中维护《安全基线》《运维手册》《应急预案》,并在 PR 模板中新增安全检查项。
- 组建最小安全清单:每次上线前确认终端隔离、密钥、日志、鉴权、备份五项指标均处于“通过”状态。
- 在完成 PoC 后再讨论“前端重写、UI 组件化”等体验向任务,确保基础安全能力可复用到后续版本。
> 本文档会随着重构推进持续更新;若需深入某一条风险(如容器隔离方案、密钥管理流程),可另开文档展开细化设计。

View File

@ -0,0 +1,35 @@
FROM python:3.11-slim
ENV DEBIAN_FRONTEND=noninteractive \
LANG=en_US.UTF-8 \
LC_ALL=en_US.UTF-8
RUN apt-get update && \
apt-get install -y --no-install-recommends \
bash \
curl \
wget \
ca-certificates \
git \
build-essential \
openssh-client \
expect \
zip \
unzip \
locales \
tzdata && \
sed -i 's/# en_US.UTF-8/en_US.UTF-8/' /etc/locale.gen && \
locale-gen && \
rm -rf /var/lib/apt/lists/*
WORKDIR /opt/workspace
COPY docker/toolbox-requirements.txt /tmp/toolbox-requirements.txt
RUN python -m venv /opt/agent-venv && \
/opt/agent-venv/bin/pip install --no-cache-dir --upgrade pip && \
/opt/agent-venv/bin/pip install --no-cache-dir -r /tmp/toolbox-requirements.txt && \
rm -f /tmp/toolbox-requirements.txt
ENV AGENT_TOOLBOX_VENV=/opt/agent-venv
ENV PATH="/opt/agent-venv/bin:${PATH}"

View File

@ -0,0 +1,18 @@
requests==2.32.3
httpx==0.28.1
beautifulsoup4==4.12.3
lxml==5.3.0
pandas==2.2.3
numpy==2.1.3
pillow==11.0.0
matplotlib==3.9.2
sympy==1.13.3
tqdm==4.67.1
pyyaml==6.0.2
jinja2==3.1.4
python-docx==1.1.2
docx2txt==0.8
openpyxl==3.1.5
python-pptx==1.0.2
pdfplumber==0.11.4
PyPDF2==3.0.1

View File

@ -12,6 +12,7 @@ try:
FORBIDDEN_ROOT_PATHS,
OUTPUT_FORMATS,
READ_TOOL_MAX_FILE_SIZE,
PROJECT_MAX_STORAGE_BYTES,
)
except ImportError: # 兼容全局环境中存在同名包的情况
import sys
@ -25,12 +26,26 @@ except ImportError: # 兼容全局环境中存在同名包的情况
FORBIDDEN_ROOT_PATHS,
OUTPUT_FORMATS,
READ_TOOL_MAX_FILE_SIZE,
PROJECT_MAX_STORAGE_BYTES,
)
# 临时禁用长度检查
DISABLE_LENGTH_CHECK = True
class FileManager:
def __init__(self, project_path: str):
self.project_path = Path(project_path).resolve()
def _get_project_size(self) -> int:
"""计算项目目录的总大小(字节)"""
total = 0
if not self.project_path.exists():
return 0
for path in self.project_path.rglob('*'):
try:
if path.is_file():
total += path.stat().st_size
except PermissionError:
continue
return total
def _validate_path(self, path: str) -> Tuple[bool, str, Path]:
"""
@ -490,6 +505,20 @@ class FileManager:
print(f"{OUTPUT_FORMATS['warning']} 检测到大量转义字符,建议检查内容格式")
try:
current_size = self._get_project_size()
existing_size = full_path.stat().st_size if full_path.exists() else 0
if mode == "a":
projected_total = current_size + len(content)
else:
projected_total = current_size - existing_size + len(content)
if PROJECT_MAX_STORAGE_BYTES and projected_total > PROJECT_MAX_STORAGE_BYTES:
return {
"success": False,
"error": "写入失败:超出项目磁盘配额",
"limit_bytes": PROJECT_MAX_STORAGE_BYTES,
"project_size_bytes": current_size,
"attempt_size_bytes": len(content)
}
# 创建父目录
full_path.parent.mkdir(parents=True, exist_ok=True)

View File

@ -11,15 +11,49 @@ from datetime import datetime
import threading
import queue
from collections import deque
import shutil
import uuid
try:
from config import OUTPUT_FORMATS, TERMINAL_OUTPUT_WAIT, TERMINAL_INPUT_MAX_CHARS
from config import (
OUTPUT_FORMATS,
TERMINAL_OUTPUT_WAIT,
TERMINAL_INPUT_MAX_CHARS,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_IMAGE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_SHELL,
TERMINAL_SANDBOX_NETWORK,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
TERMINAL_SANDBOX_BINDS,
TERMINAL_SANDBOX_BIN,
TERMINAL_SANDBOX_NAME_PREFIX,
TERMINAL_SANDBOX_ENV,
TERMINAL_SANDBOX_REQUIRE,
)
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 OUTPUT_FORMATS, TERMINAL_OUTPUT_WAIT, TERMINAL_INPUT_MAX_CHARS
from config import (
OUTPUT_FORMATS,
TERMINAL_OUTPUT_WAIT,
TERMINAL_INPUT_MAX_CHARS,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_IMAGE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_SHELL,
TERMINAL_SANDBOX_NETWORK,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
TERMINAL_SANDBOX_BINDS,
TERMINAL_SANDBOX_BIN,
TERMINAL_SANDBOX_NAME_PREFIX,
TERMINAL_SANDBOX_ENV,
TERMINAL_SANDBOX_REQUIRE,
)
class PersistentTerminal:
"""单个持久化终端实例"""
@ -31,7 +65,10 @@ class PersistentTerminal:
shell_command: str = None,
broadcast_callback: Callable = None,
max_buffer_size: int = 20000,
display_size: int = 5000
display_size: int = 5000,
project_path: Optional[str] = None,
sandbox_mode: Optional[str] = None,
sandbox_options: Optional[Dict] = None,
):
"""
初始化持久化终端
@ -45,7 +82,9 @@ class PersistentTerminal:
display_size: 显示大小限制
"""
self.session_name = session_name
self.working_dir = Path(working_dir) if working_dir else Path.cwd()
self.working_dir = Path(working_dir).resolve() if working_dir else Path.cwd()
self.project_path = Path(project_path).resolve() if project_path else self.working_dir
self.host_shell_command = shell_command
self.shell_command = shell_command
self.broadcast = broadcast_callback
self.max_buffer_size = max_buffer_size
@ -83,49 +122,127 @@ class PersistentTerminal:
# 系统特定设置
self.is_windows = sys.platform == "win32"
sandbox_defaults = {
"image": TERMINAL_SANDBOX_IMAGE,
"mount_path": TERMINAL_SANDBOX_MOUNT_PATH,
"shell": TERMINAL_SANDBOX_SHELL,
"network": TERMINAL_SANDBOX_NETWORK,
"cpus": TERMINAL_SANDBOX_CPUS,
"memory": TERMINAL_SANDBOX_MEMORY,
"binds": list(TERMINAL_SANDBOX_BINDS),
"bin": TERMINAL_SANDBOX_BIN,
"name_prefix": TERMINAL_SANDBOX_NAME_PREFIX,
"env": dict(TERMINAL_SANDBOX_ENV),
"require": TERMINAL_SANDBOX_REQUIRE,
}
if sandbox_options:
for key, value in sandbox_options.items():
if key == "binds" and isinstance(value, list):
sandbox_defaults[key] = list(value)
elif key == "env" and isinstance(value, dict):
sandbox_defaults[key] = dict(value)
else:
sandbox_defaults[key] = value
self.sandbox_mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "host").lower()
self.sandbox_options = sandbox_defaults
self.sandbox_required = bool(self.sandbox_options.get("require"))
self.sandbox_container_name = None
self.execution_mode = "host"
self.using_container = False
self._sandbox_bin_path = None
def start(self) -> bool:
"""启动终端进程(统一处理编码)"""
"""启动终端进程(支持容器沙箱"""
if self.is_running:
return False
try:
process = None
selected_mode = self.sandbox_mode
if selected_mode == "docker":
try:
process = self._start_docker_terminal()
except Exception as exc:
message = f"容器终端启动失败: {exc}"
if self.sandbox_required:
print(f"{OUTPUT_FORMATS['error']} {message}")
return False
print(f"{OUTPUT_FORMATS['warning']} {message},回退到宿主机终端。")
process = None
selected_mode = "host"
if process is None:
process = self._start_host_terminal()
selected_mode = "host"
if not process:
return False
self.process = process
self.is_running = True
self.execution_mode = "docker" if self.using_container else "host"
self.start_time = datetime.now()
self.last_output_time = None
self.last_input_time = None
self.last_input_text = ""
self.echo_loop_detected = False
self._consecutive_echo_matches = 0
# 启动输出读取线程
self.is_reading = True
self.reader_thread = threading.Thread(target=self._read_output)
self.reader_thread.daemon = True
self.reader_thread.start()
# 宿主机Windows初始化
if self.is_windows and not self.using_container:
time.sleep(0.5)
self.send_command("chcp 65001", wait_for_output=False)
time.sleep(0.5)
self.send_command("cls", wait_for_output=False)
time.sleep(0.3)
self.output_buffer.clear()
self.total_output_size = 0
# 广播终端启动事件
if self.broadcast:
self.broadcast('terminal_started', {
'session': self.session_name,
'working_dir': str(self.working_dir),
'shell': self.shell_command,
'mode': self.execution_mode,
'time': self.start_time.isoformat()
})
mode_label = "容器" if self.using_container else "宿主机"
print(f"{OUTPUT_FORMATS['success']} 终端会话启动({mode_label}): {self.session_name}")
return True
except Exception as e:
print(f"{OUTPUT_FORMATS['error']} 终端启动失败: {e}")
self.is_running = False
if self.using_container:
self._stop_sandbox_container(force=True)
return False
def _start_host_terminal(self):
"""启动宿主机终端"""
self.using_container = False
self.is_windows = sys.platform == "win32"
shell_cmd = self.host_shell_command
if self.is_windows:
shell_cmd = shell_cmd or "cmd.exe"
else:
shell_cmd = shell_cmd or os.environ.get('SHELL', '/bin/bash')
self.shell_command = shell_cmd
env = os.environ.copy()
env['PYTHONIOENCODING'] = 'utf-8'
try:
# 确定使用的shell
if self.is_windows:
# Windows下使用CMD
self.shell_command = self.shell_command or "cmd.exe"
else:
# Unix系统
self.shell_command = self.shell_command or os.environ.get('SHELL', '/bin/bash')
# 设置环境变量
env = os.environ.copy()
env['PYTHONIOENCODING'] = 'utf-8'
if self.is_windows:
# Windows特殊设置
env['CHCP'] = '65001' # UTF-8代码页
# Windows统一不使用text模式手动处理编码
self.process = subprocess.Popen(
self.shell_command,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=str(self.working_dir),
shell=False,
bufsize=0, # 无缓冲
env=env
)
else:
# Unix系统
env['TERM'] = 'xterm-256color'
env['LANG'] = 'en_US.UTF-8'
env['LC_ALL'] = 'en_US.UTF-8'
# Unix也不使用text模式统一处理
self.process = subprocess.Popen(
self.shell_command,
env['CHCP'] = '65001'
process = subprocess.Popen(
shell_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
@ -134,48 +251,114 @@ class PersistentTerminal:
bufsize=0,
env=env
)
self.is_running = True
self.start_time = datetime.now()
self.last_output_time = None
self.last_input_time = None
self.last_input_text = ""
self.echo_loop_detected = False
self._consecutive_echo_matches = 0
# 启动输出读取线程
self.is_reading = True
self.reader_thread = threading.Thread(target=self._read_output)
self.reader_thread.daemon = True
self.reader_thread.start()
# 如果是Windows设置代码页
if self.is_windows:
time.sleep(0.5) # 等待终端初始化
self.send_command("chcp 65001", wait_for_output=False)
time.sleep(0.5)
# 清屏以去除代码页设置的输出
self.send_command("cls", wait_for_output=False)
time.sleep(0.3)
self.output_buffer.clear() # 清除初始化输出
self.total_output_size = 0
# 广播终端启动事件
if self.broadcast:
self.broadcast('terminal_started', {
'session': self.session_name,
'working_dir': str(self.working_dir),
'shell': self.shell_command,
'time': self.start_time.isoformat()
})
print(f"{OUTPUT_FORMATS['success']} 终端会话启动: {self.session_name}")
return True
except Exception as e:
print(f"{OUTPUT_FORMATS['error']} 终端启动失败: {e}")
self.is_running = False
return False
else:
env['TERM'] = 'xterm-256color'
env['LANG'] = 'en_US.UTF-8'
env['LC_ALL'] = 'en_US.UTF-8'
process = subprocess.Popen(
shell_cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=str(self.working_dir),
shell=False,
bufsize=0,
env=env
)
return process
except FileNotFoundError:
print(f"{OUTPUT_FORMATS['error']} 无法找到终端程序: {shell_cmd}")
return None
def _start_docker_terminal(self):
"""启动容器化终端"""
docker_bin = self.sandbox_options.get("bin") or "docker"
docker_path = shutil.which(docker_bin)
if not docker_path:
message = f"未找到容器运行时: {docker_bin}"
if self.sandbox_required:
raise RuntimeError(message)
print(f"{OUTPUT_FORMATS['warning']} {message}")
return None
image = self.sandbox_options.get("image")
if not image:
raise RuntimeError("TERMINAL_SANDBOX_IMAGE 未配置")
mount_path = self.sandbox_options.get("mount_path") or "/workspace"
working_dir = str(self.working_dir)
if not self.working_dir.exists():
self.working_dir.mkdir(parents=True, exist_ok=True)
container_name = f"{self.sandbox_options.get('name_prefix', 'agent-term')}-{uuid.uuid4().hex[:10]}"
cmd = [
docker_path,
"run",
"--rm",
"-i",
"--name",
container_name,
"-w",
mount_path,
"-v",
f"{working_dir}:{mount_path}",
]
network = self.sandbox_options.get("network")
if network:
cmd += ["--network", network]
cpus = self.sandbox_options.get("cpus")
if cpus:
cmd += ["--cpus", cpus]
memory = self.sandbox_options.get("memory")
if memory:
cmd += ["--memory", memory]
for bind in self.sandbox_options.get("binds", []):
bind = bind.strip()
if bind:
cmd += ["-v", bind]
envs = {
"PYTHONIOENCODING": "utf-8",
"TERM": "xterm-256color",
}
for key, value in (self.sandbox_options.get("env") or {}).items():
if value is not None:
envs[key] = value
for key, value in envs.items():
cmd += ["-e", f"{key}={value}"]
cmd.append(image)
shell_path = self.sandbox_options.get("shell") or "/bin/bash"
if shell_path:
cmd.append(shell_path)
if shell_path.endswith("sh"):
cmd.append("-i")
env = os.environ.copy()
try:
process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=0,
env=env
)
except FileNotFoundError:
message = f"无法执行容器运行时: {docker_path}"
if self.sandbox_required:
raise RuntimeError(message)
print(f"{OUTPUT_FORMATS['warning']} {message}")
return None
self.sandbox_container_name = container_name
self._sandbox_bin_path = docker_path
self.shell_command = f"{shell_path} (sandbox:{image})"
self.using_container = True
self.is_windows = False
return process
def _read_output(self):
"""后台线程:持续读取输出(修复版,正确处理编码)"""
@ -610,6 +793,7 @@ class PersistentTerminal:
"is_running": self.is_running,
"working_dir": str(self.working_dir),
"shell": self.shell_command,
"execution_mode": self.execution_mode,
"start_time": self.start_time.isoformat() if self.start_time else None,
"is_interactive": self.is_interactive,
"last_command": self.last_command,
@ -663,14 +847,37 @@ class PersistentTerminal:
'time': datetime.now().isoformat()
})
if self.using_container:
self._stop_sandbox_container()
self.using_container = False
self.execution_mode = "host"
print(f"{OUTPUT_FORMATS['info']} 终端会话关闭: {self.session_name}")
return True
except Exception as e:
print(f"{OUTPUT_FORMATS['error']} 关闭终端失败: {e}")
return False
def __del__(self):
"""析构函数,确保进程被关闭"""
if hasattr(self, 'is_running') and self.is_running:
self.close()
def _stop_sandbox_container(self, force: bool = False):
"""确保容器终端被停止"""
if not self.sandbox_container_name or not self._sandbox_bin_path:
return
try:
subprocess.run(
[self._sandbox_bin_path, "kill", self.sandbox_container_name],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=3,
check=False
)
except Exception:
if force:
print(f"{OUTPUT_FORMATS['warning']} 强制终止容器 {self.sandbox_container_name} 失败,可能已退出。")
finally:
self.sandbox_container_name = None

View File

@ -12,7 +12,19 @@ try:
TERMINAL_DISPLAY_SIZE,
TERMINAL_SNAPSHOT_DEFAULT_LINES,
TERMINAL_SNAPSHOT_MAX_LINES,
TERMINAL_SNAPSHOT_MAX_CHARS
TERMINAL_SNAPSHOT_MAX_CHARS,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_IMAGE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_SHELL,
TERMINAL_SANDBOX_NETWORK,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
TERMINAL_SANDBOX_BINDS,
TERMINAL_SANDBOX_BIN,
TERMINAL_SANDBOX_NAME_PREFIX,
TERMINAL_SANDBOX_ENV,
TERMINAL_SANDBOX_REQUIRE,
)
except ImportError:
import sys
@ -26,7 +38,19 @@ except ImportError:
TERMINAL_DISPLAY_SIZE,
TERMINAL_SNAPSHOT_DEFAULT_LINES,
TERMINAL_SNAPSHOT_MAX_LINES,
TERMINAL_SNAPSHOT_MAX_CHARS
TERMINAL_SNAPSHOT_MAX_CHARS,
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_IMAGE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_SHELL,
TERMINAL_SANDBOX_NETWORK,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
TERMINAL_SANDBOX_BINDS,
TERMINAL_SANDBOX_BIN,
TERMINAL_SANDBOX_NAME_PREFIX,
TERMINAL_SANDBOX_ENV,
TERMINAL_SANDBOX_REQUIRE,
)
from modules.persistent_terminal import PersistentTerminal
@ -41,7 +65,9 @@ class TerminalManager:
max_terminals: int = None,
terminal_buffer_size: int = None,
terminal_display_size: int = None,
broadcast_callback: Callable = None
broadcast_callback: Callable = None,
sandbox_mode: Optional[str] = None,
sandbox_options: Optional[Dict] = None
):
"""
初始化终端管理器
@ -61,6 +87,30 @@ class TerminalManager:
self.max_snapshot_lines = TERMINAL_SNAPSHOT_MAX_LINES
self.max_snapshot_chars = TERMINAL_SNAPSHOT_MAX_CHARS
self.broadcast = broadcast_callback
self.sandbox_mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "host").lower()
default_sandbox_options = {
"image": TERMINAL_SANDBOX_IMAGE,
"mount_path": TERMINAL_SANDBOX_MOUNT_PATH,
"shell": TERMINAL_SANDBOX_SHELL,
"network": TERMINAL_SANDBOX_NETWORK,
"cpus": TERMINAL_SANDBOX_CPUS,
"memory": TERMINAL_SANDBOX_MEMORY,
"binds": list(TERMINAL_SANDBOX_BINDS),
"bin": TERMINAL_SANDBOX_BIN,
"name_prefix": TERMINAL_SANDBOX_NAME_PREFIX,
"env": dict(TERMINAL_SANDBOX_ENV),
"require": TERMINAL_SANDBOX_REQUIRE,
}
if sandbox_options:
# 深拷贝,确保不会影响默认值
for key, value in sandbox_options.items():
if key == "binds" and isinstance(value, list):
default_sandbox_options[key] = list(value)
elif key == "env" and isinstance(value, dict):
default_sandbox_options[key] = dict(value)
else:
default_sandbox_options[key] = value
self.sandbox_options = default_sandbox_options
# 终端会话字典
self.terminals: Dict[str, PersistentTerminal] = {}
@ -113,7 +163,7 @@ class TerminalManager:
else:
work_path = self.project_path
# 获取合适的shell命令
# 获取合适的shell命令(用于宿主机或回退模式)
shell_command = self.factory.get_shell_command()
# 创建终端实例
@ -123,7 +173,10 @@ class TerminalManager:
shell_command=shell_command,
broadcast_callback=self.broadcast,
max_buffer_size=self.terminal_buffer_size,
display_size=self.terminal_display_size
display_size=self.terminal_display_size,
project_path=str(self.project_path),
sandbox_mode=self.sandbox_mode,
sandbox_options=self.sandbox_options
)
# 启动终端
@ -154,7 +207,7 @@ class TerminalManager:
"success": True,
"session": session_name,
"working_dir": str(work_path),
"shell": shell_command,
"shell": terminal.shell_command or shell_command,
"is_active": make_active,
"total_sessions": len(self.terminals)
}
@ -235,7 +288,7 @@ class TerminalManager:
terminal = self.terminals[target_session]
working_dir = str(terminal.working_dir)
shell_command = terminal.shell_command or self.factory.get_shell_command()
shell_command = self.factory.get_shell_command()
terminal.close()
del self.terminals[target_session]
@ -246,7 +299,10 @@ class TerminalManager:
shell_command=shell_command,
broadcast_callback=self.broadcast,
max_buffer_size=self.terminal_buffer_size,
display_size=self.terminal_display_size
display_size=self.terminal_display_size,
project_path=str(self.project_path),
sandbox_mode=self.sandbox_mode,
sandbox_options=self.sandbox_options
)
if not new_terminal.start():
@ -272,7 +328,7 @@ class TerminalManager:
self.broadcast('terminal_reset', {
'session': target_session,
'working_dir': working_dir,
'shell': shell_command,
'shell': new_terminal.shell_command or shell_command,
'time': datetime.now().isoformat()
})
self.broadcast('terminal_list_update', {
@ -284,7 +340,7 @@ class TerminalManager:
"success": True,
"session": target_session,
"working_dir": working_dir,
"shell": shell_command,
"shell": new_terminal.shell_command or shell_command,
"message": "终端会话已重置并重新启动"
}

View File

@ -13,7 +13,8 @@ try:
TERMINAL_COMMAND_TIMEOUT,
FORBIDDEN_COMMANDS,
OUTPUT_FORMATS,
MAX_RUN_COMMAND_CHARS
MAX_RUN_COMMAND_CHARS,
TOOLBOX_TERMINAL_IDLE_SECONDS,
)
except ImportError:
project_root = Path(__file__).resolve().parents[1]
@ -24,8 +25,10 @@ except ImportError:
TERMINAL_COMMAND_TIMEOUT,
FORBIDDEN_COMMANDS,
OUTPUT_FORMATS,
MAX_RUN_COMMAND_CHARS
MAX_RUN_COMMAND_CHARS,
TOOLBOX_TERMINAL_IDLE_SECONDS,
)
from modules.toolbox_container import ToolboxContainer
class TerminalOperator:
def __init__(self, project_path: str):
@ -34,6 +37,7 @@ class TerminalOperator:
# 自动检测Python命令
self.python_cmd = self._detect_python_command()
print(f"{OUTPUT_FORMATS['info']} 检测到Python命令: {self.python_cmd}")
self._toolbox: Optional[ToolboxContainer] = None
def _detect_python_command(self) -> str:
"""
@ -73,6 +77,14 @@ class TerminalOperator:
# 如果都没找到,根据平台返回默认值
return "python" if sys.platform == "win32" else "python3"
def _get_toolbox(self) -> ToolboxContainer:
if self._toolbox is None:
self._toolbox = ToolboxContainer(
project_path=str(self.project_path),
idle_timeout=TOOLBOX_TERMINAL_IDLE_SECONDS,
)
return self._toolbox
def _validate_command(self, command: str) -> Tuple[bool, str]:
"""验证命令安全性"""
@ -131,20 +143,15 @@ class TerminalOperator:
}
# 设置工作目录
if working_dir:
work_path = (self.project_path / working_dir).resolve()
# 确保工作目录在项目内
try:
work_path.relative_to(self.project_path)
except ValueError:
return {
"success": False,
"error": "工作目录必须在项目文件夹内",
"output": "",
"return_code": -1
}
else:
work_path = self.project_path
try:
work_path = self._resolve_work_path(working_dir)
except ValueError:
return {
"success": False,
"error": "工作目录必须在项目文件夹内",
"output": "",
"return_code": -1
}
timeout = timeout or TERMINAL_COMMAND_TIMEOUT
@ -152,7 +159,53 @@ class TerminalOperator:
print(f"{OUTPUT_FORMATS['info']} 工作目录: {work_path}")
try:
# 创建进程
toolbox_raw = await self._get_toolbox().run(command, work_path, timeout=timeout)
result_payload = self._format_toolbox_output(toolbox_raw)
except Exception as exc:
print(f"{OUTPUT_FORMATS['warning']} 工具容器执行失败,回退到本地子进程: {exc}")
result_payload = await self._run_command_subprocess(command, work_path, timeout)
# 字符数检查
if result_payload.get("success") and "output" in result_payload:
char_count = len(result_payload["output"])
if char_count > MAX_RUN_COMMAND_CHARS:
return {
"success": False,
"error": f"结果内容过大,有{char_count}字符请使用限制字符数的获取内容方式根据程度选择10k以内的数",
"char_count": char_count,
"limit": MAX_RUN_COMMAND_CHARS,
"command": command
}
return result_payload
def _resolve_work_path(self, working_dir: Optional[str]) -> Path:
if working_dir:
work_path = (self.project_path / working_dir).resolve()
work_path.relative_to(self.project_path)
return work_path
return self.project_path
def _format_toolbox_output(self, payload: Dict) -> Dict:
success = bool(payload.get("success"))
output_text = payload.get("output", "") or ""
result = {
"success": success,
"output": output_text,
"return_code": 0 if success else -1
}
if not success:
result["error"] = payload.get("error") or payload.get("message", "命令执行失败")
if "message" in payload:
result["message"] = payload["message"]
if "status" in payload:
result["status"] = payload["status"]
if "truncated" in payload:
result["truncated"] = payload["truncated"]
return result
async def _run_command_subprocess(self, command: str, work_path: Path, timeout: int) -> Dict:
try:
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
@ -161,7 +214,6 @@ class TerminalOperator:
shell=True
)
# 等待执行完成
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
@ -177,12 +229,10 @@ class TerminalOperator:
"return_code": -1
}
# 解码输出
stdout_text = stdout.decode('utf-8', errors='replace') if stdout else ""
stderr_text = stderr.decode('utf-8', errors='replace') if stderr else ""
success = process.returncode == 0
if success:
print(f"{OUTPUT_FORMATS['success']} 命令执行成功")
else:
@ -200,7 +250,7 @@ class TerminalOperator:
truncated = True
combined_output = combined_output[-MAX_RUN_COMMAND_CHARS:]
result_payload = {
response = {
"success": success,
"command": command,
"output": combined_output,
@ -208,17 +258,16 @@ class TerminalOperator:
"truncated": truncated
}
if stderr_text:
result_payload["stderr"] = stderr_text
return result_payload
except Exception as e:
response["stderr"] = stderr_text
return response
except Exception as exc:
return {
"success": False,
"error": f"执行失败: {str(e)}",
"error": f"执行失败: {str(exc)}",
"output": "",
"return_code": -1
}
async def run_python_code(
self,
code: str,

View File

@ -0,0 +1,156 @@
# modules/toolbox_container.py - 专用工具容器管理器
import asyncio
import time
import uuid
import shlex
from pathlib import Path
from typing import Optional, Dict
from modules.persistent_terminal import PersistentTerminal
from config import (
TERMINAL_SANDBOX_MODE,
TERMINAL_SANDBOX_IMAGE,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_SHELL,
TERMINAL_SANDBOX_NETWORK,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
TERMINAL_SANDBOX_BINDS,
TERMINAL_SANDBOX_BIN,
TERMINAL_SANDBOX_NAME_PREFIX,
TERMINAL_SANDBOX_ENV,
TERMINAL_SANDBOX_REQUIRE,
TOOLBOX_TERMINAL_IDLE_SECONDS,
)
def _build_sandbox_options() -> Dict:
"""构造与终端一致的沙箱配置."""
name_prefix = TERMINAL_SANDBOX_NAME_PREFIX
if name_prefix:
name_prefix = f"{name_prefix}-toolbox"
else:
name_prefix = "toolbox-term"
return {
"image": TERMINAL_SANDBOX_IMAGE,
"mount_path": TERMINAL_SANDBOX_MOUNT_PATH,
"shell": TERMINAL_SANDBOX_SHELL,
"network": TERMINAL_SANDBOX_NETWORK,
"cpus": TERMINAL_SANDBOX_CPUS,
"memory": TERMINAL_SANDBOX_MEMORY,
"binds": list(TERMINAL_SANDBOX_BINDS),
"bin": TERMINAL_SANDBOX_BIN,
"name_prefix": name_prefix,
"env": dict(TERMINAL_SANDBOX_ENV),
"require": TERMINAL_SANDBOX_REQUIRE,
}
class ToolboxContainer:
"""为 run_command/run_python 提供的专用容器."""
def __init__(
self,
project_path: str,
sandbox_mode: Optional[str] = None,
sandbox_options: Optional[Dict] = None,
idle_timeout: int = TOOLBOX_TERMINAL_IDLE_SECONDS,
):
self.project_path = Path(project_path).resolve()
self.sandbox_mode = (sandbox_mode or TERMINAL_SANDBOX_MODE or "host").lower()
options = _build_sandbox_options()
if sandbox_options:
for key, value in sandbox_options.items():
if key == "binds" and isinstance(value, list):
options[key] = list(value)
elif key == "env" and isinstance(value, dict):
options[key] = dict(value)
else:
options[key] = value
self.sandbox_options = options
self.idle_timeout = max(0, int(idle_timeout)) if idle_timeout is not None else 0
self._terminal: Optional[PersistentTerminal] = None
self._lock = asyncio.Lock()
self._session_name = f"toolbox-{uuid.uuid4().hex[:10]}"
self._last_used = 0.0
async def _ensure_terminal(self) -> PersistentTerminal:
"""确保容器已启动。"""
async with self._lock:
if self._terminal and self._terminal.is_running:
return self._terminal
terminal = PersistentTerminal(
session_name=self._session_name,
working_dir=str(self.project_path),
broadcast_callback=None,
project_path=str(self.project_path),
sandbox_mode=self.sandbox_mode,
sandbox_options=self.sandbox_options,
)
if not terminal.start():
raise RuntimeError("工具容器启动失败,请检查 Docker 或本地 shell 环境。")
self._terminal = terminal
self._last_used = time.time()
print(f"[Toolbox] 工具容器已启动 (session={self._session_name}, mode={terminal.execution_mode})")
return terminal
def _wrap_command(self, command: str, work_path: Path, execution_mode: Optional[str]) -> str:
"""根据执行模式动态拼接 cd 指令。"""
if work_path == self.project_path:
return command
try:
relative = work_path.relative_to(self.project_path).as_posix()
except ValueError:
relative = ""
if not relative:
return command
if execution_mode == "docker":
mount_path = self.sandbox_options.get("mount_path", "/workspace").rstrip("/")
target = f"{mount_path}/{relative}"
else:
target = str(work_path)
return f"cd {shlex.quote(target)} && {command}"
async def run(self, command: str, work_path: Path, timeout: Optional[float] = None) -> Dict:
"""在容器/主机终端中执行命令。"""
terminal = await self._ensure_terminal()
shell_command = self._wrap_command(command, work_path, terminal.execution_mode)
result = await asyncio.to_thread(
terminal.send_command,
shell_command,
True,
timeout,
)
self._last_used = time.time()
self._cleanup_if_idle()
return result
def _cleanup_if_idle(self):
if self.idle_timeout <= 0 or not self._terminal:
return
if time.time() - self._last_used >= self.idle_timeout:
self._terminal.close()
print(f"[Toolbox] 工具容器空闲超时,已释放 (session={self._session_name})")
self._terminal = None
def shutdown(self):
"""立即关闭容器。"""
if self._terminal:
self._terminal.close()
print(f"[Toolbox] 工具容器已关闭 (session={self._session_name})")
self._terminal = None
@property
def execution_mode(self) -> Optional[str]:
if self._terminal:
return self._terminal.execution_mode
return None

View File

@ -243,7 +243,8 @@ tree -L 2
- 不要用生硬的"执行工具: xxx",而是说"我来帮你..."
## 当前环境信息
- 项目路径: {project_path}
- 项目路径: 你运行在隔离容器中(挂载目录 {container_path}),宿主机路径已对你隐藏
- 资源限制: 容器内核数上限 {container_cpus},内存 {container_memory},项目磁盘配额 {project_storage}
- 项目文件结构: {file_tree}
- 长期记忆: {memory}
- 当前时间: {current_time}
@ -258,4 +259,4 @@ tree -L 2
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
如果用户设置了个性化信息,根据用户的个性化需求回答
如果用户设置了个性化信息,根据用户的个性化需求回答

View File

@ -124,6 +124,10 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
if (resp.status === 503) {
window.location.href = '/resource_busy';
return;
}
const data = await resp.json();
if (data.success) {
window.location.href = '/';

48
static/resource_busy.html Normal file
View File

@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>资源繁忙</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Helvetica Neue", sans-serif;
background: #f7f7f7;
margin: 0;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: #333;
}
.card {
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.08);
padding: 48px 56px;
max-width: 460px;
text-align: center;
}
h1 {
font-size: 24px;
margin: 0 0 16px;
color: #e63946;
}
p {
margin: 0 0 20px;
line-height: 1.6;
}
.hint {
font-size: 14px;
color: #666;
}
</style>
</head>
<body>
<div class="card">
<h1>资源繁忙,请稍后重试</h1>
<p>当前在线用户已达上限,为保证服务器稳定,暂时无法创建新的会话。</p>
<p class="hint">请稍等片刻刷新页面,或联系管理员提升配额。</p>
</div>
</body>
</html>

View File

@ -15,7 +15,11 @@ try:
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
)
except ImportError:
import sys
@ -30,7 +34,11 @@ except ImportError:
READ_TOOL_DEFAULT_CONTEXT_BEFORE, READ_TOOL_DEFAULT_CONTEXT_AFTER,
READ_TOOL_MAX_CONTEXT_BEFORE, READ_TOOL_MAX_CONTEXT_AFTER,
READ_TOOL_DEFAULT_MAX_MATCHES, READ_TOOL_MAX_MATCHES,
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS
READ_TOOL_MAX_FILE_SIZE, MAX_FOCUS_FILE_CHARS,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
)
from modules.file_manager import FileManager
from modules.search_engine import SearchEngine
@ -65,6 +73,10 @@ class MainTerminal:
self.api_client = DeepSeekClient(thinking_mode=thinking_mode)
self.context_manager = ContextManager(project_path, data_dir=str(self.data_dir))
self.context_manager.main_terminal = self
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
self.container_cpu_limit = TERMINAL_SANDBOX_CPUS or "未限制"
self.container_memory_limit = TERMINAL_SANDBOX_MEMORY or "未限制"
self.project_storage_limit = f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制"
self.memory_manager = MemoryManager(data_dir=str(self.data_dir))
self.file_manager = FileManager(project_path)
self.search_engine = SearchEngine()
@ -2097,8 +2109,16 @@ class MainTerminal:
system_prompt = self.load_prompt("main_system")
# 格式化系统提示
container_path = self.container_mount_path or "/workspace"
container_cpus = self.container_cpu_limit
container_memory = self.container_memory_limit
project_storage = self.project_storage_limit
system_prompt = system_prompt.format(
project_path=self.project_path,
project_path=container_path,
container_path=container_path,
container_cpus=container_cpus,
container_memory=container_memory,
project_storage=project_storage,
file_tree=context["project_info"]["file_tree"],
memory=context["memory"],
current_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")

View File

@ -243,7 +243,8 @@ tree -L 2
- 不要用生硬的"执行工具: xxx",而是说"我来帮你..."
## 当前环境信息
- 项目路径: {project_path}
- 项目路径: 你运行在隔离容器中(挂载目录 {container_path}),宿主机路径已对你隐藏
- 资源限制: 容器内核数上限 {container_cpus},内存 {container_memory},项目磁盘配额 {project_storage}
- 项目文件结构: {file_tree}
- 长期记忆: {memory}
- 当前时间: {current_time}
@ -256,4 +257,4 @@ tree -L 2
4. **用户友好**:用简单的语言解释复杂的操作
5. **正确执行**:和用户主动确认细节,用户明确告知可以开始任务后,再开始工作流程
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。

View File

@ -8,14 +8,30 @@ from typing import Dict, List, Optional, Any
from pathlib import Path
from datetime import datetime
try:
from config import MAX_CONTEXT_SIZE, DATA_DIR, PROMPTS_DIR
from config import (
MAX_CONTEXT_SIZE,
DATA_DIR,
PROMPTS_DIR,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
)
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_CONTEXT_SIZE, DATA_DIR, PROMPTS_DIR
from config import (
MAX_CONTEXT_SIZE,
DATA_DIR,
PROMPTS_DIR,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
)
from utils.conversation_manager import ConversationManager
class ContextManager:
@ -24,6 +40,10 @@ class ContextManager:
self.initial_project_path = self.project_path
self.workspace_root = Path(__file__).resolve().parents[1]
self.data_dir = Path(data_dir).expanduser().resolve() if data_dir else Path(DATA_DIR).resolve()
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
self.container_cpu_limit = TERMINAL_SANDBOX_CPUS or "未限制"
self.container_memory_limit = TERMINAL_SANDBOX_MEMORY or "未限制"
self.project_storage_limit = f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制"
self.temp_files = {} # 临时加载的文件内容
self.file_annotations = {} # 文件备注
self.conversation_history = [] # 当前对话历史(内存中)
@ -1092,8 +1112,16 @@ class ContextManager:
system_prompt = self.load_prompt("main_system")
# 格式化系统提示
container_path = self.container_mount_path or "/workspace"
container_cpus = self.container_cpu_limit
container_memory = self.container_memory_limit
project_storage = self.project_storage_limit
system_prompt = system_prompt.format(
project_path=self.project_path,
project_path=container_path,
container_path=container_path,
container_cpus=container_cpus,
container_memory=container_memory,
project_storage=project_storage,
file_tree=context["project_info"]["file_tree"],
memory=context["memory"],
current_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")

View File

@ -8,20 +8,40 @@ from typing import Dict, List, Optional, Any
from pathlib import Path
from datetime import datetime
try:
from config import MAX_CONTEXT_SIZE, DATA_DIR, PROMPTS_DIR
from config import (
MAX_CONTEXT_SIZE,
DATA_DIR,
PROMPTS_DIR,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
)
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_CONTEXT_SIZE, DATA_DIR, PROMPTS_DIR
from config import (
MAX_CONTEXT_SIZE,
DATA_DIR,
PROMPTS_DIR,
TERMINAL_SANDBOX_MOUNT_PATH,
TERMINAL_SANDBOX_CPUS,
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
)
from utils.conversation_manager import ConversationManager
class ContextManager:
def __init__(self, project_path: str, data_dir: Optional[str] = None):
self.project_path = Path(project_path).resolve()
self.initial_project_path = self.project_path
self.container_mount_path = TERMINAL_SANDBOX_MOUNT_PATH or "/workspace"
self.container_cpu_limit = TERMINAL_SANDBOX_CPUS or "未限制"
self.container_memory_limit = TERMINAL_SANDBOX_MEMORY or "未限制"
self.project_storage_limit = f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制"
self.workspace_root = Path(__file__).resolve().parents[1]
self.data_dir = Path(data_dir).expanduser().resolve() if data_dir else Path(DATA_DIR).resolve()
self.temp_files = {} # 临时加载的文件内容
@ -1097,8 +1117,16 @@ class ContextManager:
system_prompt = self.load_prompt("main_system")
# 格式化系统提示
container_path = self.container_mount_path or "/workspace"
container_cpus = self.container_cpu_limit
container_memory = self.container_memory_limit
project_storage = self.project_storage_limit
system_prompt = system_prompt.format(
project_path=self.project_path,
project_path=container_path,
container_path=container_path,
container_cpus=container_cpus,
container_memory=container_memory,
project_storage=project_storage,
file_tree=context["project_info"]["file_tree"],
memory=context["memory"],
current_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S")

View File

@ -21,6 +21,7 @@ from datetime import datetime
from collections import defaultdict, deque
from werkzeug.utils import secure_filename
from werkzeug.routing import BaseConverter
import secrets
# 添加项目根目录到Python路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@ -42,7 +43,8 @@ from config import (
DEFAULT_PROJECT_PATH,
LOGS_DIR,
AGENT_VERSION,
THINKING_FAST_INTERVAL
THINKING_FAST_INTERVAL,
MAX_ACTIVE_USER_CONTAINERS
)
from modules.user_manager import UserManager, UserWorkspace
from modules.gui_file_manager import GuiFileManager
@ -53,7 +55,11 @@ from modules.personalization_manager import (
app = Flask(__name__, static_folder='static')
app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_SIZE
app.config['SECRET_KEY'] = 'your-secret-key-here'
_secret_key = os.environ.get("WEB_SECRET_KEY") or os.environ.get("SECRET_KEY")
if not _secret_key:
_secret_key = secrets.token_hex(32)
print("[security] WEB_SECRET_KEY 未设置,已生成临时密钥(重启后失效)。")
app.config['SECRET_KEY'] = _secret_key
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=12)
CORS(app)
@ -71,6 +77,7 @@ user_terminals: Dict[str, WebTerminal] = {}
terminal_rooms: Dict[str, set] = {}
connection_users: Dict[str, str] = {}
stop_flags: Dict[str, Dict[str, Any]] = {}
active_users: set = set()
DEFAULT_PORT = 8091
THINKING_FAILURE_KEYWORDS = ["⚠️", "🛑", "失败", "错误", "异常", "终止", "error", "failed", "未完成", "超时", "强制"]
@ -223,6 +230,10 @@ def get_user_resources(username: Optional[str] = None) -> Tuple[Optional[WebTerm
username = (username or get_current_username())
if not username:
return None, None
if username not in active_users:
if len(active_users) >= MAX_ACTIVE_USER_CONTAINERS:
raise RuntimeError("资源繁忙:终端资源已用尽,请稍后重试。")
active_users.add(username)
workspace = user_manager.ensure_user_workspace(username)
terminal = user_terminals.get(username)
if not terminal:
@ -246,7 +257,10 @@ def with_terminal(func):
@wraps(func)
def wrapper(*args, **kwargs):
username = get_current_username()
terminal, workspace = get_user_resources(username)
try:
terminal, workspace = get_user_resources(username)
except RuntimeError as exc:
return jsonify({"error": str(exc), "code": "resource_busy"}), 503
if not terminal or not workspace:
return jsonify({"error": "System not initialized"}), 503
kwargs.update({
@ -262,7 +276,10 @@ def get_terminal_for_sid(sid: str) -> Tuple[Optional[str], Optional[WebTerminal]
username = connection_users.get(sid)
if not username:
return None, None, None
terminal, workspace = get_user_resources(username)
try:
terminal, workspace = get_user_resources(username)
except RuntimeError:
return username, None, None
return username, terminal, workspace
@ -469,6 +486,8 @@ def login():
if request.method == 'GET':
if is_logged_in():
return redirect('/new')
if len(active_users) >= MAX_ACTIVE_USER_CONTAINERS:
return app.send_static_file('resource_busy.html'), 503
return app.send_static_file('login.html')
data = request.get_json() or {}
@ -479,11 +498,15 @@ def login():
if not record:
return jsonify({"success": False, "error": "账号或密码错误"}), 401
if record.username not in active_users and len(active_users) >= MAX_ACTIVE_USER_CONTAINERS:
return jsonify({"success": False, "error": "资源繁忙,请稍后重试", "code": "resource_busy"}), 503
session['logged_in'] = True
session['username'] = record.username
session['thinking_mode'] = app.config.get('DEFAULT_THINKING_MODE', False)
session.permanent = True
user_manager.ensure_user_workspace(record.username)
active_users.add(record.username)
return jsonify({"success": True})
@ -517,6 +540,8 @@ def logout():
session.clear()
if username and username in user_terminals:
user_terminals.pop(username, None)
if username in active_users:
active_users.discard(username)
return jsonify({"success": True})
@ -3806,3 +3831,6 @@ if __name__ == "__main__":
port=args.port,
debug=args.debug
)
@app.route('/resource_busy')
def resource_busy_page():
return app.send_static_file('resource_busy.html'), 503