security: harden C1-C4 (debug-RCE, auth, denial-of-wallet, sim deadline)
Closes the four CRITICAL findings from the 2026-06-13 review.
C1 — Werkzeug debug RCE / dev server in prod:
- FLASK_DEBUG defaults False (kills interactive-debugger network RCE)
- production runs gunicorn (-w 1 --threads 8) via `npm run start`; Dockerfile
builds the frontend and serves it with `vite preview` (host 0.0.0.0); gunicorn
+ uv.lock updated
- Config.validate() now runs inside create_app() so the gunicorn path enforces
SECRET_KEY (prod) / API_KEY / LLM / ZEP at boot
C2 — zero auth on all /api/* routes:
- before_request API-key guard (X-API-Key / Bearer), constant-time bytes compare,
/health + OPTIONS exempt
- AUTH_ENABLED fail-closed parse (only explicit false/0/no/off disables)
- frontend axios injects X-API-Key from build-time VITE_API_KEY, wired through
docker compose build-arg -> Dockerfile ARG -> vite build (+ frontend/.env.example)
C3 — denial-of-wallet (no cost ceiling; OASIS_DEFAULT_MAX_ROUNDS was dead config):
- OASIS_DEFAULT_MAX_ROUNDS now applied when max_rounds omitted (default 150, covers
the 144-round demo); hard ceilings OASIS_MAX_ROUNDS_CAP / OASIS_MAX_AGENTS_CAP;
runner always forwards the clamped rounds to the subprocess
C4 — no simulation deadline; env.step could wedge forever:
- every env.step (initial / round-loop / interview) wrapped in asyncio.wait_for
(OASIS_ROUND_TIMEOUT_SEC) across all 3 run scripts; per-loop total-deadline
(OASIS_RUN_TIMEOUT_SEC); gather(return_exceptions=True) + single-platform
try/except so one platform's failure can't skip env.close
New env vars documented in .env.example + README security section.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
96096ea0ff
commit
8cb92768ae
30
.env.example
30
.env.example
|
|
@ -13,4 +13,32 @@ ZEP_API_KEY=your_zep_api_key_here
|
|||
# 注意如果不使用加速配置,env文件中就不要出现下面的配置项
|
||||
LLM_BOOST_API_KEY=your_api_key_here
|
||||
LLM_BOOST_BASE_URL=your_base_url_here
|
||||
LLM_BOOST_MODEL_NAME=your_model_name_here
|
||||
LLM_BOOST_MODEL_NAME=your_model_name_here
|
||||
|
||||
# ===== 安全配置(C1)=====
|
||||
# 生产模式下必须设置自定义 SECRET_KEY(否则启动校验失败)。生成示例:python -c "import secrets;print(secrets.token_hex(32))"
|
||||
SECRET_KEY=change_me_to_a_random_secret
|
||||
# 调试模式默认关闭;设为 true 会启用 Werkzeug 交互式调试器(可远程 RCE),切勿在联网/生产开启
|
||||
FLASK_DEBUG=false
|
||||
|
||||
# ===== API 鉴权(C2)=====
|
||||
# 所有 /api/* 端点需携带 `X-API-Key: <API_KEY>`(或 `Authorization: Bearer <API_KEY>`)
|
||||
# AUTH_ENABLED=true(默认)时必须设置 API_KEY;本地开发可设 AUTH_ENABLED=false 关闭鉴权
|
||||
AUTH_ENABLED=true
|
||||
API_KEY=change_me_to_a_strong_api_key
|
||||
# 前端构建期变量:必须等于 API_KEY。docker compose 会把它作为 build-arg 注入前端构建,
|
||||
# 使打包后的 UI 自动带上 X-API-Key。注意:它会被打进客户端包、可被任何访问者提取(见 README 安全说明)。
|
||||
VITE_API_KEY=change_me_to_a_strong_api_key
|
||||
|
||||
# ===== 模拟成本上限(C3,denial-of-wallet 防护)=====
|
||||
# 客户端未传 max_rounds 时的默认轮数上限(完整长度模拟请按请求传 max_rounds 或调高此值)
|
||||
# 默认 150 覆盖典型配置(72h/30min=144 轮)以免悄悄截断标准演示
|
||||
OASIS_DEFAULT_MAX_ROUNDS=150
|
||||
# 硬上限:无论客户端传入何值都不得超过
|
||||
OASIS_MAX_ROUNDS_CAP=200
|
||||
OASIS_MAX_AGENTS_CAP=1000
|
||||
|
||||
# ===== 模拟超时(C4,秒)=====
|
||||
# 单轮 env.step 超时 + 整轮模拟总超时,防止 LLM/网络挂起导致 run 永久 wedge
|
||||
OASIS_ROUND_TIMEOUT_SEC=600
|
||||
OASIS_RUN_TIMEOUT_SEC=7200
|
||||
14
Dockerfile
14
Dockerfile
|
|
@ -23,7 +23,17 @@ RUN npm ci \
|
|||
# 复制项目源码
|
||||
COPY . .
|
||||
|
||||
# C2:前端 API Key 是 Vite 构建期变量,必须在 `npm run build` 之前进入构建环境,否则打包后的
|
||||
# UI 带空 key、在 AUTH_ENABLED=true 下所有 /api/* 会 401。由 docker compose 经 build-arg 注入。
|
||||
ARG VITE_API_KEY=""
|
||||
ENV VITE_API_KEY=$VITE_API_KEY
|
||||
|
||||
# C1:构建前端静态产物,生产用 `vite preview` 提供(不再运行 Vite 开发服务器)
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000 5001
|
||||
|
||||
# 同时启动前后端(开发模式)
|
||||
CMD ["npm", "run", "dev"]
|
||||
# C1:生产启动 —— 后端用 gunicorn(单 worker 多线程,保留进程内模拟态),
|
||||
# 前端用 vite preview 提供已构建产物。开发请改用 `npm run dev`。
|
||||
# 安全前提:须经 .env 设置 SECRET_KEY 与 API_KEY(FLASK_DEBUG 默认 false)。
|
||||
CMD ["npm", "run", "start"]
|
||||
37
README.md
37
README.md
|
|
@ -127,6 +127,43 @@ LLM_MODEL_NAME=qwen-plus
|
|||
ZEP_API_KEY=your_zep_api_key
|
||||
```
|
||||
|
||||
#### Security configuration (required for production)
|
||||
|
||||
The backend ships secure-by-default. When `FLASK_DEBUG=false` (the default) the app
|
||||
runs under **gunicorn** (no Werkzeug debugger) and refuses to start unless these are set:
|
||||
|
||||
```env
|
||||
# A random secret (required when FLASK_DEBUG=false):
|
||||
# python -c "import secrets;print(secrets.token_hex(32))"
|
||||
SECRET_KEY=your_random_secret
|
||||
|
||||
# API-key auth is ON by default — every /api/* request must carry the key.
|
||||
# Clients send X-API-Key: <API_KEY> (or Authorization: Bearer <API_KEY> ).
|
||||
AUTH_ENABLED=true
|
||||
API_KEY=your_strong_api_key
|
||||
```
|
||||
|
||||
- The bundled web UI reads the key from the **build-time** var **`VITE_API_KEY`** (set it equal to
|
||||
`API_KEY`) and sends it automatically as `X-API-Key`. It must be present **before** the frontend
|
||||
is built:
|
||||
- **Docker:** put `VITE_API_KEY` in the root `.env`; `docker compose up --build` injects it as a
|
||||
build-arg (docker-compose.yml `build.args` → Dockerfile `ARG VITE_API_KEY` → `npm run build`).
|
||||
The pre-built `ghcr.io` image (used by a bare `docker compose up` without `--build`) bakes no
|
||||
custom key — rebuild, or use `AUTH_ENABLED=false` for that path.
|
||||
- **Local frontend build:** copy `frontend/.env.example` → `frontend/.env` and set `VITE_API_KEY`,
|
||||
then `npm run build`. (The root `.env` is read by the backend only, not by Vite.)
|
||||
- ⚠️ A key baked into the client bundle is extractable by anyone who loads the page — for
|
||||
multi-tenant/public exposure replace this with session login or a gateway that injects per-user
|
||||
tokens. For local/internal/VPN or behind-a-gateway single-host use it is sufficient.
|
||||
- Local development / simplest single-host demo: set `AUTH_ENABLED=false` to disable the key
|
||||
requirement entirely (the API is then protected only by your network boundary).
|
||||
- Cost controls (denial-of-wallet): a run is bounded by `OASIS_DEFAULT_MAX_ROUNDS` (when the
|
||||
client omits `max_rounds`), the hard ceilings `OASIS_MAX_ROUNDS_CAP` / `OASIS_MAX_AGENTS_CAP`,
|
||||
and per-round / total timeouts `OASIS_ROUND_TIMEOUT_SEC` / `OASIS_RUN_TIMEOUT_SEC`. See
|
||||
`.env.example` for defaults.
|
||||
- Run the production server with a **single worker** (`gunicorn -w 1 --threads N`); simulation
|
||||
run-state is held in-process, so multiple workers break stop/status routing.
|
||||
|
||||
#### 2. Install Dependencies
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
MiroFish Backend - Flask应用工厂
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import os
|
||||
import warnings
|
||||
|
||||
|
|
@ -9,7 +10,7 @@ import warnings
|
|||
# 需要在所有其他导入之前设置
|
||||
warnings.filterwarnings("ignore", message=".*resource_tracker.*")
|
||||
|
||||
from flask import Flask, request
|
||||
from flask import Flask, jsonify, request
|
||||
from flask_cors import CORS
|
||||
|
||||
from .config import Config
|
||||
|
|
@ -18,9 +19,16 @@ from .utils.logger import setup_logger, get_logger
|
|||
|
||||
def create_app(config_class=Config):
|
||||
"""Flask应用工厂函数"""
|
||||
# 配置校验(C1/C2):在工厂内执行,确保 gunicorn(生产)路径也强制校验。
|
||||
# run.py(开发入口)也会单独校验,这里覆盖 `gunicorn app:create_app()` 这条不经过 run.py 的路径,
|
||||
# 否则 SECRET_KEY/API_KEY/LLM_API_KEY/ZEP_API_KEY 的缺省检查在生产中形同虚设。
|
||||
config_errors = config_class.validate()
|
||||
if config_errors:
|
||||
raise RuntimeError("配置错误,无法启动:\n - " + "\n - ".join(config_errors))
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(config_class)
|
||||
|
||||
|
||||
# 设置JSON编码:确保中文直接显示(而不是 \uXXXX 格式)
|
||||
# Flask >= 2.3 使用 app.json.ensure_ascii,旧版本使用 JSON_AS_ASCII 配置
|
||||
if hasattr(app, 'json') and hasattr(app.json, 'ensure_ascii'):
|
||||
|
|
@ -41,7 +49,38 @@ def create_app(config_class=Config):
|
|||
|
||||
# 启用CORS
|
||||
CORS(app, resources={r"/api/*": {"origins": "*"}})
|
||||
|
||||
|
||||
# API Key 鉴权(C2):所有 /api/* 端点强制鉴权。
|
||||
# 客户端通过 `X-API-Key: <key>` 或 `Authorization: Bearer <key>` 传入。
|
||||
# /health 等非 /api 路径豁免;CORS 预检(OPTIONS)放行(浏览器预检不带自定义头)。
|
||||
@app.before_request
|
||||
def require_api_key():
|
||||
if not Config.AUTH_ENABLED:
|
||||
return None
|
||||
path = request.path or ''
|
||||
if not path.startswith('/api/'):
|
||||
return None
|
||||
if request.method == 'OPTIONS':
|
||||
return None
|
||||
provided = request.headers.get('X-API-Key', '')
|
||||
if not provided:
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if auth_header.startswith('Bearer '):
|
||||
provided = auth_header[7:]
|
||||
expected = Config.API_KEY or ''
|
||||
if not expected:
|
||||
return jsonify({"success": False, "error": "Unauthorized"}), 401
|
||||
# 常量时间比较,避免时序侧信道。两侧编码为 bytes —— compare_digest 对含非 ASCII 字符的
|
||||
# str 会抛 TypeError;编码后任何输入都安全,绝不让鉴权拒绝路径崩成 500。
|
||||
try:
|
||||
ok = hmac.compare_digest(provided.encode('utf-8'), expected.encode('utf-8'))
|
||||
except Exception:
|
||||
ok = False
|
||||
if not ok:
|
||||
return jsonify({"success": False, "error": "Unauthorized"}), 401
|
||||
return None
|
||||
|
||||
|
||||
# 注册模拟进程清理函数(确保服务器关闭时终止所有模拟进程)
|
||||
from .services.simulation_runner import SimulationRunner
|
||||
SimulationRunner.register_cleanup()
|
||||
|
|
|
|||
|
|
@ -21,9 +21,19 @@ class Config:
|
|||
"""Flask配置类"""
|
||||
|
||||
# Flask配置
|
||||
# 注意:SECRET_KEY 的默认值是公开值,仅供 DEBUG 模式使用;生产模式(DEBUG=false)
|
||||
# 必须通过环境变量设置自定义值(见 validate())。
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key')
|
||||
DEBUG = os.environ.get('FLASK_DEBUG', 'True').lower() == 'true'
|
||||
|
||||
# 安全默认(C1):DEBUG 默认关闭,避免误把 Werkzeug 交互式调试器(可远程 RCE)暴露到网络。
|
||||
DEBUG = os.environ.get('FLASK_DEBUG', 'False').lower() == 'true'
|
||||
|
||||
# 认证配置(C2):所有 /api/* 端点强制 API Key 鉴权
|
||||
# AUTH_ENABLED 默认开启;本地开发可显式设 AUTH_ENABLED=false 关闭。
|
||||
# fail-closed 解析:仅显式 false/0/no/off 才关闭鉴权;其余任何值(含空白、拼写错误、
|
||||
# 带尾换行的 'true\n'、'1'、'yes' 等)一律视为开启,避免 env 配置失误悄悄回到零鉴权。
|
||||
API_KEY = os.environ.get('API_KEY')
|
||||
AUTH_ENABLED = os.environ.get('AUTH_ENABLED', 'true').strip().lower() not in ('false', '0', 'no', 'off')
|
||||
|
||||
# JSON配置 - 禁用ASCII转义,让中文直接显示(而不是 \uXXXX 格式)
|
||||
JSON_AS_ASCII = False
|
||||
|
||||
|
|
@ -45,7 +55,18 @@ class Config:
|
|||
DEFAULT_CHUNK_OVERLAP = 50 # 默认重叠大小
|
||||
|
||||
# OASIS模拟配置
|
||||
OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '10'))
|
||||
# OASIS_DEFAULT_MAX_ROUNDS(C3):客户端未显式传 max_rounds 时应用的默认轮数上限。
|
||||
# 之前此常量从未被引用(dead config),现已在 SimulationRunner 中生效。默认 150 覆盖
|
||||
# 典型配置(72h/30min = 144 轮)以免悄悄截断标准演示;更长的配置会被截到此值,且无论如何
|
||||
# 都不会超过硬上限 OASIS_MAX_ROUNDS_CAP。匿名 denial-of-wallet 已由 C2 鉴权堵住,此处
|
||||
# 仅约束“已鉴权客户端”单次运行的成本上界。
|
||||
OASIS_DEFAULT_MAX_ROUNDS = int(os.environ.get('OASIS_DEFAULT_MAX_ROUNDS', '150'))
|
||||
# 硬上限(C3,denial-of-wallet 防护):无论客户端传入何值,轮数/agent 数都不得超过这些上限。
|
||||
OASIS_MAX_ROUNDS_CAP = int(os.environ.get('OASIS_MAX_ROUNDS_CAP', '200'))
|
||||
OASIS_MAX_AGENTS_CAP = int(os.environ.get('OASIS_MAX_AGENTS_CAP', '1000'))
|
||||
# 模拟超时(C4,秒):每轮 env.step 超时 + 整轮模拟总超时。子进程读取同名环境变量。
|
||||
OASIS_ROUND_TIMEOUT_SEC = int(os.environ.get('OASIS_ROUND_TIMEOUT_SEC', '600'))
|
||||
OASIS_RUN_TIMEOUT_SEC = int(os.environ.get('OASIS_RUN_TIMEOUT_SEC', '7200'))
|
||||
OASIS_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations')
|
||||
|
||||
# OASIS平台可用动作配置
|
||||
|
|
@ -71,5 +92,11 @@ class Config:
|
|||
errors.append("LLM_API_KEY 未配置")
|
||||
if not cls.ZEP_API_KEY:
|
||||
errors.append("ZEP_API_KEY 未配置")
|
||||
# C1:生产模式必须设置自定义 SECRET_KEY(默认值是公开值,可伪造签名 / 削弱调试器 PIN)
|
||||
if not cls.DEBUG and cls.SECRET_KEY == 'mirofish-secret-key':
|
||||
errors.append("生产模式(FLASK_DEBUG=false)必须设置自定义 SECRET_KEY")
|
||||
# C2:开启鉴权时必须配置 API_KEY,否则所有 /api/* 都会 401
|
||||
if cls.AUTH_ENABLED and not cls.API_KEY:
|
||||
errors.append("AUTH_ENABLED=true 时必须设置 API_KEY(或显式 AUTH_ENABLED=false 关闭鉴权)")
|
||||
return errors
|
||||
|
||||
|
|
|
|||
|
|
@ -351,14 +351,27 @@ class SimulationRunner:
|
|||
total_hours = time_config.get("total_simulation_hours", 72)
|
||||
minutes_per_round = time_config.get("minutes_per_round", 30)
|
||||
total_rounds = int(total_hours * 60 / minutes_per_round)
|
||||
|
||||
# 如果指定了最大轮数,则截断
|
||||
if max_rounds is not None and max_rounds > 0:
|
||||
original_rounds = total_rounds
|
||||
total_rounds = min(total_rounds, max_rounds)
|
||||
if total_rounds < original_rounds:
|
||||
logger.info(f"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})")
|
||||
|
||||
|
||||
# C3(denial-of-wallet 防护):限制总轮数。
|
||||
# - max_rounds 未指定时,应用服务端默认上限 OASIS_DEFAULT_MAX_ROUNDS(之前为 dead config)
|
||||
# - 无论是否指定,都不得超过硬上限 OASIS_MAX_ROUNDS_CAP
|
||||
effective_max = max_rounds if (max_rounds is not None and max_rounds > 0) else Config.OASIS_DEFAULT_MAX_ROUNDS
|
||||
effective_max = min(effective_max, Config.OASIS_MAX_ROUNDS_CAP)
|
||||
if total_rounds > effective_max:
|
||||
logger.info(
|
||||
f"轮数已限制: {total_rounds} -> {effective_max} "
|
||||
f"(max_rounds={max_rounds}, default={Config.OASIS_DEFAULT_MAX_ROUNDS}, cap={Config.OASIS_MAX_ROUNDS_CAP})"
|
||||
)
|
||||
total_rounds = effective_max
|
||||
|
||||
# C3(denial-of-wallet 防护):限制 agent 数量。超过硬上限直接拒绝(避免巨额并发 LLM 调用)。
|
||||
agent_count = len(config.get("agent_configs", []))
|
||||
if agent_count > Config.OASIS_MAX_AGENTS_CAP:
|
||||
raise ValueError(
|
||||
f"Agent 数量 {agent_count} 超过上限 {Config.OASIS_MAX_AGENTS_CAP},"
|
||||
f"请减少种子实体或调高 OASIS_MAX_AGENTS_CAP 环境变量"
|
||||
)
|
||||
|
||||
state = SimulationRunState(
|
||||
simulation_id=simulation_id,
|
||||
runner_status=RunnerStatus.STARTING,
|
||||
|
|
@ -419,9 +432,9 @@ class SimulationRunner:
|
|||
"--config", config_path, # 使用完整配置文件路径
|
||||
]
|
||||
|
||||
# 如果指定了最大轮数,添加到命令行参数
|
||||
if max_rounds is not None and max_rounds > 0:
|
||||
cmd.extend(["--max-rounds", str(max_rounds)])
|
||||
# C3:始终把已限制的有效轮数传给子进程,确保子进程按 default/cap 截断
|
||||
# (total_rounds 此处已应用 OASIS_DEFAULT_MAX_ROUNDS 与 OASIS_MAX_ROUNDS_CAP)
|
||||
cmd.extend(["--max-rounds", str(total_rounds)])
|
||||
|
||||
# 创建主日志文件,避免 stdout/stderr 管道缓冲区满导致进程阻塞
|
||||
main_log_path = os.path.join(sim_dir, "simulation.log")
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ dependencies = [
|
|||
# 核心框架
|
||||
"flask>=3.0.0",
|
||||
"flask-cors>=6.0.0",
|
||||
|
||||
# 生产 WSGI 服务器(C1:替代 Werkzeug 开发服务器/调试器;须以 -w 1 --threads N 单进程启动)
|
||||
"gunicorn>=21.0.0",
|
||||
|
||||
# LLM 相关
|
||||
"openai>=1.0.0",
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@
|
|||
# ============= 核心框架 =============
|
||||
flask>=3.0.0
|
||||
flask-cors>=6.0.0
|
||||
# 生产 WSGI 服务器(C1:替代 Flask 自带的 Werkzeug 开发服务器/调试器)
|
||||
# 注意:因模拟运行态保存在进程内类变量,必须以单 worker 多线程启动(-w 1 --threads N)
|
||||
gunicorn>=21.0.0
|
||||
|
||||
# ============= LLM 相关 =============
|
||||
# OpenAI SDK(统一使用 OpenAI 格式调用 LLM)
|
||||
|
|
|
|||
|
|
@ -81,6 +81,11 @@ from typing import Dict, Any, List, Optional, Tuple
|
|||
_shutdown_event = None
|
||||
_cleanup_done = False
|
||||
|
||||
# C4:模拟超时(秒)。从环境变量读取(子进程继承 Flask 父进程的环境)。
|
||||
# 每轮 env.step 超时防止单轮因 LLM/网络挂起而永久 wedge;总超时为整轮模拟的硬墙钟上限。
|
||||
_ROUND_TIMEOUT_SEC = int(os.environ.get("OASIS_ROUND_TIMEOUT_SEC", "600"))
|
||||
_RUN_TIMEOUT_SEC = int(os.environ.get("OASIS_RUN_TIMEOUT_SEC", "7200"))
|
||||
|
||||
# 添加 backend 目录到路径
|
||||
# 脚本固定位于 backend/scripts/ 目录
|
||||
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
|
@ -333,8 +338,10 @@ class ParallelIPCHandler:
|
|||
action_args={"prompt": prompt}
|
||||
)
|
||||
actions = {agent: interview_action}
|
||||
await env.step(actions)
|
||||
|
||||
# C4:采访 env.step 加超时;TimeoutError 会被本方法的 except Exception 捕获并返回错误响应,
|
||||
# 不会让持久化命令循环永久卡死。
|
||||
await asyncio.wait_for(env.step(actions), timeout=_ROUND_TIMEOUT_SEC)
|
||||
|
||||
result = self._get_interview_result(agent_id, actual_platform)
|
||||
result["platform"] = actual_platform
|
||||
return result
|
||||
|
|
@ -466,7 +473,7 @@ class ParallelIPCHandler:
|
|||
print(f" 警告: 无法获取Twitter Agent {agent_id}: {e}")
|
||||
|
||||
if twitter_actions:
|
||||
await self.twitter_env.step(twitter_actions)
|
||||
await asyncio.wait_for(self.twitter_env.step(twitter_actions), timeout=_ROUND_TIMEOUT_SEC) # C4:批量采访超时
|
||||
|
||||
for interview in twitter_interviews:
|
||||
agent_id = interview.get("agent_id")
|
||||
|
|
@ -493,7 +500,7 @@ class ParallelIPCHandler:
|
|||
print(f" 警告: 无法获取Reddit Agent {agent_id}: {e}")
|
||||
|
||||
if reddit_actions:
|
||||
await self.reddit_env.step(reddit_actions)
|
||||
await asyncio.wait_for(self.reddit_env.step(reddit_actions), timeout=_ROUND_TIMEOUT_SEC) # C4:批量采访超时
|
||||
|
||||
for interview in reddit_interviews:
|
||||
agent_id = interview.get("agent_id")
|
||||
|
|
@ -1203,8 +1210,12 @@ async def run_twitter_simulation(
|
|||
pass
|
||||
|
||||
if initial_actions:
|
||||
await result.env.step(initial_actions)
|
||||
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
|
||||
# C4:初始帖子的 env.step 也加超时 —— 否则在进入受保护的主循环前就可能因 LLM/网络挂起而 wedge
|
||||
try:
|
||||
await asyncio.wait_for(result.env.step(initial_actions), timeout=_ROUND_TIMEOUT_SEC)
|
||||
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
|
||||
except asyncio.TimeoutError:
|
||||
log_info(f"[超时] 初始帖子 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过初始帖子继续")
|
||||
|
||||
# 记录 round 0 结束
|
||||
if action_logger:
|
||||
|
|
@ -1250,8 +1261,20 @@ async def run_twitter_simulation(
|
|||
action_logger.log_round_end(round_num + 1, 0)
|
||||
continue
|
||||
|
||||
# C4:总时长上限 —— 超过则优雅停止(保留环境,后续仍可 close/interview)
|
||||
if (datetime.now() - start_time).total_seconds() > _RUN_TIMEOUT_SEC:
|
||||
log_info(f"[超时] 模拟总时长超过 {_RUN_TIMEOUT_SEC}s,在第 {round_num + 1} 轮停止")
|
||||
break
|
||||
|
||||
actions = {agent: LLMAction() for _, agent in active_agents}
|
||||
await result.env.step(actions)
|
||||
# C4:每轮超时 —— 防止 env.step 因 LLM/网络挂起而永久 wedge
|
||||
try:
|
||||
await asyncio.wait_for(result.env.step(actions), timeout=_ROUND_TIMEOUT_SEC)
|
||||
except asyncio.TimeoutError:
|
||||
log_info(f"[超时] 第 {round_num + 1} 轮 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过并停止循环")
|
||||
if action_logger:
|
||||
action_logger.log_round_end(round_num + 1, 0)
|
||||
break
|
||||
|
||||
# 从数据库获取实际执行的动作并记录
|
||||
actual_actions, last_rowid = fetch_new_actions_from_db(
|
||||
|
|
@ -1402,8 +1425,12 @@ async def run_reddit_simulation(
|
|||
pass
|
||||
|
||||
if initial_actions:
|
||||
await result.env.step(initial_actions)
|
||||
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
|
||||
# C4:初始帖子的 env.step 也加超时 —— 否则在进入受保护的主循环前就可能因 LLM/网络挂起而 wedge
|
||||
try:
|
||||
await asyncio.wait_for(result.env.step(initial_actions), timeout=_ROUND_TIMEOUT_SEC)
|
||||
log_info(f"已发布 {len(initial_actions)} 条初始帖子")
|
||||
except asyncio.TimeoutError:
|
||||
log_info(f"[超时] 初始帖子 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过初始帖子继续")
|
||||
|
||||
# 记录 round 0 结束
|
||||
if action_logger:
|
||||
|
|
@ -1449,8 +1476,20 @@ async def run_reddit_simulation(
|
|||
action_logger.log_round_end(round_num + 1, 0)
|
||||
continue
|
||||
|
||||
# C4:总时长上限 —— 超过则优雅停止(保留环境,后续仍可 close/interview)
|
||||
if (datetime.now() - start_time).total_seconds() > _RUN_TIMEOUT_SEC:
|
||||
log_info(f"[超时] 模拟总时长超过 {_RUN_TIMEOUT_SEC}s,在第 {round_num + 1} 轮停止")
|
||||
break
|
||||
|
||||
actions = {agent: LLMAction() for _, agent in active_agents}
|
||||
await result.env.step(actions)
|
||||
# C4:每轮超时 —— 防止 env.step 因 LLM/网络挂起而永久 wedge
|
||||
try:
|
||||
await asyncio.wait_for(result.env.step(actions), timeout=_ROUND_TIMEOUT_SEC)
|
||||
except asyncio.TimeoutError:
|
||||
log_info(f"[超时] 第 {round_num + 1} 轮 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过并停止循环")
|
||||
if action_logger:
|
||||
action_logger.log_round_end(round_num + 1, 0)
|
||||
break
|
||||
|
||||
# 从数据库获取实际执行的动作并记录
|
||||
actual_actions, last_rowid = fetch_new_actions_from_db(
|
||||
|
|
@ -1577,16 +1616,34 @@ async def main():
|
|||
reddit_result: Optional[PlatformSimulation] = None
|
||||
|
||||
if args.twitter_only:
|
||||
twitter_result = await run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds)
|
||||
# C4:与并行路径对称 —— 单平台非超时异常也隔离为 None 并记录,确保后续 env.close 块仍可达
|
||||
try:
|
||||
twitter_result = await run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds)
|
||||
except Exception as e:
|
||||
log_manager.error(f"[Twitter] 模拟异常,已隔离: {e}")
|
||||
twitter_result = None
|
||||
elif args.reddit_only:
|
||||
reddit_result = await run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds)
|
||||
try:
|
||||
reddit_result = await run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds)
|
||||
except Exception as e:
|
||||
log_manager.error(f"[Reddit] 模拟异常,已隔离: {e}")
|
||||
reddit_result = None
|
||||
else:
|
||||
# 并行运行(每个平台使用独立的日志记录器)
|
||||
# C4:return_exceptions=True —— 一个平台抛异常(如 LLM 401/429/配额)不再取消另一平台,
|
||||
# 也不会绕过下方的 env.close(否则环境泄漏,违背“优雅停止、保留环境”的设计)。
|
||||
results = await asyncio.gather(
|
||||
run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds),
|
||||
run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds),
|
||||
return_exceptions=True,
|
||||
)
|
||||
twitter_result, reddit_result = results
|
||||
if isinstance(twitter_result, BaseException):
|
||||
log_manager.error(f"[Twitter] 模拟异常,已隔离: {twitter_result}")
|
||||
twitter_result = None
|
||||
if isinstance(reddit_result, BaseException):
|
||||
log_manager.error(f"[Reddit] 模拟异常,已隔离: {reddit_result}")
|
||||
reddit_result = None
|
||||
|
||||
total_elapsed = (datetime.now() - start_time).total_seconds()
|
||||
log_manager.info("=" * 60)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ from typing import Dict, Any, List, Optional
|
|||
_shutdown_event = None
|
||||
_cleanup_done = False
|
||||
|
||||
# C4:模拟超时(秒),从环境变量读取(子进程继承父进程环境)
|
||||
_ROUND_TIMEOUT_SEC = int(os.environ.get("OASIS_ROUND_TIMEOUT_SEC", "600"))
|
||||
_RUN_TIMEOUT_SEC = int(os.environ.get("OASIS_RUN_TIMEOUT_SEC", "7200"))
|
||||
|
||||
# 添加项目路径
|
||||
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
|
||||
|
|
@ -230,7 +234,7 @@ class IPCHandler:
|
|||
|
||||
# 执行Interview
|
||||
actions = {agent: interview_action}
|
||||
await self.env.step(actions)
|
||||
await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) # C4:采访超时(TimeoutError 由本块 except 捕获)
|
||||
|
||||
# 从数据库获取结果
|
||||
result = self._get_interview_result(agent_id)
|
||||
|
|
@ -276,7 +280,7 @@ class IPCHandler:
|
|||
return False
|
||||
|
||||
# 执行批量Interview
|
||||
await self.env.step(actions)
|
||||
await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) # C4:采访超时(TimeoutError 由本块 except 捕获)
|
||||
|
||||
# 获取所有结果
|
||||
results = {}
|
||||
|
|
@ -616,8 +620,12 @@ class RedditSimulationRunner:
|
|||
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
|
||||
|
||||
if initial_actions:
|
||||
await self.env.step(initial_actions)
|
||||
print(f" 已发布 {len(initial_actions)} 条初始帖子")
|
||||
# C4:初始帖子的 env.step 也加超时,避免进入主循环前就因 LLM/网络挂起而 wedge
|
||||
try:
|
||||
await asyncio.wait_for(self.env.step(initial_actions), timeout=_ROUND_TIMEOUT_SEC)
|
||||
print(f" 已发布 {len(initial_actions)} 条初始帖子")
|
||||
except asyncio.TimeoutError:
|
||||
print(f" [超时] 初始帖子 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过继续", flush=True)
|
||||
|
||||
# 主模拟循环
|
||||
print("\n开始模拟循环...")
|
||||
|
|
@ -634,13 +642,23 @@ class RedditSimulationRunner:
|
|||
|
||||
if not active_agents:
|
||||
continue
|
||||
|
||||
|
||||
# C4:总时长上限 —— 超过则优雅停止
|
||||
if (datetime.now() - start_time).total_seconds() > _RUN_TIMEOUT_SEC:
|
||||
print(f"[超时] 模拟总时长超过 {_RUN_TIMEOUT_SEC}s,在第 {round_num + 1} 轮停止", flush=True)
|
||||
break
|
||||
|
||||
actions = {
|
||||
agent: LLMAction()
|
||||
for _, agent in active_agents
|
||||
}
|
||||
|
||||
await self.env.step(actions)
|
||||
|
||||
# C4:每轮超时 —— 防止 env.step 因 LLM/网络挂起而永久 wedge
|
||||
try:
|
||||
await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC)
|
||||
except asyncio.TimeoutError:
|
||||
print(f"[超时] 第 {round_num + 1} 轮 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过并停止循环", flush=True)
|
||||
break
|
||||
|
||||
if (round_num + 1) % 10 == 0 or round_num == 0:
|
||||
elapsed = (datetime.now() - start_time).total_seconds()
|
||||
|
|
|
|||
|
|
@ -29,6 +29,10 @@ from typing import Dict, Any, List, Optional
|
|||
_shutdown_event = None
|
||||
_cleanup_done = False
|
||||
|
||||
# C4:模拟超时(秒),从环境变量读取(子进程继承父进程环境)
|
||||
_ROUND_TIMEOUT_SEC = int(os.environ.get("OASIS_ROUND_TIMEOUT_SEC", "600"))
|
||||
_RUN_TIMEOUT_SEC = int(os.environ.get("OASIS_RUN_TIMEOUT_SEC", "7200"))
|
||||
|
||||
# 添加项目路径
|
||||
_scripts_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
|
||||
|
|
@ -230,7 +234,7 @@ class IPCHandler:
|
|||
|
||||
# 执行Interview
|
||||
actions = {agent: interview_action}
|
||||
await self.env.step(actions)
|
||||
await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) # C4:采访超时(TimeoutError 由本块 except 捕获)
|
||||
|
||||
# 从数据库获取结果
|
||||
result = self._get_interview_result(agent_id)
|
||||
|
|
@ -276,7 +280,7 @@ class IPCHandler:
|
|||
return False
|
||||
|
||||
# 执行批量Interview
|
||||
await self.env.step(actions)
|
||||
await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) # C4:采访超时(TimeoutError 由本块 except 捕获)
|
||||
|
||||
# 获取所有结果
|
||||
results = {}
|
||||
|
|
@ -623,8 +627,12 @@ class TwitterSimulationRunner:
|
|||
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
|
||||
|
||||
if initial_actions:
|
||||
await self.env.step(initial_actions)
|
||||
print(f" 已发布 {len(initial_actions)} 条初始帖子")
|
||||
# C4:初始帖子的 env.step 也加超时,避免进入主循环前就因 LLM/网络挂起而 wedge
|
||||
try:
|
||||
await asyncio.wait_for(self.env.step(initial_actions), timeout=_ROUND_TIMEOUT_SEC)
|
||||
print(f" 已发布 {len(initial_actions)} 条初始帖子")
|
||||
except asyncio.TimeoutError:
|
||||
print(f" [超时] 初始帖子 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过继续", flush=True)
|
||||
|
||||
# 主模拟循环
|
||||
print("\n开始模拟循环...")
|
||||
|
|
@ -643,15 +651,25 @@ class TwitterSimulationRunner:
|
|||
|
||||
if not active_agents:
|
||||
continue
|
||||
|
||||
|
||||
# C4:总时长上限 —— 超过则优雅停止
|
||||
if (datetime.now() - start_time).total_seconds() > _RUN_TIMEOUT_SEC:
|
||||
print(f"[超时] 模拟总时长超过 {_RUN_TIMEOUT_SEC}s,在第 {round_num + 1} 轮停止", flush=True)
|
||||
break
|
||||
|
||||
# 构建动作
|
||||
actions = {
|
||||
agent: LLMAction()
|
||||
for _, agent in active_agents
|
||||
}
|
||||
|
||||
|
||||
# 执行动作
|
||||
await self.env.step(actions)
|
||||
# C4:每轮超时 —— 防止 env.step 因 LLM/网络挂起而永久 wedge
|
||||
try:
|
||||
await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC)
|
||||
except asyncio.TimeoutError:
|
||||
print(f"[超时] 第 {round_num + 1} 轮 env.step 超过 {_ROUND_TIMEOUT_SEC}s,跳过并停止循环", flush=True)
|
||||
break
|
||||
|
||||
# 打印进度
|
||||
if (round_num + 1) % 10 == 0 or round_num == 0:
|
||||
|
|
|
|||
|
|
@ -511,6 +511,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/51/c7/b64cae5dba3a1b138d7123ec36bb5ccd39d39939f18454407e5468f4763f/fsspec-2025.12.0-py3-none-any.whl", hash = "sha256:8bf1fe301b7d8acfa6e8571e3b1c3d158f909666642431cc78a1b7b4dbc5ec5b", size = 201422, upload-time = "2025-12-03T15:23:41.434Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "26.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/b7/a4a3f632f823e432ce6bc65f62961b7980c898c77f075a2f7118cb3846fe/gunicorn-26.0.0.tar.gz", hash = "sha256:ca9346f85e3a4aeeb64d491045c16b9a35647abd37ea15efe53080eb8b090baf", size = 727286, upload-time = "2026-05-05T06:38:25.529Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/40/9c2384fc2be4ad25dd4a49decd5ad9ea5a3639814c11bd40ab77cb9f0a14/gunicorn-26.0.0-py3-none-any.whl", hash = "sha256:40233d26a5f0d1872916188c276e21641155111c2853f0c2cd55260aec0d24fc", size = 212009, upload-time = "2026-05-05T06:38:23.007Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
|
|
@ -994,6 +1006,7 @@ dependencies = [
|
|||
{ name = "charset-normalizer" },
|
||||
{ name = "flask" },
|
||||
{ name = "flask-cors" },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "openai" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pymupdf" },
|
||||
|
|
@ -1022,6 +1035,7 @@ requires-dist = [
|
|||
{ name = "charset-normalizer", specifier = ">=3.0.0" },
|
||||
{ name = "flask", specifier = ">=3.0.0" },
|
||||
{ name = "flask-cors", specifier = ">=6.0.0" },
|
||||
{ name = "gunicorn", specifier = ">=21.0.0" },
|
||||
{ name = "openai", specifier = ">=1.0.0" },
|
||||
{ name = "pipreqs", marker = "extra == 'dev'", specifier = ">=0.5.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
|
|
|
|||
|
|
@ -3,6 +3,13 @@ services:
|
|||
image: ghcr.io/666ghj/mirofish:latest
|
||||
# 加速镜像(如拉取缓慢可替换上方地址)
|
||||
# image: ghcr.nju.edu.cn/666ghj/mirofish:latest
|
||||
# C2:本地构建以把前端 API Key 烤进打包产物。`docker compose up --build` 会用根 .env 里的
|
||||
# VITE_API_KEY 作为 build-arg;若直接拉取上方预构建镜像(不 --build),其内置的 key 不可控,
|
||||
# 此时请改用 AUTH_ENABLED=false(单机/内网)或自行构建带 VITE_API_KEY 的镜像。
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
VITE_API_KEY: ${VITE_API_KEY:-}
|
||||
container_name: mirofish
|
||||
env_file:
|
||||
- .env
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
# 前端构建期变量(Vite 读取,仅 VITE_ 前缀会暴露到客户端包)
|
||||
# 本地前端构建:复制为 frontend/.env 并填写;Docker 构建则由 docker compose 经 build-arg 注入(见根 .env 的 VITE_API_KEY)。
|
||||
|
||||
# 必须等于后端 .env 的 API_KEY —— 打包后的 UI 会用它作为 X-API-Key 调用 /api/*。
|
||||
# ⚠️ 会被打进客户端包、可被任何访问者提取,不能当作多租户隔离手段(见 README 安全说明)。
|
||||
VITE_API_KEY=
|
||||
|
||||
# 后端 API 基地址(默认走前端预览/开发服务器的 /api 代理到后端;如直连后端请填写完整地址)
|
||||
# VITE_API_BASE_URL=http://localhost:5001
|
||||
|
|
@ -10,10 +10,19 @@ const service = axios.create({
|
|||
}
|
||||
})
|
||||
|
||||
// API Key(C2):后端默认对 /api/* 强制鉴权,前端需在每个请求带上 X-API-Key。
|
||||
// 注意:构建到客户端包里的 VITE_API_KEY 是【可被任何访问者从 JS 包中提取】的,因此对“公开部署”
|
||||
// 它只能挡住不加载页面的脚本式滥用,不能当作多租户隔离手段。真正的多租户场景应改为会话登录鉴权,
|
||||
// 或在网关处注入按用户签发的 token;单机/内网/网关后部署时此值足够。
|
||||
const API_KEY = import.meta.env.VITE_API_KEY || ''
|
||||
|
||||
// 请求拦截器
|
||||
service.interceptors.request.use(
|
||||
config => {
|
||||
config.headers['Accept-Language'] = i18n.global.locale.value
|
||||
if (API_KEY) {
|
||||
config.headers['X-API-Key'] = API_KEY
|
||||
}
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
|
|
|
|||
|
|
@ -21,5 +21,19 @@ export default defineConfig({
|
|||
secure: false
|
||||
}
|
||||
}
|
||||
},
|
||||
// C1:生产镜像用 `vite preview` 提供已构建的静态产物(而非开发服务器)。
|
||||
// preview 默认不套用 server.proxy,需单独声明,否则 /api 不被转发到后端。
|
||||
// host:'0.0.0.0' 必需:默认仅绑 localhost,在容器内会让 Docker 端口映射(3000:3000)无法触达。
|
||||
preview: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5001',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@
|
|||
"dev": "concurrently --kill-others -n \"backend,frontend\" -c \"green,cyan\" \"npm run backend\" \"npm run frontend\"",
|
||||
"backend": "cd backend && uv run python run.py",
|
||||
"frontend": "cd frontend && npm run dev",
|
||||
"build": "cd frontend && npm run build"
|
||||
"build": "cd frontend && npm run build",
|
||||
"start": "concurrently --kill-others -n \"backend,frontend\" -c \"green,cyan\" \"npm run backend:prod\" \"npm run frontend:preview\"",
|
||||
"backend:prod": "cd backend && uv run gunicorn -w 1 --threads 8 --timeout 120 -b 0.0.0.0:5001 'app:create_app()'",
|
||||
"frontend:preview": "cd frontend && npm run preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.2"
|
||||
|
|
|
|||
Loading…
Reference in New Issue