feat: containerize terminals and add resource controls
This commit is contained in:
parent
f3206357e9
commit
b81d6760bd
49
.env.example
Normal file
49
.env.example
Normal 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
|
||||
45
README.md
45
README.md
@ -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` 定义的 Emoji;Web 模式所有工具事件都会写入 `logs/debug_stream.log`。
|
||||
- **数据隔离**:多用户目录位于 `users/<username>/`;请避免将真实密钥提交到仓库,必要时扩展 `.gitignore`。
|
||||
- **测试**:暂未配置自动化测试,可参考 `test/` 目录(如 `api_interceptor_server.py`)编写自定义脚本。
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
'''
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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
45
doc/phase1_summary.md
Normal 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
86
doc/security_review.md
Normal 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` 仅检查 session;Socket.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 组件化”等体验向任务,确保基础安全能力可复用到后续版本。
|
||||
|
||||
> 本文档会随着重构推进持续更新;若需深入某一条风险(如容器隔离方案、密钥管理流程),可另开文档展开细化设计。
|
||||
35
docker/terminal.Dockerfile
Normal file
35
docker/terminal.Dockerfile
Normal 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}"
|
||||
18
docker/toolbox-requirements.txt
Normal file
18
docker/toolbox-requirements.txt
Normal 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
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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": "终端会话已重置并重新启动"
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
156
modules/toolbox_container.py
Normal file
156
modules/toolbox_container.py
Normal 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
|
||||
@ -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
|
||||
|
||||
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
|
||||
|
||||
如果用户设置了个性化信息,根据用户的个性化需求回答
|
||||
如果用户设置了个性化信息,根据用户的个性化需求回答
|
||||
|
||||
@ -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
48
static/resource_busy.html
Normal 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>
|
||||
@ -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")
|
||||
|
||||
@ -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. **正确执行**:和用户主动确认细节,用户明确告知可以开始任务后,再开始工作流程
|
||||
|
||||
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
|
||||
记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user