Files
group_fqcd_jr/tools/acceptance_check.py
T
lzf_0626 c32d3dbd06 chore: 开发专用 JWT 密钥、密钥生成脚本与轮换文档
背景:此前全环境共用一把 JWT 密钥(config/jwt/jwt-private.pem)。它相当于
"能冒充 9001/9002/9003 的万能钥匙"(实测边界:签名有效 + 用户存在且启用才通过,
伪造新用户与使用禁用账号都会被拒)。为避免同一把密钥将来又变成生产密钥,
本次引入开发专用密钥,并把签发侧收敛到配置。

改动:
1. 新增 tools/generate_jwt_keys.py:可复现地生成 RS256 密钥对(PKCS#8 / SPKI),
   打印公钥 SHA-256 指纹便于核对服务端加载的是否同一把;密钥已存在时默认拒绝
   覆盖,避免误操作导致所有已签发令牌立即失效。
2. 生成开发专用密钥到 config/jwt/dev/(该目录整体已被 .gitignore 忽略,不入库)。
3. 三个工具脚本不再硬编码私钥路径,改为读配置:acceptance_check 与 demo_agent_e2e
   走 get_settings().jwt_private_key_path,smoke_check 因刻意不依赖 app 包而读
   JWT_PRIVATE_KEY_PATH 环境变量。今后轮换密钥只需改 .env 一处。
4. .env、.env.example 与 Settings 默认值统一指向 config/jwt/dev/。
5. 新增 docs/21-JWT密钥管理与轮换.md:密钥分工(服务端只读公钥,
   JWT_PRIVATE_KEY_PATH 在 app/ 中无任何读取点,故生产机可只挂公钥)、
   克隆后必须自行生成、多人共用一个服务时必须共用同一把私钥、
   轮换的影响面与生产部署要点、安全红线。
6. 记录一处易被忽略的问题:生产环境的 JWT_ISSUER / JWT_AUDIENCE 也应与开发不同,
   否则开发环境签发的令牌在生产上依然有效——这比换密钥更容易漏。

说明:本次提交不含任何密钥文件(.env 与 config/jwt/ 均在 .gitignore 中)。
旧密钥 config/jwt/jwt-private.pem 已退役但保留未删,配置不再引用它,
用它签发的令牌会被拒绝。

验证:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、integration 29 passed、
acceptance_check --production 7 PASS、demo_agent_e2e 9/9 PASS——均使用新密钥完成
签发与验签。
2026-09-10 18:14:03 +08:00

283 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""独立验收脚本:真实 HTTP + 真实 JWT + 真实 MySQL + 显式注册的测试 Agent。
覆盖 P0-1 越权拦截、P0-2 未注册拒绝、P0-3 端到端闭环、P1-3 SSE delta。
生产代码不注册业务 Agent,本脚本按 TODO 阶段 8 的约定显式注入测试注册表。
"""
from __future__ import annotations
import asyncio
import datetime as dt
import sys
import uuid
from pathlib import Path
from typing import Any
import httpx
import jwt
from sqlalchemy import delete, select
import app.service.admin_service as admin_service
import app.service.agent_run_application_service as run_service
import app.service.public_platform_service as public_platform_service
from app.core.config import get_settings
from app.core.contracts import (
AgentDefinition,
AgentRequest,
CoreResult,
RequestContext,
ResolvedAgentConfig,
)
from app.infrastructure.db import SessionFactory
from app.main import create_app
from app.model.audit import InteractionAudit
from app.model.conversation import ConversationMessage
from app.model.platform import AgentRun, DomainEventOutbox, OutboxDelivery, RequestIdempotency
from app.service.agent.base import BaseAgent
from app.service.agent.bootstrap import get_agent_factory
from app.service.agent.factory import AgentFactory
from app.worker.runtime import WorkerRuntime
# 密钥路径统一从配置读(.env 的 JWT_PRIVATE_KEY_PATH),换密钥只改配置,不用改脚本。
PRIVATE_KEY = Path(get_settings().jwt_private_key_path).read_text(encoding="utf-8")
CUSTOMER_ID = "9001" # tools/seed_test_rbac.py 造的数据
_results: list[tuple[str, str, str]] = []
def record(name: str, expected: str, actual: str) -> None:
_results.append(("PASS" if expected == actual else "FAIL", name, f"期望 {expected} / 实际 {actual}"))
def token(sub: str) -> str:
now = dt.datetime.now(dt.UTC)
return jwt.encode(
{
"sub": sub,
"iss": "jr-local",
"aud": "jr-agent-platform",
"exp": now + dt.timedelta(minutes=30),
"nbf": now - dt.timedelta(seconds=5),
"jti": str(uuid.uuid4()),
},
PRIVATE_KEY,
algorithm="RS256",
)
class StubGovernance:
async def resolve(self, definition: AgentDefinition, context: RequestContext) -> ResolvedAgentConfig:
del definition, context
return ResolvedAgentConfig(
config_version="acceptance", prompt_version="acceptance", model_endpoint="stub"
)
async def recall(self, context: RequestContext) -> tuple[Any, ...]:
del context
return ()
async def review(
self, result: Any, context: RequestContext, config: Any, memories: Any
) -> Any:
del context, config, memories
return result
class AcceptanceAgent(BaseAgent):
async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
del request, context
return CoreResult(text="独立验收通过")
def register_probes(factory: AgentFactory, *, version: str = "acceptance") -> None:
"""向工厂注册两个探针 Agent(customer_service / risk)。
生产代码不注册业务 Agent(底座只提供框架),而验收需要走完整 HTTP 链路,
因此通过工厂的公开 `register` 接口注册——这与业务组员接入 Agent 的方式一致。
"""
for agent_type, roles in (
("customer_service", ("customer",)),
("risk", ("risk_operator",)),
):
definition = AgentDefinition(
agent_type=agent_type,
version=version,
allowed_roles=roles,
allowed_portals=("api",),
)
factory.register(definition, lambda _ctx, _def=definition: AcceptanceAgent(_def))
def build_registry() -> AgentFactory:
"""替身治理装配:供 HTTP 契约验收使用(**不验证生产装配**)。"""
factory = AgentFactory(StubGovernance())
register_probes(factory)
return factory
def production_factory() -> AgentFactory:
"""生产装配 + 探针 Agent,并**断言生产装配完整**。
与旧做法的关键差别:不再无条件把测试工厂塞进生产模块的全局名字——那样做会让
"生产装配到底可不可用"完全验收不到。这里直接用生产工厂,先断言模型服务、
工具执行器、意图分类器都已注入,再把探针 Agent 注册进去。
"""
factory = get_agent_factory()
missing = [
name
for name, value in (
("model_service", factory._model_service),
("tool_executor", factory._tool_executor),
("intent_classifier", factory._intent_classifier),
)
if value is None
]
if missing:
raise SystemExit(f"生产装配不完整,缺少:{missing}")
register_probes(factory, version="acceptance-production")
return factory
def inject_factory(factory: AgentFactory) -> None:
"""把替身工厂绑定到服务模块的全局名字——**仅限默认的 HTTP 契约验收模式**。
这是本脚本唯一的注入手段:Controller 直接构造 `AgentRunApplicationService(session)`
而不接收工厂,因此没有依赖注入点可用。生产装配的验证由 `--production` 模式与
`tools/memory_chain_probe.py` 承担,不会被这个注入掩盖。
"""
for module in (run_service, public_platform_service, admin_service):
if hasattr(module, "get_agent_factory"):
module.get_agent_factory = lambda: factory # type: ignore[attr-defined]
async def _collect_events(
client: httpx.AsyncClient, run_id: str, auth: dict[str, str]
) -> list[str]:
events: list[str] = []
async with client.stream(
"GET", f"/api/v1/agent-runs/{run_id}/events", headers=auth, timeout=30
) as stream:
async for line in stream.aiter_lines():
if line.startswith("event:"):
events.append(line.split(":", 1)[1].strip())
if events[-1] in {"done", "error"}:
break
return events
async def cleanup(session_id: str, run_id: str | None) -> None:
async with SessionFactory() as session, session.begin():
if run_id:
event_ids = select(DomainEventOutbox.event_id).where(
DomainEventOutbox.aggregate_id == run_id
)
await session.execute(
delete(OutboxDelivery).where(OutboxDelivery.event_id.in_(event_ids))
)
await session.execute(
delete(DomainEventOutbox).where(DomainEventOutbox.aggregate_id == run_id)
)
await session.execute(
delete(InteractionAudit).where(InteractionAudit.session_id == session_id)
)
await session.execute(delete(AgentRun).where(AgentRun.session_id == session_id))
await session.execute(
delete(RequestIdempotency).where(RequestIdempotency.session_id == session_id)
)
await session.execute(
delete(ConversationMessage).where(ConversationMessage.session_id == session_id)
)
async def main() -> None:
if "--production" in sys.argv:
factory = production_factory()
print("模式:生产装配验收(真实治理链 + 探针 Agent)")
else:
factory = build_registry()
inject_factory(factory)
print("模式:HTTP 契约验收(替身治理)——未验证生产装配;加 --production 验生产链路")
app = create_app()
session_id = f"acceptance-{uuid.uuid4()}"
run_id: str | None = None
try:
async with httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), base_url="http://test", timeout=30
) as client:
auth = {"Authorization": f"Bearer {token(CUSTOMER_ID)}"}
response = await client.post(
"/api/v1/agent-runs",
json={
"agent_type": "customer_service",
"message": "独立验收消息",
"session_id": session_id,
"idempotency_key": uuid.uuid4().hex,
},
headers=auth,
)
record("客户调用已授权 Agent", "202", str(response.status_code))
if response.status_code == 202:
run_id = response.json().get("data", {}).get("run_id")
response = await client.post(
"/api/v1/agent-runs",
json={
"agent_type": "risk",
"message": "越权尝试",
"session_id": session_id,
"idempotency_key": uuid.uuid4().hex,
},
headers=auth,
)
record("客户越权调用 risk Agent", "403", str(response.status_code))
response = await client.post(
"/api/v1/agent-runs",
json={
"agent_type": "no_such_agent",
"message": "x",
"session_id": session_id,
"idempotency_key": uuid.uuid4().hex,
},
headers=auth,
)
record("未注册 agent_type", "404", str(response.status_code))
if run_id:
runtime = WorkerRuntime(factory)
await runtime.dispatch_one(run_id=run_id)
# 先建立 SSE 订阅(此时 run 仍为 queued),再执行,才能观察到 delta 分块
subscription = asyncio.create_task(_collect_events(client, run_id, auth))
await asyncio.sleep(1.0)
await runtime.execute(run_id)
events = await subscription
record("SSE 含 delta 事件", "yes", "yes" if "delta" in events else "no")
record("SSE 终止事件", "done", events[-1] if events else "无事件")
response = await client.get(f"/api/v1/agent-runs/{run_id}", headers=auth)
# 文档 §3.3:单资源成功响应是 {data, meta} 信封。
body = response.json().get("data") or {}
record("运行终态", "succeeded", str(body.get("status")))
record(
"运行结果内容",
"独立验收通过",
str((body.get("result") or {}).get("content")),
)
finally:
await cleanup(session_id, run_id)
width = max(len(name) for _, name, _ in _results)
print()
for verdict, name, detail in _results:
print(f"[{verdict}] {name.ljust(width)} {detail}")
failed = sum(1 for verdict, _, _ in _results if verdict == "FAIL")
print(f"\n合计 {len(_results)} 项,失败 {failed} 项")
if __name__ == "__main__":
asyncio.run(main())