diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b8f3ae0 --- /dev/null +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 783d7a2..0f08404 100644 --- a/README.md +++ b/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//`;请避免将真实密钥提交到仓库,必要时扩展 `.gitignore`。 - **测试**:暂未配置自动化测试,可参考 `test/` 目录(如 `api_interceptor_server.py`)编写自定义脚本。 diff --git a/config/__init__.py b/config/__init__.py index a706c5f..aae11d2 100644 --- a/config/__init__.py +++ b/config/__init__.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 diff --git a/config/api.py b/config/api.py index c46ef13..d169cac 100644 --- a/config/api.py +++ b/config/api.py @@ -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" -''' diff --git a/config/auth.py b/config/auth.py index 5706878..133d27d 100644 --- a/config/auth.py +++ b/config/auth.py @@ -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", diff --git a/config/limits.py b/config/limits.py index c3687c6..0b2481b 100644 --- a/config/limits.py +++ b/config/limits.py @@ -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", ] diff --git a/config/terminal.py b/config/terminal.py index bcf9a8c..3d83a9e 100644 --- a/config/terminal.py +++ b/config/terminal.py @@ -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", ] diff --git a/core/main_terminal.py b/core/main_terminal.py index 4ac2cbf..d8b1991 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -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") diff --git a/doc/phase1_summary.md b/doc/phase1_summary.md new file mode 100644 index 0000000..f750520 --- /dev/null +++ b/doc/phase1_summary.md @@ -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`,了解之前的风险总览及尚未推进的条目。祝顺利! + diff --git a/doc/security_review.md b/doc/security_review.md new file mode 100644 index 0000000..e91b80a --- /dev/null +++ b/doc/security_review.md @@ -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//` | JSON 文件无加密、无访问控制 | +| 对话与工具日志 | `data/conversations/*`, `logs/` | 包含终端输出、上传文件、任务上下文 | +| 用户上传文件 | `users//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 组件化”等体验向任务,确保基础安全能力可复用到后续版本。 + +> 本文档会随着重构推进持续更新;若需深入某一条风险(如容器隔离方案、密钥管理流程),可另开文档展开细化设计。 diff --git a/docker/terminal.Dockerfile b/docker/terminal.Dockerfile new file mode 100644 index 0000000..6368515 --- /dev/null +++ b/docker/terminal.Dockerfile @@ -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}" diff --git a/docker/toolbox-requirements.txt b/docker/toolbox-requirements.txt new file mode 100644 index 0000000..8edd13b --- /dev/null +++ b/docker/toolbox-requirements.txt @@ -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 diff --git a/modules/file_manager.py b/modules/file_manager.py index 35f6895..6cab15a 100644 --- a/modules/file_manager.py +++ b/modules/file_manager.py @@ -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) diff --git a/modules/persistent_terminal.py b/modules/persistent_terminal.py index 5182e34..17b989c 100644 --- a/modules/persistent_terminal.py +++ b/modules/persistent_terminal.py @@ -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 diff --git a/modules/terminal_manager.py b/modules/terminal_manager.py index 62b7e6f..ce47ce5 100644 --- a/modules/terminal_manager.py +++ b/modules/terminal_manager.py @@ -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": "终端会话已重置并重新启动" } diff --git a/modules/terminal_ops.py b/modules/terminal_ops.py index 293328c..4ba11cf 100644 --- a/modules/terminal_ops.py +++ b/modules/terminal_ops.py @@ -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, diff --git a/modules/toolbox_container.py b/modules/toolbox_container.py new file mode 100644 index 0000000..07ef4b8 --- /dev/null +++ b/modules/toolbox_container.py @@ -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 diff --git a/prompts/main_system.txt b/prompts/main_system.txt index 857072a..7838af8 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -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 记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。 -如果用户设置了个性化信息,根据用户的个性化需求回答 \ No newline at end of file +如果用户设置了个性化信息,根据用户的个性化需求回答 diff --git a/static/login.html b/static/login.html index 86bdcfe..bcb49fc 100644 --- a/static/login.html +++ b/static/login.html @@ -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 = '/'; diff --git a/static/resource_busy.html b/static/resource_busy.html new file mode 100644 index 0000000..4b7e758 --- /dev/null +++ b/static/resource_busy.html @@ -0,0 +1,48 @@ + + + + + 资源繁忙 + + + +
+

资源繁忙,请稍后重试

+

当前在线用户已达上限,为保证服务器稳定,暂时无法创建新的会话。

+

请稍等片刻刷新页面,或联系管理员提升配额。

+
+ + diff --git a/sub_agent/core/main_terminal.py b/sub_agent/core/main_terminal.py index 16ac226..97ea713 100644 --- a/sub_agent/core/main_terminal.py +++ b/sub_agent/core/main_terminal.py @@ -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") diff --git a/sub_agent/prompts/main_system.txt b/sub_agent/prompts/main_system.txt index 019e671..5fc5d7c 100644 --- a/sub_agent/prompts/main_system.txt +++ b/sub_agent/prompts/main_system.txt @@ -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. **正确执行**:和用户主动确认细节,用户明确告知可以开始任务后,再开始工作流程 -记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。 \ No newline at end of file +记住:你的用户可能不懂技术,你的目标是让他们感觉到"这个助手真好用",而不是"怎么这么复杂"。 diff --git a/sub_agent/utils/context_manager.py b/sub_agent/utils/context_manager.py index 928ca2f..61f2f0a 100644 --- a/sub_agent/utils/context_manager.py +++ b/sub_agent/utils/context_manager.py @@ -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") diff --git a/utils/context_manager.py b/utils/context_manager.py index 0ea8008..43f893b 100644 --- a/utils/context_manager.py +++ b/utils/context_manager.py @@ -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") diff --git a/web_server.py b/web_server.py index b11981e..430bdcc 100644 --- a/web_server.py +++ b/web_server.py @@ -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