Files
AgentLab/backend/app/main.py
T
ThearchyHelios a59409cc85 使用者选人(后端):按用途给候选池,开登录时入口表单、待办表单的选人控件按同一个池核对
为什么:使用者在选人控件里没有候选(名录只给搭建者),服务端也不核对。填错的名字原样派下去,待办给了一个不存在的人,
没人能处理、也不报错(05-已知遗留 (b))。名录又不能整份放开给使用者(只读与最小可见)。

做了什么:
- auth/directory 新增候选池:form 用途缺省是「能用这个应用的人」——使用及以上的授权(人;组列出组本身并展开成员),
  加上管理它的人(管理员、建应用的人);应用授权给了所有人时池是全部启用的人(open),不带前缀 q 不列(needs_query)。
  字段可以声明 x-principal: {pool: app | group:<组> | slot:<岗位>}(slot 按这次用到的应用版本的 bindings 展开)。
  review 用途是启用的搭建者和管理员(同 personal._review_targets)。停用的人一律不列。
- GET /api/directory/candidates(任何登录的人;main.py 路由清单只加一行):purpose=form 带 app_id+entry 或 task_id、field,
  调用的人不能用这个应用 403;只回 {users:[{username, display_name}], groups, truncated, needs_query?},不给停用、角色、
  邮箱、组成员;q 按用户名或显示名开头筛,limit 最多 50。/api/directory 仍只给搭建者。
- 开了登录时 start_entry 和待办表单提交按同一个池核对,不在池里 422,errors 的 code=principal(FORM_ERROR_CODES 加一项),
  话写成「「x」不在可选的人里,请从候选中选择」(单点登录另说要先登录一次才进名录)。直接生效(遗留-Q4 按推荐)。
  关登录照旧手填、不核对;不属于应用的待办(试运行、画布)没有池,不核对。
- apps/forms:x-principal 进 _FIELD_KEYS,validate_form_schema 查写法(只能写在选人控件上、只能是三种池)。

搭建者没有授权时按 apps/access 只能「查看全部实例」,不算池里的人——池按「能用」算,这一点写进了 app_pool 的说明。

测试:test_directory_candidates(只给名字、停用的不列、前缀筛、没使用权 403、名录仍 403;授权给所有人要前缀、截断;review
只出搭建者和管理员;入口表单不在池 422、停用的 422、组名和池里的人放行;待办表单同一个池;声明 group:<组> 收窄;关登录
任何名字照收;x-principal 写法校验)。
2026-10-16 10:24:22 +08:00

281 lines
13 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.
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import AsyncIterator
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api import (
artifacts, bundle, catalog, compare, conversations, copilot, datasources, evidence, governance, inquiry, knowledge,
runs, sandbox, schedules, settings as settings_api, source_versions, table_imports, tools, whatif, workflows,
)
# 应用平台(docs/design/app-platform-and-look.md)。权限按 §3.12 在这里挂路由级依赖(api/deps.require_role),
# 关登录时全部放行
from app.api import (
actions as actions_api, apps as apps_api, auth as auth_api, capabilities as capabilities_api, hooks as hooks_api,
identity as identity_api, ledger as ledger_api, notify as notify_api, tasks as tasks_api,
)
# 普通用户建应用(docs/design/normal-user-apps.md §3.10):蓝图、模板、文件、个人应用。权限在接口里按蓝图的范围和拥有者判
from app.api import (
blueprints as blueprints_api, copilot_blueprint, files as files_api, personal as personal_api,
templates as templates_api,
)
from app.api.coded import CodedHTTPException, coded_handler
from app.api.deps import chat_scope, evidence_scope, private_run_scope, require_role, role_by_method, run_scope
from app.auth.middleware import AuthMiddleware
from app.core.config import settings
from app.db.base import init_db
from app.engine.runner import run_manager
from app.seed import seed_defaults
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s | %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("agentlab")
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
from app.data.engine import sqlite_dqs_self_check
sqlite_dqs_self_check() # 写错的双引号列名必须报错;做不到就记日志并让启动失败
settings.ensure_dirs()
await init_db()
await seed_defaults()
# 记级别之前发布过的老版本:有正式运行凭据的补记为已发布,免得子工作流、口径卡钉住它们的正式运行被误判成「没发布过」
from app.apps.versions import backfill_legacy_levels
from app.db.base import SessionLocal as _Sessions
async with _Sessions() as _s:
if levelled := await backfill_legacy_levels(_s):
logger.info("给 %d 个老的发布版本补记了级别(有正式运行凭据)", levelled)
await _s.commit()
await _platform_startup()
# 把存的 embedding 配置读进来。不读的话重启后会悄悄退回本地哈希向量,
# 而库里的向量是上次那个模型建的——检索会整体退回关键词,
# 用户只看到"重启之后搜得不准了"
from app.db.base import SessionLocal
from app.memory.embeddings import load_setting
async with SessionLocal() as _session:
try:
await load_setting(_session)
except Exception as e: # noqa: BLE001 - key 没了不该拦住整个服务起不来
logger.warning("embedding 配置没能生效,暂用本地向量:%s", e)
# 上次被强杀时留下的"处理中"文档。后台任务不会自己回来,留着它只会在
# 界面上永远转圈——和 runner 对 running 的 run 是同一个道理
from sqlalchemy import update as _update
from app.db.models import Document as _Doc
async with SessionLocal() as _s:
await _s.execute(
_update(_Doc).where(_Doc.status == "processing")
.values(status="failed", error="服务重启,这份文档没有处理完,请重新上传")
)
await _s.commit()
# 上传表格的版本底座:清理上次没发布完的文件、给老上传补初始版本、回收没人用的版本。
# 出错只记日志:最坏是留下几个多余的文件,不该让服务起不来
from app.data import table_versions
async with SessionLocal() as _s:
try:
await table_versions.startup(_s)
except Exception: # noqa: BLE001
logger.exception("上传表格的版本整理失败,服务照常启动")
# 上次没做完的数据目录起草、剖析任务:标成中断(没结束的表记停止),不自动续跑——拷来的数据目录(检查脚本、验收副本)
# 启动时不能替人去调模型、查业务库。界面上点「重试」接着做没做完的表。剖析设置里按整次剖析算的旧预算顺带迁移掉
from app.data import catalog_jobs, catalog_profile_jobs
try:
await catalog_jobs.startup()
except Exception: # noqa: BLE001
logger.exception("数据目录起草任务的启动整理失败,服务照常启动")
try:
await catalog_profile_jobs.startup()
except Exception: # noqa: BLE001
logger.exception("数据剖析任务的启动整理失败,服务照常启动")
await run_manager.setup()
# 流程:存量的待审批补建待办(派给任何人,幂等);上次进程没了时已发出、没拿到结果的写入收成结果未知、安排核实
from app.process import actions as _actions, tasks as _tasks
await _tasks.startup()
await _actions.startup()
# 发布单:上次进程没了时还在执行的单改成停下(停在没做完的那一步、写明执行中断),这样能接着做
from app.blueprints import publish_plan as _plans
try:
if stopped := await _plans.startup():
logger.info("%d 份发布单执行中断,已改成停下,可以接着做", stopped)
except Exception: # noqa: BLE001
logger.exception("发布单的启动整理失败,服务照常启动")
from app.sandbox.manager import sandbox_manager as _sbm
await _sbm.start_reaper()
# 定时任务的调度循环放在 run_manager 之后起:它到点要发起运行。拷来的数据目录(检查脚本的
# --stacks)用 AGENTLAB_SCHEDULER=0 关掉,免得替原库把定时任务再执行一遍
from app import scheduler
if scheduler.scheduler_enabled():
await scheduler.start()
logger.info("AgentLab 就绪 · 数据目录 %s", settings.data_dir)
try:
yield
finally:
# 先停调度,再停运行:停运行的途中不该再有新运行被发起。起草、剖析任务停下来标成中断,下次启动不会再续
await scheduler.stop()
await catalog_jobs.manager.shutdown()
await catalog_profile_jobs.manager.shutdown()
await run_manager.shutdown()
from app.sandbox.manager import sandbox_manager
await sandbox_manager.close()
logger.info("已停止")
async def _platform_startup() -> None:
"""应用平台的启动整理(BE-1):登录设置读进缓存;自定义工具请求头、MCP env 和请求头里的存量明文加密一遍(幂等);
存量的内网 MCP 服务改为绑定登记端点(只在升级后第一次启动时做);能力登记同步一次。后三样出错只记日志:最坏是登记
暂时不全,不该让服务起不来。"""
from app.auth import current_mode, load_settings
from app.capabilities import registry as capability_registry
from app.capabilities.secrets import seal_existing
from app.db.base import SessionLocal
await load_settings(force=True)
logger.info("登录方式:%s", current_mode())
try:
if sealed := await seal_existing():
logger.info("已加密 %d 处存量的明文密钥(自定义工具请求头、MCP 环境变量和请求头)", sealed)
except Exception: # noqa: BLE001
logger.exception("存量密钥加密失败,服务照常启动")
try:
from app.tools.mcp_manager import adopt_intranet_servers
if adopted := await adopt_intranet_servers():
logger.info("已为 %d 个指向内网的存量 MCP 服务登记端点并绑定:%s", len(adopted),
"、".join(a["server"] for a in adopted))
except Exception: # noqa: BLE001
logger.exception("存量 MCP 服务的端点登记失败,服务照常启动")
try:
async with SessionLocal() as session:
await capability_registry.sync_all(session)
except Exception: # noqa: BLE001
logger.exception("能力登记同步失败,服务照常启动")
app = FastAPI(
title="AgentLab",
description="可视化 Agent 编排实验台",
version="0.1.0",
lifespan=lifespan,
)
# 身份(app/auth/middleware.py):按请求认出是谁、核对 CSRF。加在 CORS 之前,CORS 就在它外面一层:
# 跨域预检由 CORS 先答,不经过登录检查
app.add_middleware(AuthMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 路由级权限(§3.12)。关登录时这些依赖全部放行;开了登录,中间件先挡掉没登录的,这里再按角色和运行挡
_BUILDER = [require_role("builder")]
_ADMIN = [require_role("admin")]
_USER = [require_role("user")]
for router, guard in (
(workflows.router, _BUILDER),
# 这几个也挂在 /api/runs 下面,要在 runs.router 之前登记:以后加的固定路径(/api/runs/compare 之类)
# 才不会被 runs 的 /{run_id} 当成运行 id 吃掉。/{run_id}/… 的子路径放在哪边都不冲突。
# 运行对比、可验证报告、质询、假设分析交的是查询原文,只给 builder 及以上;别人的个人应用刷新、个人蓝图试运行只给
# 拥有者(private_run_scope),管理员也不例外
(compare.router, [*_BUILDER, private_run_scope]),
(bundle.router, [*_BUILDER, private_run_scope]),
(inquiry.router, [*_BUILDER, private_run_scope]),
(whatif.router, [*_BUILDER, private_run_scope]),
(runs.router, [run_scope]),
(runs.approvals_router, _BUILDER),
(evidence.router, [evidence_scope]),
# 模型接入、运行默认值:搭建者只读,改动只给管理员
(settings_api.router, [role_by_method(read="builder", write="admin")]),
(tools.router, _BUILDER),
(tools.custom_router, _BUILDER),
# MCP 的 stdio 只有管理员能建、能改(api/tools.py 里按传输方式判断)
(tools.mcp_router, _BUILDER),
(knowledge.memory_router, _BUILDER),
(knowledge.kb_router, _BUILDER),
(knowledge.skills_router, _BUILDER),
(sandbox.router, _BUILDER),
(copilot.router, [chat_scope]),
(conversations.router, [chat_scope]),
# 工件:user 角色只能取能读的运行引用的、遮罩后的(api/artifacts.py)
(artifacts.router, _USER),
(governance.router, _BUILDER),
(schedules.router, _BUILDER),
# 问数据页选数据源用的名单(只有名字、说明、种类):开了问数据的使用者也能读,同样要在通配 /{source_id} 之前登记
(datasources.names_router, [chat_scope]),
# 按配方导入的 /api/datasources/imports/… 要在 datasources 的通配 /{source_id} 之前登记
(table_imports.router, _BUILDER),
(source_versions.router, _BUILDER),
(catalog.router, _BUILDER),
(datasources.router, _BUILDER),
# 应用平台。登录、登出、OIDC、JWKS 匿名可用,设置在接口里要管理员;入站钩子只认签名
(auth_api.router, []),
(identity_api.router, _ADMIN),
# 选人用的名录(用户名、显示名、是否停用、组名):岗位绑定、应用授权由搭建者做,只读
(identity_api.directory_router, _BUILDER),
(identity_api.candidates_router, _USER),
# 能力登记、端点:搭建者只读,改动在接口里要管理员
(capabilities_api.router, _BUILDER),
# 普通用户建应用:个人应用的接口共用 /api/apps 前缀,要在 apps_api 之前登记;蓝图助手共用 /api/copilot 前缀,
# 在接口里按蓝图的范围判(共享要 builder,个人要拥有者且开了 apps.user_personal_apps)
(personal_api.router, _USER),
(blueprints_api.router, _USER),
(templates_api.router, _USER),
(files_api.router, _USER),
(copilot_blueprint.router, _USER),
# 应用、待办、生效记录、通知:各自按应用授权、派给谁再判断
(apps_api.router, _USER),
(tasks_api.router, _USER),
(actions_api.router, _USER),
(hooks_api.router, []),
(notify_api.router, _USER),
(ledger_api.router, _BUILDER),
):
app.include_router(router, dependencies=guard)
app.add_exception_handler(CodedHTTPException, coded_handler)
@app.exception_handler(ValueError)
async def value_error_handler(_: Request, exc: ValueError) -> JSONResponse:
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok", "service": "agentlab"}
# 生产部署:前端构建产物由服务端一并托管(见 app/web.py)。页面回退是通配路由,
# 必须在所有 /api 路由之后注册。没设 AGENTLAB_WEB_DIST 时什么都不挂,开发模式照旧
if settings.web_dist is not None:
from app.web import mount_web
mount_web(app, settings.web_dist)