This commit is contained in:
Watchara Sueasakul 2026-06-13 11:23:07 +00:00 committed by GitHub
commit 54b110fab9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 667 additions and 202 deletions

View File

@ -14,3 +14,35 @@ ZEP_API_KEY=your_zep_api_key_here
LLM_BOOST_API_KEY=your_api_key_here LLM_BOOST_API_KEY=your_api_key_here
LLM_BOOST_BASE_URL=your_base_url_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
# ===== CORS 允许来源H4=====
# 逗号分隔的前端来源;不再用通配 '*'。生产填前端域名,例如 https://app.example.com
ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
# ===== 模拟成本上限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 . . 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 EXPOSE 3000 5001
# 同时启动前后端(开发模式) # C1生产启动 —— 后端用 gunicorn单 worker 多线程,保留进程内模拟态),
CMD ["npm", "run", "dev"] # 前端用 vite preview 提供已构建产物。开发请改用 `npm run dev`。
# 安全前提:须经 .env 设置 SECRET_KEY 与 API_KEYFLASK_DEBUG 默认 false
CMD ["npm", "run", "start"]

View File

@ -127,6 +127,48 @@ LLM_MODEL_NAME=qwen-plus
ZEP_API_KEY=your_zep_api_key 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.
- **CSP / API origin:** the frontend ships a Content-Security-Policy whose `connect-src` allows
`'self'` + `http://localhost:5001` (the default API). If you point the UI at a different API
host (set `VITE_API_BASE_URL`), you **must** add that origin to `connect-src` in
`frontend/index.html` too, or the browser will silently block all API calls. Restrict
`ALLOWED_ORIGINS` (backend) to your real frontend origin in production.
#### 2. Install Dependencies #### 2. Install Dependencies
```bash ```bash

View File

@ -2,6 +2,7 @@
MiroFish Backend - Flask应用工厂 MiroFish Backend - Flask应用工厂
""" """
import hmac
import os import os
import warnings import warnings
@ -9,7 +10,7 @@ import warnings
# 需要在所有其他导入之前设置 # 需要在所有其他导入之前设置
warnings.filterwarnings("ignore", message=".*resource_tracker.*") warnings.filterwarnings("ignore", message=".*resource_tracker.*")
from flask import Flask, request from flask import Flask, jsonify, request
from flask_cors import CORS from flask_cors import CORS
from .config import Config from .config import Config
@ -18,6 +19,13 @@ from .utils.logger import setup_logger, get_logger
def create_app(config_class=Config): def create_app(config_class=Config):
"""Flask应用工厂函数""" """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 = Flask(__name__)
app.config.from_object(config_class) app.config.from_object(config_class)
@ -39,8 +47,39 @@ def create_app(config_class=Config):
logger.info("MiroFish Backend 启动中...") logger.info("MiroFish Backend 启动中...")
logger.info("=" * 50) logger.info("=" * 50)
# 启用CORS # 启用CORSH4限定来源为 Config.ALLOWED_ORIGINS默认本地前端源不再通配 '*'。
CORS(app, resources={r"/api/*": {"origins": "*"}}) CORS(app, resources={r"/api/*": {"origins": Config.ALLOWED_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 from .services.simulation_runner import SimulationRunner
@ -62,6 +101,15 @@ def create_app(config_class=Config):
logger.debug(f"响应: {response.status_code}") logger.debug(f"响应: {response.status_code}")
return response return response
# 安全响应头纵深防御API 响应附带基础安全头。前端 HTML 的 CSP 由 index.html 的
# <meta> + vite preview 响应头提供(后端不直接服务 HTML
@app.after_request
def security_headers(response):
response.headers.setdefault('X-Content-Type-Options', 'nosniff')
response.headers.setdefault('X-Frame-Options', 'DENY')
response.headers.setdefault('Referrer-Policy', 'strict-origin-when-cross-origin')
return response
# 注册蓝图 # 注册蓝图
from .api import graph_bp, simulation_bp, report_bp from .api import graph_bp, simulation_bp, report_bp
app.register_blueprint(graph_bp, url_prefix='/api/graph') app.register_blueprint(graph_bp, url_prefix='/api/graph')

View File

@ -4,7 +4,7 @@
""" """
import os import os
import traceback from ..utils.security import safe_traceback, safe_error, upload_content_ok
import threading import threading
from flask import request, jsonify from flask import request, jsonify
@ -182,7 +182,8 @@ def generate_ontology():
all_text = "" all_text = ""
for file in uploaded_files: for file in uploaded_files:
if file and file.filename and allowed_file(file.filename): # 扩展名白名单 + 魔术字节嗅探(拒绝改名混入的二进制/伪装文件)
if file and file.filename and allowed_file(file.filename) and upload_content_ok(file, file.filename):
# 保存文件到项目目录 # 保存文件到项目目录
file_info = ProjectManager.save_file_to_project( file_info = ProjectManager.save_file_to_project(
project.project_id, project.project_id,
@ -250,8 +251,8 @@ def generate_ontology():
except Exception as e: except Exception as e:
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -495,17 +496,17 @@ def build_graph():
except Exception as e: except Exception as e:
# 更新项目状态为失败 # 更新项目状态为失败
build_logger.error(f"[{task_id}] 图谱构建失败: {str(e)}") build_logger.error(f"[{task_id}] 图谱构建失败: {str(e)}")
build_logger.debug(traceback.format_exc()) build_logger.debug(safe_traceback())
project.status = ProjectStatus.FAILED project.status = ProjectStatus.FAILED
project.error = str(e) project.error = safe_error(e)
ProjectManager.save_project(project) ProjectManager.save_project(project)
task_manager.update_task( task_manager.update_task(
task_id, task_id,
status=TaskStatus.FAILED, status=TaskStatus.FAILED,
message=t('progress.buildFailed', error=str(e)), message=t('progress.buildFailed', error=safe_error(e)),
error=traceback.format_exc() error=safe_traceback()
) )
# 启动后台线程 # 启动后台线程
@ -524,8 +525,8 @@ def build_graph():
except Exception as e: except Exception as e:
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -589,8 +590,8 @@ def get_graph_data(graph_id: str):
except Exception as e: except Exception as e:
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -617,6 +618,6 @@ def delete_graph(graph_id: str):
except Exception as e: except Exception as e:
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500

View File

@ -4,7 +4,7 @@ Report API路由
""" """
import os import os
import traceback from ..utils.security import safe_traceback, safe_error
import threading import threading
from flask import request, jsonify, send_file from flask import request, jsonify, send_file
@ -173,7 +173,7 @@ def generate_report():
except Exception as e: except Exception as e:
logger.error(f"报告生成失败: {str(e)}") logger.error(f"报告生成失败: {str(e)}")
task_manager.fail_task(task_id, str(e)) task_manager.fail_task(task_id, safe_error(e))
# 启动后台线程 # 启动后台线程
thread = threading.Thread(target=run_generate, daemon=True) thread = threading.Thread(target=run_generate, daemon=True)
@ -195,8 +195,8 @@ def generate_report():
logger.error(f"启动报告生成任务失败: {str(e)}") logger.error(f"启动报告生成任务失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -268,7 +268,7 @@ def get_generate_status():
logger.error(f"查询任务状态失败: {str(e)}") logger.error(f"查询任务状态失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e) "error": safe_error(e)
}), 500 }), 500
@ -311,8 +311,8 @@ def get_report(report_id: str):
logger.error(f"获取报告失败: {str(e)}") logger.error(f"获取报告失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -350,8 +350,8 @@ def get_report_by_simulation(simulation_id: str):
logger.error(f"获取报告失败: {str(e)}") logger.error(f"获取报告失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -390,8 +390,8 @@ def list_reports():
logger.error(f"列出报告失败: {str(e)}") logger.error(f"列出报告失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -436,8 +436,8 @@ def download_report(report_id: str):
logger.error(f"下载报告失败: {str(e)}") logger.error(f"下载报告失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -462,8 +462,8 @@ def delete_report(report_id: str):
logger.error(f"删除报告失败: {str(e)}") logger.error(f"删除报告失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -559,8 +559,8 @@ def chat_with_report_agent():
logger.error(f"对话失败: {str(e)}") logger.error(f"对话失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -602,8 +602,8 @@ def get_report_progress(report_id: str):
logger.error(f"获取报告进度失败: {str(e)}") logger.error(f"获取报告进度失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -653,8 +653,8 @@ def get_report_sections(report_id: str):
logger.error(f"获取章节列表失败: {str(e)}") logger.error(f"获取章节列表失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -697,8 +697,8 @@ def get_single_section(report_id: str, section_index: int):
logger.error(f"获取章节内容失败: {str(e)}") logger.error(f"获取章节内容失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -748,8 +748,8 @@ def check_report_status(simulation_id: str):
logger.error(f"检查报告状态失败: {str(e)}") logger.error(f"检查报告状态失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -809,8 +809,8 @@ def get_agent_log(report_id: str):
logger.error(f"获取Agent日志失败: {str(e)}") logger.error(f"获取Agent日志失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -843,8 +843,8 @@ def stream_agent_log(report_id: str):
logger.error(f"获取Agent日志失败: {str(e)}") logger.error(f"获取Agent日志失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -891,8 +891,8 @@ def get_console_log(report_id: str):
logger.error(f"获取控制台日志失败: {str(e)}") logger.error(f"获取控制台日志失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -925,8 +925,8 @@ def stream_console_log(report_id: str):
logger.error(f"获取控制台日志失败: {str(e)}") logger.error(f"获取控制台日志失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -975,8 +975,8 @@ def search_graph_tool():
logger.error(f"图谱搜索失败: {str(e)}") logger.error(f"图谱搜索失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1015,6 +1015,6 @@ def get_graph_statistics_tool():
logger.error(f"获取图谱统计失败: {str(e)}") logger.error(f"获取图谱统计失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500

View File

@ -4,7 +4,7 @@ Step2: Zep实体读取与过滤、OASIS模拟准备与运行全程自动化
""" """
import os import os
import traceback from ..utils.security import safe_traceback, safe_error
from flask import request, jsonify, send_file from flask import request, jsonify, send_file
from . import simulation_bp from . import simulation_bp
@ -85,8 +85,8 @@ def get_graph_entities(graph_id: str):
logger.error(f"获取图谱实体失败: {str(e)}") logger.error(f"获取图谱实体失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -118,8 +118,8 @@ def get_entity_detail(graph_id: str, entity_uuid: str):
logger.error(f"获取实体详情失败: {str(e)}") logger.error(f"获取实体详情失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -155,8 +155,8 @@ def get_entities_by_type(graph_id: str, entity_type: str):
logger.error(f"获取实体失败: {str(e)}") logger.error(f"获取实体失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -232,8 +232,8 @@ def create_simulation():
logger.error(f"创建模拟失败: {str(e)}") logger.error(f"创建模拟失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -597,14 +597,14 @@ def prepare_simulation():
) )
except Exception as e: except Exception as e:
logger.error(f"准备模拟失败: {str(e)}") logger.error(f"准备模拟失败: {e}", exc_info=True)
task_manager.fail_task(task_id, str(e)) task_manager.fail_task(task_id, safe_error(e))
# 更新模拟状态为失败 # 更新模拟状态为失败
state = manager.get_simulation(simulation_id) state = manager.get_simulation(simulation_id)
if state: if state:
state.status = SimulationStatus.FAILED state.status = SimulationStatus.FAILED
state.error = str(e) state.error = safe_error(e)
manager._save_simulation_state(state) manager._save_simulation_state(state)
# 启动后台线程 # 启动后台线程
@ -634,8 +634,8 @@ def prepare_simulation():
logger.error(f"启动准备任务失败: {str(e)}") logger.error(f"启动准备任务失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -748,7 +748,7 @@ def get_prepare_status():
logger.error(f"查询任务状态失败: {str(e)}") logger.error(f"查询任务状态失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e) "error": safe_error(e)
}), 500 }), 500
@ -780,8 +780,8 @@ def get_simulation(simulation_id: str):
logger.error(f"获取模拟状态失败: {str(e)}") logger.error(f"获取模拟状态失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -809,8 +809,8 @@ def list_simulations():
logger.error(f"列出模拟失败: {str(e)}") logger.error(f"列出模拟失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -982,8 +982,8 @@ def get_simulation_history():
logger.error(f"获取历史模拟失败: {str(e)}") logger.error(f"获取历史模拟失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1020,8 +1020,8 @@ def get_simulation_profiles(simulation_id: str):
logger.error(f"获取Profile失败: {str(e)}") logger.error(f"获取Profile失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1130,8 +1130,8 @@ def get_simulation_profiles_realtime(simulation_id: str):
logger.error(f"实时获取Profile失败: {str(e)}") logger.error(f"实时获取Profile失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1250,8 +1250,8 @@ def get_simulation_config_realtime(simulation_id: str):
logger.error(f"实时获取Config失败: {str(e)}") logger.error(f"实时获取Config失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1286,8 +1286,8 @@ def get_simulation_config(simulation_id: str):
logger.error(f"获取配置失败: {str(e)}") logger.error(f"获取配置失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1315,8 +1315,8 @@ def download_simulation_config(simulation_id: str):
logger.error(f"下载配置失败: {str(e)}") logger.error(f"下载配置失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1367,8 +1367,8 @@ def download_simulation_script(script_name: str):
logger.error(f"下载脚本失败: {str(e)}") logger.error(f"下载脚本失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1441,8 +1441,8 @@ def generate_profiles():
logger.error(f"生成Profile失败: {str(e)}") logger.error(f"生成Profile失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1636,8 +1636,8 @@ def start_simulation():
logger.error(f"启动模拟失败: {str(e)}") logger.error(f"启动模拟失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1695,8 +1695,8 @@ def stop_simulation():
logger.error(f"停止模拟失败: {str(e)}") logger.error(f"停止模拟失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1755,8 +1755,8 @@ def get_run_status(simulation_id: str):
logger.error(f"获取运行状态失败: {str(e)}") logger.error(f"获取运行状态失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1856,8 +1856,8 @@ def get_run_status_detail(simulation_id: str):
logger.error(f"获取详细状态失败: {str(e)}") logger.error(f"获取详细状态失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1910,8 +1910,8 @@ def get_simulation_actions(simulation_id: str):
logger.error(f"获取动作历史失败: {str(e)}") logger.error(f"获取动作历史失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1950,8 +1950,8 @@ def get_simulation_timeline(simulation_id: str):
logger.error(f"获取时间线失败: {str(e)}") logger.error(f"获取时间线失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -1977,8 +1977,8 @@ def get_agent_stats(simulation_id: str):
logger.error(f"获取Agent统计失败: {str(e)}") logger.error(f"获取Agent统计失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -2057,8 +2057,8 @@ def get_simulation_posts(simulation_id: str):
logger.error(f"获取帖子失败: {str(e)}") logger.error(f"获取帖子失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -2132,8 +2132,8 @@ def get_simulation_comments(simulation_id: str):
logger.error(f"获取评论失败: {str(e)}") logger.error(f"获取评论失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -2263,8 +2263,8 @@ def interview_agent():
logger.error(f"Interview失败: {str(e)}") logger.error(f"Interview失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -2401,8 +2401,8 @@ def interview_agents_batch():
logger.error(f"批量Interview失败: {str(e)}") logger.error(f"批量Interview失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -2504,8 +2504,8 @@ def interview_all_agents():
logger.error(f"全局Interview失败: {str(e)}") logger.error(f"全局Interview失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -2576,8 +2576,8 @@ def get_interview_history():
logger.error(f"获取Interview历史失败: {str(e)}") logger.error(f"获取Interview历史失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -2641,8 +2641,8 @@ def get_env_status():
logger.error(f"获取环境状态失败: {str(e)}") logger.error(f"获取环境状态失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500
@ -2711,6 +2711,6 @@ def close_simulation_env():
logger.error(f"关闭环境失败: {str(e)}") logger.error(f"关闭环境失败: {str(e)}")
return jsonify({ return jsonify({
"success": False, "success": False,
"error": str(e), "error": safe_error(e),
"traceback": traceback.format_exc() "traceback": safe_traceback()
}), 500 }), 500

View File

@ -21,8 +21,26 @@ class Config:
"""Flask配置类""" """Flask配置类"""
# Flask配置 # Flask配置
# 注意SECRET_KEY 的默认值是公开值,仅供 DEBUG 模式使用生产模式DEBUG=false
# 必须通过环境变量设置自定义值(见 validate())。
SECRET_KEY = os.environ.get('SECRET_KEY', 'mirofish-secret-key') 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')
# CORS 允许来源H4不再用通配 '*'。默认仅本地前端开发/预览源;生产用逗号分隔的
# ALLOWED_ORIGINS 指定前端域名(例如 https://app.example.com。'*' 仍可显式设置但不推荐。
ALLOWED_ORIGINS = [
o.strip() for o in os.environ.get(
'ALLOWED_ORIGINS', 'http://localhost:3000,http://127.0.0.1:3000'
).split(',') if o.strip()
]
# JSON配置 - 禁用ASCII转义让中文直接显示而不是 \uXXXX 格式) # JSON配置 - 禁用ASCII转义让中文直接显示而不是 \uXXXX 格式)
JSON_AS_ASCII = False JSON_AS_ASCII = False
@ -45,7 +63,18 @@ class Config:
DEFAULT_CHUNK_OVERLAP = 50 # 默认重叠大小 DEFAULT_CHUNK_OVERLAP = 50 # 默认重叠大小
# OASIS模拟配置 # 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_SIMULATION_DATA_DIR = os.path.join(os.path.dirname(__file__), '../uploads/simulations')
# OASIS平台可用动作配置 # OASIS平台可用动作配置
@ -71,5 +100,11 @@ class Config:
errors.append("LLM_API_KEY 未配置") errors.append("LLM_API_KEY 未配置")
if not cls.ZEP_API_KEY: if not cls.ZEP_API_KEY:
errors.append("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 return errors

View File

@ -12,6 +12,7 @@ from typing import Dict, Any, List, Optional
from enum import Enum from enum import Enum
from dataclasses import dataclass, field, asdict from dataclasses import dataclass, field, asdict
from ..config import Config from ..config import Config
from ..utils.security import validate_id
class ProjectStatus(str, Enum): class ProjectStatus(str, Enum):
@ -112,6 +113,7 @@ class ProjectManager:
@classmethod @classmethod
def _get_project_dir(cls, project_id: str) -> str: def _get_project_dir(cls, project_id: str) -> str:
"""获取项目目录路径""" """获取项目目录路径"""
validate_id(project_id, 'project_id') # 路径校验,阻断穿越后再 join/rmtree
return os.path.join(cls.PROJECTS_DIR, project_id) return os.path.join(cls.PROJECTS_DIR, project_id)
@classmethod @classmethod

View File

@ -18,6 +18,10 @@ from ..models.task import TaskManager, TaskStatus
from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges from ..utils.zep_paging import fetch_all_nodes, fetch_all_edges
from .text_processor import TextProcessor from .text_processor import TextProcessor
from ..utils.locale import t, get_locale, set_locale from ..utils.locale import t, get_locale, set_locale
from ..utils.logger import get_logger
from ..utils.security import safe_error
logger = get_logger('mirofish.graph_builder')
@dataclass @dataclass
@ -186,9 +190,8 @@ class GraphBuilderService:
}) })
except Exception as e: except Exception as e:
import traceback logger.error(f"graph build failed (task {task_id})", exc_info=True)
error_msg = f"{str(e)}\n{traceback.format_exc()}" self.task_manager.fail_task(task_id, safe_error(e))
self.task_manager.fail_task(task_id, error_msg)
def create_graph(self, name: str) -> str: def create_graph(self, name: str) -> str:
"""创建Zep图谱公开方法""" """创建Zep图谱公开方法"""
@ -339,7 +342,7 @@ class GraphBuilderService:
except Exception as e: except Exception as e:
if progress_callback: if progress_callback:
progress_callback(t('progress.batchFailed', batch=batch_num, error=str(e)), 0) progress_callback(t('progress.batchFailed', batch=batch_num, error=safe_error(e)), 0)
raise raise
return episode_uuids return episode_uuids

View File

@ -21,6 +21,7 @@ from enum import Enum
from ..config import Config from ..config import Config
from ..utils.llm_client import LLMClient from ..utils.llm_client import LLMClient
from ..utils.logger import get_logger from ..utils.logger import get_logger
from ..utils.security import validate_id
from ..utils.locale import get_language_instruction, t from ..utils.locale import get_language_instruction, t
from .zep_tools import ( from .zep_tools import (
ZepToolsService, ZepToolsService,
@ -48,6 +49,7 @@ class ReportLogger:
Args: Args:
report_id: 报告ID用于确定日志文件路径 report_id: 报告ID用于确定日志文件路径
""" """
validate_id(report_id, 'report_id') # path check: report_id flows straight into the file path
self.report_id = report_id self.report_id = report_id
self.log_file_path = os.path.join( self.log_file_path = os.path.join(
Config.UPLOAD_FOLDER, 'reports', report_id, 'agent_log.jsonl' Config.UPLOAD_FOLDER, 'reports', report_id, 'agent_log.jsonl'
@ -319,6 +321,7 @@ class ReportConsoleLogger:
Args: Args:
report_id: 报告ID用于确定日志文件路径 report_id: 报告ID用于确定日志文件路径
""" """
validate_id(report_id, 'report_id') # path check: report_id flows straight into the file path
self.report_id = report_id self.report_id = report_id
self.log_file_path = os.path.join( self.log_file_path = os.path.join(
Config.UPLOAD_FOLDER, 'reports', report_id, 'console_log.txt' Config.UPLOAD_FOLDER, 'reports', report_id, 'console_log.txt'
@ -1910,6 +1913,7 @@ class ReportManager:
@classmethod @classmethod
def _get_report_folder(cls, report_id: str) -> str: def _get_report_folder(cls, report_id: str) -> str:
"""获取报告文件夹路径""" """获取报告文件夹路径"""
validate_id(report_id, 'report_id') # path check before join/rmtree
return os.path.join(cls.REPORTS_DIR, report_id) return os.path.join(cls.REPORTS_DIR, report_id)
@classmethod @classmethod

View File

@ -14,6 +14,7 @@ from enum import Enum
from ..config import Config from ..config import Config
from ..utils.logger import get_logger from ..utils.logger import get_logger
from ..utils.security import validate_id
from .zep_entity_reader import ZepEntityReader, FilteredEntities from .zep_entity_reader import ZepEntityReader, FilteredEntities
from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile from .oasis_profile_generator import OasisProfileGenerator, OasisAgentProfile
from .simulation_config_generator import SimulationConfigGenerator, SimulationParameters from .simulation_config_generator import SimulationConfigGenerator, SimulationParameters
@ -138,6 +139,7 @@ class SimulationManager:
def _get_simulation_dir(self, simulation_id: str) -> str: def _get_simulation_dir(self, simulation_id: str) -> str:
"""获取模拟数据目录""" """获取模拟数据目录"""
validate_id(simulation_id, 'simulation_id') # 路径校验,阻断穿越后再 join/makedirs
sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id) sim_dir = os.path.join(self.SIMULATION_DATA_DIR, simulation_id)
os.makedirs(sim_dir, exist_ok=True) os.makedirs(sim_dir, exist_ok=True)
return sim_dir return sim_dir

View File

@ -21,6 +21,7 @@ from queue import Queue
from ..config import Config from ..config import Config
from ..utils.logger import get_logger from ..utils.logger import get_logger
from ..utils.locale import get_locale, set_locale from ..utils.locale import get_locale, set_locale
from ..utils.security import validate_id
from .zep_graph_memory_updater import ZepGraphMemoryManager from .zep_graph_memory_updater import ZepGraphMemoryManager
from .simulation_ipc import SimulationIPCClient, CommandType, IPCResponse from .simulation_ipc import SimulationIPCClient, CommandType, IPCResponse
@ -239,10 +240,16 @@ class SimulationRunner:
cls._run_states[simulation_id] = state cls._run_states[simulation_id] = state
return state return state
@classmethod
def _run_dir(cls, simulation_id: str) -> str:
"""Validated RUN_STATE_DIR/<simulation_id> -- blocks path traversal before any fs op."""
validate_id(simulation_id, 'simulation_id')
return os.path.join(cls.RUN_STATE_DIR, simulation_id)
@classmethod @classmethod
def _load_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]: def _load_run_state(cls, simulation_id: str) -> Optional[SimulationRunState]:
"""从文件加载运行状态""" """从文件加载运行状态"""
state_file = os.path.join(cls.RUN_STATE_DIR, simulation_id, "run_state.json") state_file = os.path.join(cls._run_dir(simulation_id), "run_state.json")
if not os.path.exists(state_file): if not os.path.exists(state_file):
return None return None
@ -298,7 +305,7 @@ class SimulationRunner:
@classmethod @classmethod
def _save_run_state(cls, state: SimulationRunState): def _save_run_state(cls, state: SimulationRunState):
"""保存运行状态到文件""" """保存运行状态到文件"""
sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id) sim_dir = cls._run_dir(state.simulation_id)
os.makedirs(sim_dir, exist_ok=True) os.makedirs(sim_dir, exist_ok=True)
state_file = os.path.join(sim_dir, "run_state.json") state_file = os.path.join(sim_dir, "run_state.json")
@ -331,13 +338,16 @@ class SimulationRunner:
Returns: Returns:
SimulationRunState SimulationRunState
""" """
# 路径校验simulation_id 会进入 RUN_STATE_DIR 下的 join/makedirs 与子进程参数
validate_id(simulation_id, 'simulation_id')
# 检查是否已在运行 # 检查是否已在运行
existing = cls.get_run_state(simulation_id) existing = cls.get_run_state(simulation_id)
if existing and existing.runner_status in [RunnerStatus.RUNNING, RunnerStatus.STARTING]: if existing and existing.runner_status in [RunnerStatus.RUNNING, RunnerStatus.STARTING]:
raise ValueError(f"模拟已在运行中: {simulation_id}") raise ValueError(f"模拟已在运行中: {simulation_id}")
# 加载模拟配置 # 加载模拟配置
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
config_path = os.path.join(sim_dir, "simulation_config.json") config_path = os.path.join(sim_dir, "simulation_config.json")
if not os.path.exists(config_path): if not os.path.exists(config_path):
@ -352,12 +362,25 @@ class SimulationRunner:
minutes_per_round = time_config.get("minutes_per_round", 30) minutes_per_round = time_config.get("minutes_per_round", 30)
total_rounds = int(total_hours * 60 / minutes_per_round) total_rounds = int(total_hours * 60 / minutes_per_round)
# 如果指定了最大轮数,则截断 # C3denial-of-wallet 防护):限制总轮数。
if max_rounds is not None and max_rounds > 0: # - max_rounds 未指定时,应用服务端默认上限 OASIS_DEFAULT_MAX_ROUNDS之前为 dead config
original_rounds = total_rounds # - 无论是否指定,都不得超过硬上限 OASIS_MAX_ROUNDS_CAP
total_rounds = min(total_rounds, max_rounds) effective_max = max_rounds if (max_rounds is not None and max_rounds > 0) else Config.OASIS_DEFAULT_MAX_ROUNDS
if total_rounds < original_rounds: effective_max = min(effective_max, Config.OASIS_MAX_ROUNDS_CAP)
logger.info(f"轮数已截断: {original_rounds} -> {total_rounds} (max_rounds={max_rounds})") 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( state = SimulationRunState(
simulation_id=simulation_id, simulation_id=simulation_id,
@ -419,9 +442,9 @@ class SimulationRunner:
"--config", config_path, # 使用完整配置文件路径 "--config", config_path, # 使用完整配置文件路径
] ]
# 如果指定了最大轮数,添加到命令行参数 # C3始终把已限制的有效轮数传给子进程确保子进程按 default/cap 截断
if max_rounds is not None and max_rounds > 0: # total_rounds 此处已应用 OASIS_DEFAULT_MAX_ROUNDS 与 OASIS_MAX_ROUNDS_CAP
cmd.extend(["--max-rounds", str(max_rounds)]) cmd.extend(["--max-rounds", str(total_rounds)])
# 创建主日志文件,避免 stdout/stderr 管道缓冲区满导致进程阻塞 # 创建主日志文件,避免 stdout/stderr 管道缓冲区满导致进程阻塞
main_log_path = os.path.join(sim_dir, "simulation.log") main_log_path = os.path.join(sim_dir, "simulation.log")
@ -482,7 +505,7 @@ class SimulationRunner:
def _monitor_simulation(cls, simulation_id: str, locale: str = 'zh'): def _monitor_simulation(cls, simulation_id: str, locale: str = 'zh'):
"""监控模拟进程,解析动作日志""" """监控模拟进程,解析动作日志"""
set_locale(locale) set_locale(locale)
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
# 新的日志结构:分平台的动作日志 # 新的日志结构:分平台的动作日志
twitter_actions_log = os.path.join(sim_dir, "twitter", "actions.jsonl") twitter_actions_log = os.path.join(sim_dir, "twitter", "actions.jsonl")
@ -700,7 +723,7 @@ class SimulationRunner:
Returns: Returns:
True 如果所有启用的平台都已完成 True 如果所有启用的平台都已完成
""" """
sim_dir = os.path.join(cls.RUN_STATE_DIR, state.simulation_id) sim_dir = cls._run_dir(state.simulation_id)
twitter_log = os.path.join(sim_dir, "twitter", "actions.jsonl") twitter_log = os.path.join(sim_dir, "twitter", "actions.jsonl")
reddit_log = os.path.join(sim_dir, "reddit", "actions.jsonl") reddit_log = os.path.join(sim_dir, "reddit", "actions.jsonl")
@ -910,7 +933,7 @@ class SimulationRunner:
Returns: Returns:
完整的动作列表按时间戳排序新的在前 完整的动作列表按时间戳排序新的在前
""" """
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
actions = [] actions = []
# 读取 Twitter 动作文件(根据文件路径自动设置 platform 为 twitter # 读取 Twitter 动作文件(根据文件路径自动设置 platform 为 twitter
@ -1124,7 +1147,7 @@ class SimulationRunner:
""" """
import shutil import shutil
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
if not os.path.exists(sim_dir): if not os.path.exists(sim_dir):
return {"success": True, "message": "模拟目录不存在,无需清理"} return {"success": True, "message": "模拟目录不存在,无需清理"}
@ -1242,7 +1265,7 @@ class SimulationRunner:
# 同时更新 state.json将状态设为 stopped # 同时更新 state.json将状态设为 stopped
try: try:
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
state_file = os.path.join(sim_dir, "state.json") state_file = os.path.join(sim_dir, "state.json")
logger.info(f"尝试更新 state.json: {state_file}") logger.info(f"尝试更新 state.json: {state_file}")
if os.path.exists(state_file): if os.path.exists(state_file):
@ -1381,7 +1404,7 @@ class SimulationRunner:
Returns: Returns:
True 表示环境存活False 表示环境已关闭 True 表示环境存活False 表示环境已关闭
""" """
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
if not os.path.exists(sim_dir): if not os.path.exists(sim_dir):
return False return False
@ -1399,7 +1422,7 @@ class SimulationRunner:
Returns: Returns:
状态详情字典包含 status, twitter_available, reddit_available, timestamp 状态详情字典包含 status, twitter_available, reddit_available, timestamp
""" """
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
status_file = os.path.join(sim_dir, "env_status.json") status_file = os.path.join(sim_dir, "env_status.json")
default_status = { default_status = {
@ -1453,7 +1476,7 @@ class SimulationRunner:
ValueError: 模拟不存在或环境未运行 ValueError: 模拟不存在或环境未运行
TimeoutError: 等待响应超时 TimeoutError: 等待响应超时
""" """
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
if not os.path.exists(sim_dir): if not os.path.exists(sim_dir):
raise ValueError(f"模拟不存在: {simulation_id}") raise ValueError(f"模拟不存在: {simulation_id}")
@ -1515,7 +1538,7 @@ class SimulationRunner:
ValueError: 模拟不存在或环境未运行 ValueError: 模拟不存在或环境未运行
TimeoutError: 等待响应超时 TimeoutError: 等待响应超时
""" """
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
if not os.path.exists(sim_dir): if not os.path.exists(sim_dir):
raise ValueError(f"模拟不存在: {simulation_id}") raise ValueError(f"模拟不存在: {simulation_id}")
@ -1572,7 +1595,7 @@ class SimulationRunner:
Returns: Returns:
全局采访结果字典 全局采访结果字典
""" """
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
if not os.path.exists(sim_dir): if not os.path.exists(sim_dir):
raise ValueError(f"模拟不存在: {simulation_id}") raise ValueError(f"模拟不存在: {simulation_id}")
@ -1625,7 +1648,7 @@ class SimulationRunner:
Returns: Returns:
操作结果字典 操作结果字典
""" """
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
if not os.path.exists(sim_dir): if not os.path.exists(sim_dir):
raise ValueError(f"模拟不存在: {simulation_id}") raise ValueError(f"模拟不存在: {simulation_id}")
@ -1736,7 +1759,7 @@ class SimulationRunner:
Returns: Returns:
Interview历史记录列表 Interview历史记录列表
""" """
sim_dir = os.path.join(cls.RUN_STATE_DIR, simulation_id) sim_dir = cls._run_dir(simulation_id)
results = [] results = []

View File

@ -27,17 +27,23 @@ def _ensure_utf8_stdout():
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs') LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'logs')
def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.Logger: def setup_logger(name: str = 'mirofish', level: int = None) -> logging.Logger:
""" """
设置日志器 设置日志器
Args: Args:
name: 日志器名称 name: 日志器名称
level: 日志级别 level: 日志级别None 时按 FLASK_DEBUG 自动选择
Returns: Returns:
配置好的日志器 配置好的日志器
""" """
# H5生产模式FLASK_DEBUG=false下用 INFO —— 避免把请求体/调试细节(可能含上传内容、
# 提示词、客户端传入的凭据)以明文写入轮转日志文件。开发模式仍用 DEBUG。
if level is None:
from ..config import Config
level = logging.DEBUG if Config.DEBUG else logging.INFO
# 确保日志目录存在 # 确保日志目录存在
os.makedirs(LOG_DIR, exist_ok=True) os.makedirs(LOG_DIR, exist_ok=True)
@ -71,7 +77,7 @@ def setup_logger(name: str = 'mirofish', level: int = logging.DEBUG) -> logging.
backupCount=5, backupCount=5,
encoding='utf-8' encoding='utf-8'
) )
file_handler.setLevel(logging.DEBUG) file_handler.setLevel(level)
file_handler.setFormatter(detailed_formatter) file_handler.setFormatter(detailed_formatter)
# 2. 控制台处理器 - 简洁日志INFO及以上 # 2. 控制台处理器 - 简洁日志INFO及以上

View File

@ -0,0 +1,69 @@
"""
安全辅助函数评审 H5 / 上传嗅探 / 路径校验
- safe_traceback: 仅在 DEBUG 时把堆栈返回客户端生产环境只记到服务端日志
- validate_id: 校验 URL 传入的 id避免落入文件系统 sink路径穿越/异常字符
- upload_content_ok: 按魔术字节嗅探上传内容防止改扩展名的二进制混入
"""
import os
import re
import traceback as _traceback
from .logger import get_logger
# 允许的 id 字符集:字母数字 + 下划线 + 连字符,长度 1-64。
# 排除 '/'、'.'、'\\' 等可用于路径穿越或越级的字符。
_ID_RE = re.compile(r'^[A-Za-z0-9_-]{1,64}$')
def safe_traceback() -> str:
"""
H5完整堆栈始终写入服务端日志仅在 DEBUG 模式才把堆栈返回客户端
生产环境返回通用提示避免向客户端泄露内部路径/栈帧
"""
# 延迟导入 Config避免与配置模块的潜在循环依赖
from ..config import Config
tb = _traceback.format_exc()
get_logger('mirofish.error').error(tb)
return tb if Config.DEBUG else 'Internal server error (see server logs)'
def safe_error(e) -> str:
"""
客户端可见的错误文案DEBUG 模式返回异常消息生产环境返回通用提示
避免异常消息本身 FileNotFoundError 的路径ValueError 里的配置值泄露给客户端
完整异常仍由各调用点的 logger.error / safe_traceback 记到服务端日志
"""
from ..config import Config
return str(e) if Config.DEBUG else 'Internal server error (see server logs)'
def validate_id(value: str, kind: str = 'id') -> str:
"""
路径校验拒绝任何不匹配 _ID_RE id '..''/'空值
id 进入 os.path.join / makedirs / rmtree 之前阻断路径穿越
"""
if not isinstance(value, str) or not _ID_RE.match(value):
raise ValueError(f'Invalid {kind}: {value!r}')
return value
def upload_content_ok(file_storage, filename: str) -> bool:
"""
上传嗅探按扩展名校验文件头部内容使改名的二进制无法通过扩展名白名单
- pdf必须以 %PDF- 开头
- txt/md/markdown头部不得含 NUL 字节典型二进制特征
读取后将流指针复位避免影响后续保存
"""
ext = os.path.splitext(filename)[1].lower().lstrip('.')
head = file_storage.read(512)
file_storage.seek(0)
if ext == 'pdf':
return head[:5] == b'%PDF-'
if ext in ('txt', 'md', 'markdown'):
# 接受带 BOM 的 UTF-16/UTF-32/UTF-8 文本(这些合法文本会含 NUL 字节,
# 与 file_parser 的多编码支持一致);否则按头部含 NUL 判定为二进制并拒绝。
if head.startswith((b'\xff\xfe', b'\xfe\xff', b'\xef\xbb\xbf')):
return True
return b'\x00' not in head
return False

View File

@ -12,6 +12,8 @@ dependencies = [
# 核心框架 # 核心框架
"flask>=3.0.0", "flask>=3.0.0",
"flask-cors>=6.0.0", "flask-cors>=6.0.0",
# 生产 WSGI 服务器C1替代 Werkzeug 开发服务器/调试器;须以 -w 1 --threads N 单进程启动)
"gunicorn>=21.0.0",
# LLM 相关 # LLM 相关
"openai>=1.0.0", "openai>=1.0.0",

View File

@ -8,6 +8,9 @@
# ============= 核心框架 ============= # ============= 核心框架 =============
flask>=3.0.0 flask>=3.0.0
flask-cors>=6.0.0 flask-cors>=6.0.0
# 生产 WSGI 服务器C1替代 Flask 自带的 Werkzeug 开发服务器/调试器)
# 注意:因模拟运行态保存在进程内类变量,必须以单 worker 多线程启动(-w 1 --threads N
gunicorn>=21.0.0
# ============= LLM 相关 ============= # ============= LLM 相关 =============
# OpenAI SDK统一使用 OpenAI 格式调用 LLM # OpenAI SDK统一使用 OpenAI 格式调用 LLM

View File

@ -81,6 +81,11 @@ from typing import Dict, Any, List, Optional, Tuple
_shutdown_event = None _shutdown_event = None
_cleanup_done = False _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 目录到路径
# 脚本固定位于 backend/scripts/ 目录 # 脚本固定位于 backend/scripts/ 目录
_scripts_dir = os.path.dirname(os.path.abspath(__file__)) _scripts_dir = os.path.dirname(os.path.abspath(__file__))
@ -333,7 +338,9 @@ class ParallelIPCHandler:
action_args={"prompt": prompt} action_args={"prompt": prompt}
) )
actions = {agent: interview_action} 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 = self._get_interview_result(agent_id, actual_platform)
result["platform"] = actual_platform result["platform"] = actual_platform
@ -466,7 +473,7 @@ class ParallelIPCHandler:
print(f" 警告: 无法获取Twitter Agent {agent_id}: {e}") print(f" 警告: 无法获取Twitter Agent {agent_id}: {e}")
if twitter_actions: 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: for interview in twitter_interviews:
agent_id = interview.get("agent_id") agent_id = interview.get("agent_id")
@ -493,7 +500,7 @@ class ParallelIPCHandler:
print(f" 警告: 无法获取Reddit Agent {agent_id}: {e}") print(f" 警告: 无法获取Reddit Agent {agent_id}: {e}")
if reddit_actions: 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: for interview in reddit_interviews:
agent_id = interview.get("agent_id") agent_id = interview.get("agent_id")
@ -1203,8 +1210,12 @@ async def run_twitter_simulation(
pass pass
if initial_actions: if initial_actions:
await result.env.step(initial_actions) # C4初始帖子的 env.step 也加超时 —— 否则在进入受保护的主循环前就可能因 LLM/网络挂起而 wedge
log_info(f"已发布 {len(initial_actions)} 条初始帖子") 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 结束 # 记录 round 0 结束
if action_logger: if action_logger:
@ -1250,8 +1261,20 @@ async def run_twitter_simulation(
action_logger.log_round_end(round_num + 1, 0) action_logger.log_round_end(round_num + 1, 0)
continue 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} 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( actual_actions, last_rowid = fetch_new_actions_from_db(
@ -1402,8 +1425,12 @@ async def run_reddit_simulation(
pass pass
if initial_actions: if initial_actions:
await result.env.step(initial_actions) # C4初始帖子的 env.step 也加超时 —— 否则在进入受保护的主循环前就可能因 LLM/网络挂起而 wedge
log_info(f"已发布 {len(initial_actions)} 条初始帖子") 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 结束 # 记录 round 0 结束
if action_logger: if action_logger:
@ -1449,8 +1476,20 @@ async def run_reddit_simulation(
action_logger.log_round_end(round_num + 1, 0) action_logger.log_round_end(round_num + 1, 0)
continue 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} 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( actual_actions, last_rowid = fetch_new_actions_from_db(
@ -1577,16 +1616,34 @@ async def main():
reddit_result: Optional[PlatformSimulation] = None reddit_result: Optional[PlatformSimulation] = None
if args.twitter_only: 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: 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: else:
# 并行运行(每个平台使用独立的日志记录器) # 并行运行(每个平台使用独立的日志记录器)
# C4return_exceptions=True —— 一个平台抛异常(如 LLM 401/429/配额)不再取消另一平台,
# 也不会绕过下方的 env.close否则环境泄漏违背“优雅停止、保留环境”的设计
results = await asyncio.gather( results = await asyncio.gather(
run_twitter_simulation(config, simulation_dir, twitter_logger, log_manager, args.max_rounds), 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), run_reddit_simulation(config, simulation_dir, reddit_logger, log_manager, args.max_rounds),
return_exceptions=True,
) )
twitter_result, reddit_result = results 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() total_elapsed = (datetime.now() - start_time).total_seconds()
log_manager.info("=" * 60) log_manager.info("=" * 60)

View File

@ -29,6 +29,10 @@ from typing import Dict, Any, List, Optional
_shutdown_event = None _shutdown_event = None
_cleanup_done = False _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__)) _scripts_dir = os.path.dirname(os.path.abspath(__file__))
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..')) _backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
@ -230,7 +234,7 @@ class IPCHandler:
# 执行Interview # 执行Interview
actions = {agent: interview_action} 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) result = self._get_interview_result(agent_id)
@ -276,7 +280,7 @@ class IPCHandler:
return False return False
# 执行批量Interview # 执行批量Interview
await self.env.step(actions) await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) # C4采访超时TimeoutError 由本块 except 捕获)
# 获取所有结果 # 获取所有结果
results = {} results = {}
@ -616,8 +620,12 @@ class RedditSimulationRunner:
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}") print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
if initial_actions: if initial_actions:
await self.env.step(initial_actions) # C4初始帖子的 env.step 也加超时,避免进入主循环前就因 LLM/网络挂起而 wedge
print(f" 已发布 {len(initial_actions)} 条初始帖子") 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开始模拟循环...") print("\n开始模拟循环...")
@ -635,12 +643,22 @@ class RedditSimulationRunner:
if not active_agents: if not active_agents:
continue 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 = { actions = {
agent: LLMAction() agent: LLMAction()
for _, agent in active_agents 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: if (round_num + 1) % 10 == 0 or round_num == 0:
elapsed = (datetime.now() - start_time).total_seconds() elapsed = (datetime.now() - start_time).total_seconds()

View File

@ -29,6 +29,10 @@ from typing import Dict, Any, List, Optional
_shutdown_event = None _shutdown_event = None
_cleanup_done = False _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__)) _scripts_dir = os.path.dirname(os.path.abspath(__file__))
_backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..')) _backend_dir = os.path.abspath(os.path.join(_scripts_dir, '..'))
@ -230,7 +234,7 @@ class IPCHandler:
# 执行Interview # 执行Interview
actions = {agent: interview_action} 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) result = self._get_interview_result(agent_id)
@ -276,7 +280,7 @@ class IPCHandler:
return False return False
# 执行批量Interview # 执行批量Interview
await self.env.step(actions) await asyncio.wait_for(self.env.step(actions), timeout=_ROUND_TIMEOUT_SEC) # C4采访超时TimeoutError 由本块 except 捕获)
# 获取所有结果 # 获取所有结果
results = {} results = {}
@ -623,8 +627,12 @@ class TwitterSimulationRunner:
print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}") print(f" 警告: 无法为Agent {agent_id}创建初始帖子: {e}")
if initial_actions: if initial_actions:
await self.env.step(initial_actions) # C4初始帖子的 env.step 也加超时,避免进入主循环前就因 LLM/网络挂起而 wedge
print(f" 已发布 {len(initial_actions)} 条初始帖子") 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开始模拟循环...") print("\n开始模拟循环...")
@ -644,6 +652,11 @@ class TwitterSimulationRunner:
if not active_agents: if not active_agents:
continue 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 = { actions = {
agent: LLMAction() agent: LLMAction()
@ -651,7 +664,12 @@ class TwitterSimulationRunner:
} }
# 执行动作 # 执行动作
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: 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" }, { 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]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" version = "0.16.0"
@ -994,6 +1006,7 @@ dependencies = [
{ name = "charset-normalizer" }, { name = "charset-normalizer" },
{ name = "flask" }, { name = "flask" },
{ name = "flask-cors" }, { name = "flask-cors" },
{ name = "gunicorn" },
{ name = "openai" }, { name = "openai" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pymupdf" }, { name = "pymupdf" },
@ -1022,6 +1035,7 @@ requires-dist = [
{ name = "charset-normalizer", specifier = ">=3.0.0" }, { name = "charset-normalizer", specifier = ">=3.0.0" },
{ name = "flask", specifier = ">=3.0.0" }, { name = "flask", specifier = ">=3.0.0" },
{ name = "flask-cors", specifier = ">=6.0.0" }, { name = "flask-cors", specifier = ">=6.0.0" },
{ name = "gunicorn", specifier = ">=21.0.0" },
{ name = "openai", specifier = ">=1.0.0" }, { name = "openai", specifier = ">=1.0.0" },
{ name = "pipreqs", marker = "extra == 'dev'", specifier = ">=0.5.0" }, { name = "pipreqs", marker = "extra == 'dev'", specifier = ">=0.5.0" },
{ name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", specifier = ">=2.0.0" },

View File

@ -3,6 +3,13 @@ services:
image: ghcr.io/666ghj/mirofish:latest image: ghcr.io/666ghj/mirofish:latest
# 加速镜像(如拉取缓慢可替换上方地址) # 加速镜像(如拉取缓慢可替换上方地址)
# image: ghcr.nju.edu.cn/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 container_name: mirofish
env_file: env_file:
- .env - .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

@ -6,6 +6,11 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@100..800&family=Noto+Sans+SC:wght@300;400;500;700;800;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@100..800&family=Noto+Sans+SC:wght@300;400;500;700;800;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- CSP纵深防御配合 DOMPurify 抵御 XSS。注意
- script-src 含 'unsafe-inline' 仅因本文件顶部有一段内联 lang 脚本;移除该脚本可去掉它以收紧策略。
- connect-src 默认含本机后端;若后端在其它域,部署时需把该域加入 connect-src。
- frame-ancestors 在 <meta> 中无效,已由服务端/预览响应头X-Frame-Options / CSP 头)补充。 -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob:; connect-src 'self' http://localhost:5001; object-src 'none'; base-uri 'self'; form-action 'self'" />
<link rel="icon" type="image/png" href="/icon.png" /> <link rel="icon" type="image/png" href="/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="MiroFish - 社交媒体舆论模拟系统" /> <meta name="description" content="MiroFish - 社交媒体舆论模拟系统" />

View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"axios": "^1.14.0", "axios": "^1.14.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"dompurify": "^3.4.10",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-i18n": "^11.3.0", "vue-i18n": "^11.3.0",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"
@ -938,6 +939,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz",
@ -1435,7 +1443,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC", "license": "ISC",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
@ -1538,6 +1545,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/dompurify": {
"version": "3.4.10",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
"integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@ -1913,7 +1929,6 @@
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@ -2053,7 +2068,6 @@
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==", "integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@ -2128,7 +2142,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==", "integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.25", "@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25", "@vue/compiler-sfc": "3.5.25",

View File

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"axios": "^1.14.0", "axios": "^1.14.0",
"d3": "^7.9.0", "d3": "^7.9.0",
"dompurify": "^3.4.10",
"vue": "^3.5.24", "vue": "^3.5.24",
"vue-i18n": "^11.3.0", "vue-i18n": "^11.3.0",
"vue-router": "^4.6.3" "vue-router": "^4.6.3"

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( service.interceptors.request.use(
config => { config => {
config.headers['Accept-Language'] = i18n.global.locale.value config.headers['Accept-Language'] = i18n.global.locale.value
if (API_KEY) {
config.headers['X-API-Key'] = API_KEY
}
return config return config
}, },
error => { error => {

View File

@ -391,6 +391,7 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick, h, reactive } from 'vue'
import DOMPurify from 'dompurify'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { getAgentLog, getConsoleLog } from '../api/report' import { getAgentLog, getConsoleLog } from '../api/report'
@ -1531,11 +1532,14 @@ const InterviewDisplay = {
]), ]),
h('div', { h('div', {
class: ['qa-text', 'answer-text', { 'placeholder-text': isPlaceholder }], class: ['qa-text', 'answer-text', { 'placeholder-text': isPlaceholder }],
innerHTML: isPlaceholder // H1answerText LLM innerHTML XSS
? answerText innerHTML: DOMPurify.sanitize(
: formatAnswer(answerText, isExpanded) isPlaceholder
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') ? answerText
.replace(/\n/g, '<br>') : formatAnswer(answerText, isExpanded)
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\n/g, '<br>')
)
}), }),
// Expand/Collapse Button // Expand/Collapse Button
!isPlaceholder && answerText.length > 400 && h('button', { !isPlaceholder && answerText.length > 400 && h('button', {
@ -1973,7 +1977,8 @@ const renderMarkdown = (content) => {
} }
html = tokens.join('') html = tokens.join('')
return html // H1 HTML LLM / XSS
return DOMPurify.sanitize(html)
} }
const getTimelineItemClass = (log, idx, total) => { const getTimelineItemClass = (log, idx, total) => {

View File

@ -412,6 +412,7 @@
<script setup> <script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue' import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
import DOMPurify from 'dompurify'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { chatWithReport, getReport, getAgentLog } from '../api/report' import { chatWithReport, getReport, getAgentLog } from '../api/report'
import { interviewAgents, getSimulationProfilesRealtime } from '../api/simulation' import { interviewAgents, getSimulationProfilesRealtime } from '../api/simulation'
@ -638,7 +639,8 @@ const renderMarkdown = (content) => {
} }
html = tokens.join('') html = tokens.join('')
return html // H1 HTML LLM/访/ XSS
return DOMPurify.sanitize(html)
} }
// Chat Methods // Chat Methods

View File

@ -21,5 +21,27 @@ export default defineConfig({
secure: false 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,
// 安全响应头(生产由 vite preview 提供前端时生效。frame-ancestors 仅在响应头中有效,
// 故在此补充;其余 CSP 指令由 index.html 的 <meta> 提供(两者一致)。
headers: {
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Content-Security-Policy': "frame-ancestors 'none'"
},
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\"", "dev": "concurrently --kill-others -n \"backend,frontend\" -c \"green,cyan\" \"npm run backend\" \"npm run frontend\"",
"backend": "cd backend && uv run python run.py", "backend": "cd backend && uv run python run.py",
"frontend": "cd frontend && npm run dev", "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": { "devDependencies": {
"concurrently": "^9.1.2" "concurrently": "^9.1.2"