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:
Yo-LRK 2026-06-13 17:19:05 +07:00
parent 96096ea0ff
commit 8cb92768ae
17 changed files with 356 additions and 48 deletions

View File

@ -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
# ===== 模拟成本上限C3denial-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

View File

@ -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_KEYFLASK_DEBUG 默认 false
CMD ["npm", "run", "start"]

View File

@ -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

View File

@ -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()

View File

@ -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'
# 安全默认C1DEBUG 默认关闭,避免误把 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_ROUNDSC3客户端未显式传 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'))
# 硬上限C3denial-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

View File

@ -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})")
# C3denial-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
# C3denial-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")

View File

@ -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",

View File

@ -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

View File

@ -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:
# 并行运行(每个平台使用独立的日志记录器)
# C4return_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)

View File

@ -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()

View File

@ -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:

View File

@ -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" },

View File

@ -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

9
frontend/.env.example Normal file
View File

@ -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

View File

@ -10,10 +10,19 @@ const service = axios.create({
}
})
// API KeyC2后端默认对 /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 => {

View File

@ -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
}
}
}
})

View File

@ -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"