fix: code review — critical and important fixes [v0.2.1]

- inject_message now uses session's event_bus (BOSS_TURN reaches WebSocket/SSE)
- asyncio.get_running_loop() replaces deprecated get_event_loop()
- API keys masked in /api/providers response
- ServerSession (renamed from Session) with cleanup() for handler teardown
- Path traversal protection in _resolve_scenario_path
- XSS fix: escapeHtml(d.name) in web UI
- Duplicate imports removed, os/json moved to module level
- WebSocket handler restructured for reliable cleanup
- New tests: cleanup, provider masking, path traversal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 09:15:24 +03:00
parent ff5ee2879a
commit f0500502a7
3 changed files with 145 additions and 76 deletions

View File

@@ -3,7 +3,9 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from contextlib import asynccontextmanager
from typing import Any
@@ -16,7 +18,7 @@ from pydantic import BaseModel
from meeting_room.api_client import APIClient
from meeting_room.config import load_config, load_discussion_config, find_workspace_config
from meeting_room.engine import DiscussionEngine
from meeting_room.events import AsyncEventBridge, EventEmitter
from meeting_room.events import AGENT_ERROR, AGENT_MESSAGE, BOSS_TURN, AsyncEventBridge, EventEmitter
from meeting_room.models import DiscussionConfig, ProviderConfig
from meeting_room.scenario_loader import load_scenario
from meeting_room.tools import ToolRegistry
@@ -60,11 +62,11 @@ class SessionDetailResponse(BaseModel):
# ---------------------------------------------------------------------------
# Session store
# Session state (renamed from Session to avoid collision with models.Session)
# ---------------------------------------------------------------------------
class Session:
class ServerSession:
"""Holds state for a single discussion session."""
def __init__(
@@ -82,8 +84,10 @@ class Session:
self.event_bus = EventEmitter()
self.bridge = AsyncEventBridge(self.event_bus)
self._engine_task: asyncio.Task | None = None
self._collect_handler = None # stored for cleanup
def build_engine(self) -> DiscussionEngine:
"""Build a DiscussionEngine using this session's event_bus."""
providers = {
name: ProviderConfig(**vals)
for name, vals in self.config.get("providers", {}).items()
@@ -97,23 +101,31 @@ class Session:
events=self.event_bus,
)
def cleanup(self) -> None:
"""Stop bridge and remove event handlers."""
self.bridge.stop()
if self._collect_handler is not None:
for event_type in (AGENT_MESSAGE, BOSS_TURN, AGENT_ERROR):
self.event_bus.off(event_type, self._collect_handler)
self._collect_handler = None
class SessionStore:
"""In-memory session store."""
def __init__(self) -> None:
self._sessions: dict[str, Session] = {}
self._sessions: dict[str, ServerSession] = {}
def create(self, config: dict, discussion_config: DiscussionConfig) -> Session:
def create(self, config: dict, discussion_config: DiscussionConfig) -> ServerSession:
session_id = uuid.uuid4().hex[:12]
session = Session(session_id, config, discussion_config)
session = ServerSession(session_id, config, discussion_config)
self._sessions[session_id] = session
return session
def get(self, session_id: str) -> Session | None:
def get(self, session_id: str) -> ServerSession | None:
return self._sessions.get(session_id)
def list_sessions(self) -> list[Session]:
def list_sessions(self) -> list[ServerSession]:
return list(self._sessions.values())
@@ -129,22 +141,22 @@ async def lifespan(app: FastAPI):
"""Startup/shutdown lifecycle."""
logger.info("Meeting Room server started")
yield
# Cleanup: cancel any running engine tasks
# Cleanup: cancel running engine tasks and stop bridges
for session in store.list_sessions():
if session._engine_task and not session._engine_task.done():
session._engine_task.cancel()
session.cleanup()
logger.info("Meeting Room server stopped")
app = FastAPI(
title="Meeting Room",
version="0.2.0",
version="0.2.1",
description="Multi-agent discussion framework — REST API and WebSocket",
lifespan=lifespan,
)
# Serve static files (web UI)
import os
_static_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
app.mount("/static", StaticFiles(directory=_static_dir), name="static")
@@ -152,7 +164,6 @@ app.mount("/static", StaticFiles(directory=_static_dir), name="static")
@app.get("/")
async def index():
"""Redirect to the web UI."""
from fastapi.responses import RedirectResponse
return RedirectResponse(url="/static/index.html")
@@ -171,6 +182,44 @@ def _load_config_or_default(config_path: str | None) -> dict:
return load_config()
def _resolve_scenario_path(scenario: str) -> str:
"""Resolve scenario path, preventing path traversal.
Only allows scenarios from CWD or the package scenarios directory.
"""
# Reject path traversal
if ".." in scenario:
raise HTTPException(status_code=400, detail="Invalid scenario path: path traversal not allowed")
if os.path.isabs(scenario_path := scenario):
if not os.path.exists(scenario_path):
raise HTTPException(status_code=404, detail=f"Scenario not found: {scenario}")
return scenario_path
# Try relative to CWD first
cwd_path = os.path.join(os.getcwd(), scenario)
if os.path.exists(cwd_path):
return cwd_path
# Try package scenarios directory
pkg_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "scenarios", scenario)
if os.path.exists(pkg_path):
return pkg_path
raise HTTPException(status_code=404, detail=f"Scenario not found: {scenario}")
def _mask_api_keys(providers: dict) -> dict:
"""Mask API keys in provider config for safe display."""
masked = {}
for name, cfg in providers.items():
masked[name] = {
"base_url": cfg.get("base_url", ""),
"api_key": cfg.get("api_key", "")[:4] + "****" if cfg.get("api_key") else "",
}
return masked
# ---------------------------------------------------------------------------
# REST API (mounted at /api)
# ---------------------------------------------------------------------------
@@ -225,18 +274,8 @@ async def start_discussion(session_id: str, body: StartDiscussionRequest):
if session.status == "running":
raise HTTPException(status_code=409, detail="Discussion already running")
# Load scenario
import os
scenario_path = body.scenario
if not os.path.isabs(scenario_path):
scenario_path = os.path.join(os.getcwd(), scenario_path)
if not os.path.exists(scenario_path):
scenario_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "scenarios", body.scenario
)
if not os.path.exists(scenario_path):
raise HTTPException(status_code=404, detail=f"Scenario not found: {body.scenario}")
# Resolve and validate scenario path
scenario_path = _resolve_scenario_path(body.scenario)
scenario_data = load_scenario(scenario_path)
session.scenario_name = scenario_data.get("name", "Untitled")
@@ -247,13 +286,11 @@ async def start_discussion(session_id: str, body: StartDiscussionRequest):
session.config = raw_config
session.discussion_config = discussion_config
# Start the bridge
loop = asyncio.get_event_loop()
# Start the bridge on the running event loop
loop = asyncio.get_running_loop()
session.bridge.start(loop)
# Subscribe a handler to collect messages
from meeting_room.events import AGENT_MESSAGE, BOSS_TURN, AGENT_ERROR
# Subscribe a handler to collect messages (stored for cleanup)
def collect_message(event):
session.messages.append({
"role_id": event.data.get("role_id", ""),
@@ -261,6 +298,7 @@ async def start_discussion(session_id: str, body: StartDiscussionRequest):
"content": event.data.get("content", event.data.get("error", "")),
})
session._collect_handler = collect_message
session.event_bus.on(AGENT_MESSAGE, collect_message)
session.event_bus.on(BOSS_TURN, collect_message)
session.event_bus.on(AGENT_ERROR, collect_message)
@@ -277,7 +315,7 @@ async def start_discussion(session_id: str, body: StartDiscussionRequest):
logger.exception("Engine error in session %s", session.id)
session.status = "error"
finally:
session.bridge.stop()
session.cleanup()
session._engine_task = asyncio.create_task(run_engine())
@@ -291,20 +329,20 @@ async def start_discussion(session_id: str, body: StartDiscussionRequest):
@api.post("/sessions/{session_id}/inject", response_model=dict)
async def inject_message(session_id: str, body: InjectMessageRequest):
"""Inject a message from a Boss/CSO role."""
"""Inject a message from a Boss/CSO role.
Uses the session's event_bus so WebSocket/SSE clients receive the event.
"""
session = store.get(session_id)
if not session:
raise HTTPException(status_code=404, detail="Session not found")
if session.status != "running":
raise HTTPException(status_code=409, detail="Session is not running")
# Use the session's event_bus — inject_message emits BOSS_TURN on it,
# which the bridge forwards to all subscribers (WebSocket/SSE).
engine = session.build_engine()
msg = engine.inject_message(body.role_id, body.content)
session.messages.append({
"role_id": msg.role_id,
"name": msg.name,
"content": msg.content,
})
return {"role_id": msg.role_id, "name": msg.name, "content": msg.content}
@@ -327,9 +365,9 @@ async def list_roles(config: str | None = None):
@api.get("/providers")
async def list_providers(config: str | None = None):
"""List configured providers."""
"""List configured providers (API keys masked)."""
raw_config = _load_config_or_default(config)
return raw_config.get("providers", {})
return _mask_api_keys(raw_config.get("providers", {}))
# Mount API router
@@ -351,40 +389,32 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str):
await websocket.accept()
queue = session.bridge.subscribe()
send_task = asyncio.create_task(_ws_send_loop(websocket, queue))
try:
# Task: forward events from queue to WebSocket
async def send_events():
try:
while True:
event = await queue.get()
await websocket.send_json(event)
except asyncio.CancelledError:
pass
send_task = asyncio.create_task(send_events())
# Listen for incoming messages (inject)
try:
while True:
data = await websocket.receive_json()
if data.get("type") == "inject":
role_id = data.get("role_id", "boss")
content = data.get("content", "")
# Use session's event_bus so events reach all subscribers
engine = session.build_engine()
msg = engine.inject_message(role_id, content)
session.messages.append({
"role_id": msg.role_id,
"name": msg.name,
"content": msg.content,
})
engine.inject_message(role_id, content)
except WebSocketDisconnect:
pass
finally:
send_task.cancel()
session.bridge.unsubscribe(queue)
except Exception:
session.bridge.unsubscribe(queue)
async def _ws_send_loop(websocket: WebSocket, queue: asyncio.Queue[dict]) -> None:
"""Forward events from bridge queue to WebSocket."""
try:
while True:
event = await queue.get()
await websocket.send_json(event)
except asyncio.CancelledError:
pass
# ---------------------------------------------------------------------------
@@ -404,8 +434,6 @@ async def sse_endpoint(session_id: str):
try:
while True:
event = await queue.get()
# SSE format
import json
yield f"data: {json.dumps(event)}\n\n"
except asyncio.CancelledError:
pass

View File

@@ -255,12 +255,12 @@
const el = document.createElement('div');
el.className = 'msg';
const color = ROLE_COLORS[d.role_id] || '#999';
el.innerHTML = `<div class="name" style="color:${color}">${d.name}</div><div class="content">${escapeHtml(d.content || '')}</div>`;
el.innerHTML = `<div class="name" style="color:${color}">${escapeHtml(d.name)}</div><div class="content">${escapeHtml(d.content || '')}</div>`;
container.appendChild(el);
} else if (type === 'agent_error') {
const el = document.createElement('div');
el.className = 'msg error';
el.innerHTML = `<div class="name">${d.name}</div><div class="content">Error: ${escapeHtml(d.error || '')}</div>`;
el.innerHTML = `<div class="name">${escapeHtml(d.name)}</div><div class="content">Error: ${escapeHtml(d.error || '')}</div>`;
container.appendChild(el);
} else if (type === 'tool_call') {
const el = document.createElement('div');

View File

@@ -5,10 +5,11 @@ from __future__ import annotations
import asyncio
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from meeting_room.events import AsyncEventBridge, EventEmitter
from meeting_room.server import Session, SessionStore, app, store
from meeting_room.server import ServerSession, SessionStore, app, store
@pytest.fixture(autouse=True)
@@ -113,13 +114,53 @@ class TestRolesAndProviders:
# ---------------------------------------------------------------------------
class TestSession:
class TestServerSession:
def test_build_engine(self) -> None:
config = _minimal_config()
session = Session("test-id", config, _minimal_discussion_config())
session = ServerSession("test-id", config, _minimal_discussion_config())
engine = session.build_engine()
assert engine is not None
def test_cleanup_removes_collect_handler(self) -> None:
from meeting_room.events import AGENT_MESSAGE, BOSS_TURN, AGENT_ERROR
config = _minimal_config()
session = ServerSession("test-id", config, _minimal_discussion_config())
# Simulate what start_discussion does
def collect_message(event):
pass
session._collect_handler = collect_message
session.event_bus.on(AGENT_MESSAGE, collect_message)
session.event_bus.on(BOSS_TURN, collect_message)
session.event_bus.on(AGENT_ERROR, collect_message)
assert len(session.event_bus.handlers(AGENT_MESSAGE)) == 1
session.cleanup()
assert len(session.event_bus.handlers(AGENT_MESSAGE)) == 0
class TestProviderMasking:
def test_api_keys_masked(self) -> None:
from meeting_room.server import _mask_api_keys
providers = {
"routerai": {"base_url": "https://api.example.com", "api_key": "sk-1234567890abcdef"},
}
masked = _mask_api_keys(providers)
assert masked["routerai"]["api_key"] == "sk-1****"
assert masked["routerai"]["base_url"] == "https://api.example.com"
def test_empty_key(self) -> None:
from meeting_room.server import _mask_api_keys
providers = {"test": {"base_url": "https://x.com", "api_key": ""}}
masked = _mask_api_keys(providers)
assert masked["test"]["api_key"] == ""
class TestPathTraversal:
def test_reject_parent_dir(self) -> None:
from meeting_room.server import _resolve_scenario_path
with pytest.raises(HTTPException) as exc_info:
_resolve_scenario_path("../../etc/passwd")
assert exc_info.value.status_code == 400
# ---------------------------------------------------------------------------
# Helper