需求:限制每个用户在 swarm 中并发的 Agent 数为 10。按「并发 WS 连接数/用户」口径实现,env 可调(默认 10)。 orchestrator/main.py:ConnectionManager 新增 per-user 记账(agent_user / user_agents + user_agent_count / can_bind_user / bind_user / unbind);max_agents_per_user() 读 MAX_AGENTS_PER_USER(默认 10)。WS register 携带 user_id 且该用户已达上限时回 registration_rejected(reason,limit) 并 close(1008),不注册;同 agent_id 重连放行;断开 unbind 释放名额。未带 user_id 的 Agent 为 unbound、不受限。 agent/main.py:新增 user_id 构造参数 + HEICODE_USER_ID 环境回退,并在 register 载荷中带上(仅在设置时)。 测试 scripts/test-max-agents-per-user.py:单元 + WS 集成(MAX_AGENTS_PER_USER=3)。接入 CI。docs/integration/security-boundary.md §6 记录该配额。 影响范围:仅 agent_swarm;不改 Manager↔Swarm 契约、计费、审批链、密钥处理。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
159 lines
5.7 KiB
Python
159 lines
5.7 KiB
Python
"""Per-user agent cap tests: at most MAX_AGENTS_PER_USER concurrent agents per user.
|
|
|
|
Covers:
|
|
* unit — ConnectionManager per-user accounting (can_bind_user / bind_user / unbind / count) and
|
|
the env-tunable limit (default 10);
|
|
* integration — the REAL orchestrator over WebSocket: registrations beyond the cap are rejected
|
|
(registration_rejected + socket close), a different user is unaffected, and freeing a slot
|
|
(disconnect) lets a new agent in. Unbound agents (no user_id) are never capped.
|
|
|
|
Uses MAX_AGENTS_PER_USER=3 so the integration test stays small/fast.
|
|
|
|
Run from agent_swarm_v6 (install deps first):
|
|
pip install -r orchestrator/requirements.txt -r agent/requirements.txt
|
|
REDIS_FAKE=1 python scripts/test-max-agents-per-user.py
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
os.environ["REDIS_FAKE"] = "1"
|
|
os.environ["MAX_AGENTS_PER_USER"] = "3"
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
import httpx
|
|
import uvicorn
|
|
import websockets
|
|
from orchestrator import main as orch
|
|
|
|
PORT = 8137
|
|
BASE = f"http://127.0.0.1:{PORT}"
|
|
WS = f"ws://127.0.0.1:{PORT}"
|
|
|
|
logging.getLogger("orchestrator.swarm_runtime").setLevel(logging.ERROR)
|
|
|
|
failures = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(("PASS" if cond else "FAIL"), "-", name)
|
|
if not cond:
|
|
failures.append(name)
|
|
|
|
|
|
def test_unit():
|
|
cm = orch.ConnectionManager()
|
|
limit = 3
|
|
for i in range(limit):
|
|
check(f"unit: agent a{i} allowed under cap", cm.can_bind_user(f"a{i}", "U", limit))
|
|
cm.bind_user(f"a{i}", "U")
|
|
check("unit: user count == 3", cm.user_agent_count("U") == 3)
|
|
check("unit: 4th distinct agent rejected at cap", not cm.can_bind_user("a3", "U", limit))
|
|
check("unit: reconnect by an already-counted agent allowed at cap", cm.can_bind_user("a0", "U", limit))
|
|
check("unit: a different user is independent", cm.can_bind_user("b0", "V", limit))
|
|
cm.unbind("a0")
|
|
check("unit: count drops after unbind", cm.user_agent_count("U") == 2)
|
|
check("unit: freed slot lets a new agent in", cm.can_bind_user("a3", "U", limit))
|
|
cm.unbind("nonexistent") # no-op, must not raise
|
|
check("unit: env limit default respected (=3 here)", orch.max_agents_per_user() == 3)
|
|
|
|
|
|
async def _register(agent_id, user_id=None):
|
|
"""Open a WS, send register (optionally with user_id), return (ws, response_dict)."""
|
|
ws = await websockets.connect(f"{WS}/ws/{agent_id}")
|
|
msg = {"type": "register", "agent_id": agent_id, "capabilities": ["general"]}
|
|
if user_id is not None:
|
|
msg["user_id"] = user_id
|
|
await ws.send(json.dumps(msg))
|
|
resp = json.loads(await ws.recv())
|
|
return ws, resp
|
|
|
|
|
|
async def wait_health(client):
|
|
for _ in range(150):
|
|
try:
|
|
if (await client.get(f"{BASE}/health")).status_code == 200:
|
|
return True
|
|
except Exception:
|
|
pass
|
|
await asyncio.sleep(0.1)
|
|
return False
|
|
|
|
|
|
async def test_integration():
|
|
config = uvicorn.Config(orch.app, host="127.0.0.1", port=PORT, log_level="warning")
|
|
server = uvicorn.Server(config)
|
|
server.install_signal_handlers = lambda: None
|
|
thread = threading.Thread(target=server.run, daemon=True)
|
|
thread.start()
|
|
open_ws = []
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
if not await wait_health(client):
|
|
check("orchestrator started", False)
|
|
return
|
|
|
|
# 3 agents for one user all register OK
|
|
for i in range(3):
|
|
ws, resp = await _register(f"cap-{i}", user_id="user-cap")
|
|
check(f"integration: agent cap-{i} registered", resp.get("type") == "registered")
|
|
open_ws.append(ws)
|
|
|
|
# 4th for the same user is rejected
|
|
ws4, resp4 = await _register("cap-3", user_id="user-cap")
|
|
check("integration: 4th agent rejected (over cap)", resp4.get("type") == "registration_rejected")
|
|
check("integration: rejection states reason + limit",
|
|
resp4.get("reason") == "max_agents_per_user_exceeded" and resp4.get("limit") == 3)
|
|
await ws4.close()
|
|
|
|
# a different user is unaffected
|
|
wsB, respB = await _register("other-0", user_id="user-other")
|
|
check("integration: different user unaffected", respB.get("type") == "registered")
|
|
open_ws.append(wsB)
|
|
|
|
# freeing a slot (disconnect) lets a new agent for the capped user in
|
|
await open_ws[0].close()
|
|
open_ws.pop(0)
|
|
await asyncio.sleep(0.4) # let the server's finally-block run unbind
|
|
ws5, resp5 = await _register("cap-4", user_id="user-cap")
|
|
check("integration: freed slot admits a new agent", resp5.get("type") == "registered")
|
|
open_ws.append(ws5)
|
|
|
|
# unbound agents (no user_id) are never capped — many can register
|
|
for i in range(5):
|
|
ws, resp = await _register(f"unbound-{i}") # no user_id
|
|
check(f"integration: unbound agent unbound-{i} not capped", resp.get("type") == "registered")
|
|
open_ws.append(ws)
|
|
finally:
|
|
for ws in open_ws:
|
|
try:
|
|
await ws.close()
|
|
except Exception:
|
|
pass
|
|
server.should_exit = True
|
|
thread.join(timeout=5)
|
|
|
|
|
|
async def main():
|
|
test_unit()
|
|
await test_integration()
|
|
print()
|
|
if failures:
|
|
print(f"{len(failures)} per-user-cap check(s) FAILED: {failures}")
|
|
return 1
|
|
print("all per-user agent-cap checks passed")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
_code = asyncio.run(main())
|
|
sys.stdout.flush()
|
|
sys.stderr.flush()
|
|
os._exit(_code or 0)
|