合入 W1-d 集成底座:安全响应头(frame-ancestors 缺省 self、白名单、script-src 先只上报);对外访问地址只留一个设置(环境变量优先);开登录时收起 /docs;集成接口的错误一律带机读码

This commit is contained in:
ThearchyHelios
2026-10-16 12:51:43 +08:00
29 changed files with 1328 additions and 126 deletions
+26 -19
View File
@@ -12,14 +12,18 @@ import dataclasses
import json
from typing import Any
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from fastapi import APIRouter, Depends, Header, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.coded import (
APP_BINDINGS_INVALID, APP_DRAFT_INVALID, APP_GRANTS_INVALID, APP_HOOK_NOT_DECLARED, APP_HOOK_NOT_FOUND,
APP_INSTANCE_NOT_FOUND, APP_NOT_FOUND, APP_PARAMS_INVALID, APP_VERSION_NOT_FOUND, FORM_INVALID, RUN_LAUNCH_REFUSED,
CodedHTTPException,
)
from app.api.deps import Principal, actor_of, current_principal
from app.apps.access import ROLE_TEXT, app_role, at_least, grants_of
from app.db.base import get_session
@@ -60,11 +64,11 @@ def _principal(x_actor: str | None) -> Principal:
async def _app(session: AsyncSession, app_id: str, principal: Principal, need: str) -> tuple[App, str]:
app = await session.get(App, app_id)
if app is None:
raise HTTPException(404, APP_GONE)
raise CodedHTTPException(404, APP_GONE, APP_NOT_FOUND)
role = await app_role(session, principal, app)
if role is None:
# 没有任何授权的人不该知道这个应用在不在
raise HTTPException(404, APP_GONE)
# 没有任何授权的人不该知道这个应用在不在:码和不存在的一样
raise CodedHTTPException(404, APP_GONE, APP_NOT_FOUND)
if not at_least(role, need):
raise CodedHTTPException(403, f"需要这个应用的「{ROLE_TEXT[need]}」权限", NO_ACCESS)
return app, role
@@ -314,7 +318,7 @@ async def derive(payload: DeriveIn, session: AsyncSession = Depends(get_session)
try:
return {"spec": await derive_app(session, payload.workflow_id, payload.version, name=payload.name)}
except DeriveRefused as e:
raise HTTPException(e.status_code, e.message) from e
raise CodedHTTPException(e.status_code, e.message, e.code) from e
@router.get("/{app_id}")
@@ -359,13 +363,13 @@ async def update_app(app_id: str, payload: AppPatch, session: AsyncSession = Dep
app, role = await _app(session, app_id, principal, "manage")
_refuse_archived(app, "修改")
if payload.draft_bindings is not None and (problem := _bindings_problem(payload.draft_bindings)):
raise HTTPException(422, problem)
raise CodedHTTPException(422, problem, APP_BINDINGS_INVALID)
if app.scope == "personal" and payload.draft is not None:
# 个人应用的草稿就是在用的那一份:只许改呈现,固定的蓝图、视图来源只由「存成我的应用」改
from app.blueprints.personal import draft_problem
if problem := draft_problem(app.draft or {}, payload.draft):
raise HTTPException(422, problem)
raise CodedHTTPException(422, problem, APP_DRAFT_INVALID)
for key in ("name", "description", "icon"):
if (value := getattr(payload, key)) is not None:
setattr(app, key, value)
@@ -568,7 +572,7 @@ async def get_version(app_id: str, version: int, session: AsyncSession = Depends
row = (await session.execute(select(AppVersion).where(
AppVersion.app_id == app.id, AppVersion.version == version))).scalar_one_or_none()
if row is None:
raise HTTPException(404, f"「{app.name}」没有 v{version}")
raise CodedHTTPException(404, f"「{app.name}」没有 v{version}", APP_VERSION_NOT_FOUND)
# 岗位绑定是这一个部署里的人和组:管理者才看得到
return {**_version_row(row), "spec": row.spec, "bindings": row.bindings if role == "manage" else {"slots": {}}}
@@ -611,7 +615,7 @@ async def put_grants(app_id: str, payload: GrantsIn, session: AsyncSession = Dep
_refuse_personal(app, "授权给别人")
wanted, problem = await grants_problem(session, [(i.principal_kind, i.principal, i.role) for i in payload.items])
if problem:
raise HTTPException(422, problem)
raise CodedHTTPException(422, problem, APP_GRANTS_INVALID)
return {"items": await replace_grants(session, app, wanted, actor=actor_of(x_actor))}
@@ -713,7 +717,8 @@ async def rotate_hook(app_id: str, name: str, session: AsyncSession = Depends(ge
draft, published, rows = await _hook_state(session, app)
declared = draft.get(name) or published.get(name)
if declared is None:
raise HTTPException(404, f"草稿和当前发布版里都没有声明钩子「{name}」,请先在「钩子」一节里声明它")
raise CodedHTTPException(404, f"草稿和当前发布版里都没有声明钩子「{name}」,请先在「钩子」一节里声明它",
APP_HOOK_NOT_DECLARED)
# 从读行锁到提交:两位管理者同时点「生成密钥」,后到的那次读得到前一次插入的行,只轮换它(见 process/hooks.py)
async with hooks.hook_lock(app.id, name):
row, secret, created = await hooks.rotate_hook(session, app.id, name, kind=declared["kind"],
@@ -731,7 +736,7 @@ async def _hook_named(session: AsyncSession, app: App, name: str) -> AppHook:
row = await hook_row(session, app.id, name)
if row is None:
raise HTTPException(404, f"钩子「{name}」还没有生成密钥")
raise CodedHTTPException(404, f"钩子「{name}」还没有生成密钥", APP_HOOK_NOT_FOUND)
return row
@@ -778,9 +783,9 @@ def _params(raw: str | None) -> dict[str, Any]:
try:
value = json.loads(raw)
except ValueError as e:
raise HTTPException(422, "页面参数需要是 JSON 对象") from e
raise CodedHTTPException(422, "页面参数需要是 JSON 对象", APP_PARAMS_INVALID) from e
if not isinstance(value, dict):
raise HTTPException(422, "页面参数需要是 JSON 对象")
raise CodedHTTPException(422, "页面参数需要是 JSON 对象", APP_PARAMS_INVALID)
return value
@@ -788,14 +793,16 @@ async def _instance(session: AsyncSession, app: App, run_id: str, principal: Pri
"""实例要属于这个应用;只有 use 的人只能看自己发起的(关登录时都能看)。"""
run = await session.get(Run, run_id)
if run is None or run.app_id != app.id or run.origin_kind not in ("app_entry", "hook"):
raise HTTPException(404, "实例不存在")
raise CodedHTTPException(404, "实例不存在", APP_INSTANCE_NOT_FOUND)
if principal.mode != "off" and not at_least(role, "observe") and run.started_by != principal.username:
raise CodedHTTPException(403, "只能查看自己发起的实例", NO_ACCESS)
return run
def _view_refused(e: Any) -> HTTPException:
return CodedHTTPException(e.status_code, e.message, e.code) if e.code else HTTPException(e.status_code, e.message)
def _view_refused(e: Any) -> CodedHTTPException:
"""视图、入口、补算、发起运行的拒绝 → 带 code 的 HTTP 错误。只有引擎不发起运行(LaunchRefused)有不带码的几种(工作流
没发布、版本不存在、图没通过校验……),补上同一类的 run_launch_refused,detail 照旧说清是哪一种。"""
return CodedHTTPException(e.status_code, e.message, e.code or RUN_LAUNCH_REFUSED)
@router.get("/{app_id}/pages/{page_id}")
@@ -834,7 +841,7 @@ async def start(app_id: str, entry_id: str, payload: StartIn, session: AsyncSess
try:
run = await start_entry(app.id, entry_id, payload.values, principal)
except FormRejected as e:
return JSONResponse(status_code=422, content={"detail": "表单没有通过校验", "errors": e.errors})
return JSONResponse(status_code=422, content={"detail": "表单没有通过校验", "code": FORM_INVALID, "errors": e.errors})
except (EntryRefused, LaunchRefused) as e:
raise _view_refused(e) from e
return {"run_id": run.id, "title": instance_title(run)}
@@ -905,7 +912,7 @@ async def refresh_view(app_id: str, view_id: str, payload: RefreshIn, session: A
run, cached = await run_view(session, app=app, version=version, view_id=view_id, params=payload.params,
principal=principal, actor=actor_of(x_actor))
except FormRejected as e:
return JSONResponse(status_code=422, content={"detail": "参数没有通过校验", "errors": e.errors})
return JSONResponse(status_code=422, content={"detail": "参数没有通过校验", "code": FORM_INVALID, "errors": e.errors})
except (ViewRefused, LaunchRefused) as e:
raise _view_refused(e) from e
return {"run_id": run.id, "cached": cached}
+9 -6
View File
@@ -2,9 +2,12 @@ from __future__ import annotations
from typing import Any
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter
from sqlalchemy import select
from app.api.coded import (
ARTIFACT_FORBIDDEN, ARTIFACT_KIND_FORBIDDEN, ARTIFACT_NOT_FOUND, ARTIFACT_TAMPERED, CodedHTTPException,
)
from app.api.deps import can_read_artifact, current_principal
from app.core import artifact_store
@@ -23,14 +26,14 @@ async def get_artifact(artifact_id: str) -> dict[str, Any]:
principal = current_principal()
restricted = principal.mode != "off" and not principal.at_least("builder")
if principal.mode != "off" and not await can_read_artifact(principal, artifact_id):
raise HTTPException(403, "没有权限查看这件工件")
raise CodedHTTPException(403, "没有权限查看这件工件", ARTIFACT_FORBIDDEN)
try:
content = artifact_store.load(artifact_id)
except ValueError as e:
# 哈希对不上:明确报给调用方,这正是工件库存在的意义
raise HTTPException(409, str(e)) from e
raise CodedHTTPException(409, str(e), ARTIFACT_TAMPERED) from e
if content is None:
raise HTTPException(404, "工件不存在,可能已被删除")
raise CodedHTTPException(404, "工件不存在,可能已被删除", ARTIFACT_NOT_FOUND)
if restricted:
content = await _masked_for_user(principal, artifact_id, content)
return {"id": artifact_id, "content": content}
@@ -68,7 +71,7 @@ async def _masked_for_user(principal: Any, artifact_id: str, content: Any) -> An
return await _masked_metrics(principal, artifact_id, content, rows)
if _RECEIPT in kinds and isinstance(content, dict):
return await _masked_receipt(content, rows)
raise HTTPException(403, "这类工件的原文只对搭建者及以上的角色开放")
raise CodedHTTPException(403, "这类工件的原文只对搭建者及以上的角色开放", ARTIFACT_KIND_FORBIDDEN)
async def _masked_snapshot(content: dict[str, Any], *, endpoint: bool) -> dict[str, Any]:
@@ -106,7 +109,7 @@ async def _masked_metrics(principal: Any, artifact_id: str, card: dict[str, Any]
owner = (run_id, node_id or "")
break
if owner is None:
raise HTTPException(403, "没有权限查看这件工件")
raise CodedHTTPException(403, "没有权限查看这件工件", ARTIFACT_FORBIDDEN)
sealed = await _sealed(owner[0])
masks = _Masks(sealed)
metrics: list[Any] = []
+6 -3
View File
@@ -17,6 +17,7 @@ from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import AUTH_REQUIRED
from app.api.deps import Principal, current_principal, decode_actor, ensure_role
from app.auth import (
MODES, SETTING_KEY, current_mode, env_mode, load_settings, remember_settings, stored_settings, user_can_chat,
@@ -25,7 +26,6 @@ from app.auth import oidc, sessions
from app.auth.audit import record
from app.auth.passwords import hash_password, password_problem, verify_password
from app.core import crypto
from app.core.config import settings
from app.db.base import get_session
from app.db.models import Setting, User
@@ -60,7 +60,7 @@ async def me(x_actor: str | None = Header(default=None), session: AsyncSession =
return await principal_full(session, Principal(username=actor, display_name=actor, role="admin", mode="off"))
if not principal.username:
can_bootstrap = principal.mode == "local" and not await _local_admin_exists(session)
return JSONResponse(status_code=401, content={"detail": "请先登录", "code": "auth_required",
return JSONResponse(status_code=401, content={"detail": "请先登录", "code": AUTH_REQUIRED,
"mode": principal.mode, "can_bootstrap": can_bootstrap})
return await principal_full(session, principal)
@@ -113,7 +113,10 @@ async def logout(request: Request, session: AsyncSession = Depends(get_session))
def _redirect_uri(request: Request) -> str:
base = settings.public_url.rstrip("/") or str(request.base_url).rstrip("/")
"""回调地址:对外访问地址(环境变量优先,其次设置,core/public_url.py),都没有按请求自己的地址拼。"""
from app.core.public_url import public_base_url
base = public_base_url() or str(request.base_url).rstrip("/")
return f"{base}/api/auth/oidc/callback"
+49
View File
@@ -29,6 +29,55 @@ CATALOG_DRAFT_BUSY = "catalog_draft_busy"
CATALOG_DRAFT_MODEL_UNAVAILABLE = "catalog_draft_model_unavailable"
CATALOG_DRAFT_FINISHED = "catalog_draft_finished"
# ---------------------------------------------------------------- 集成接口(04 细案 A7)
# 外部系统用访问密钥调的应用、待办、工件接口,以及角色、运行读权限的拒绝:按 code 认是哪种错,detail 照旧是给人看的那句
#: 身份:没登录(401,中间件、auth.require 都回它)、角色不够(403,路由级依赖、接口内部的 ensure_role、开登录时的 /docs)
AUTH_REQUIRED = "auth_required"
ROLE_REQUIRED = "role_required"
#: 运行:这次运行读不了(403,api/deps 的证据、运行接口);别人的个人应用刷新、个人蓝图试运行只给拥有者(403)
RUN_FORBIDDEN = "run_forbidden"
RUN_PRIVATE = "run_private"
#: 问数据和助手:使用者没开问数据,或者这个接口不在问数据的范围里(403)
CHAT_FORBIDDEN = "chat_forbidden"
#: 应用(api/apps.py)。app_not_found 同入口发起时的 apps/entries.APP_GONE;没有任何授权的人也回它(不该知道应用在不在)
APP_NOT_FOUND = "app_not_found"
APP_VERSION_NOT_FOUND = "app_version_not_found"
APP_INSTANCE_NOT_FOUND = "app_instance_not_found"
#: 草稿里、当前发布版里都没有声明这个钩子(404);声明了、还没生成密钥(404)
APP_HOOK_NOT_DECLARED = "app_hook_not_declared"
APP_HOOK_NOT_FOUND = "app_hook_not_found"
#: 改草稿、授权时写进来的不合规(422):岗位绑定、个人应用的草稿(只许改呈现)、授权的人和组
APP_BINDINGS_INVALID = "app_bindings_invalid"
APP_DRAFT_INVALID = "app_draft_invalid"
APP_GRANTS_INVALID = "app_grants_invalid"
#: 页面参数不是 JSON 对象(422)
APP_PARAMS_INVALID = "app_params_invalid"
#: 入口表单、按需视图的参数没通过校验(422,另带 errors:[{path, code, message}])。同待办回复、入站钩子的表单错误
FORM_INVALID = "form_invalid"
#: 发起实例、刷新视图、补算时引擎不发起运行,又没有更细的码(engine/launch.LaunchRefused 没带 code 的那几种)
RUN_LAUNCH_REFUSED = "run_launch_refused"
# 派生应用草稿时工作流、版本不存在(404)的码在 apps/derive.py(WORKFLOW_GONE、VERSION_GONE);入口、视图、补算的码
# 在 apps/entries.py、apps/resolve.py、apps/backfill.py,同这里一样都是 app_ 开头
#: 待办(api/tasks.py)。服务层(process/tasks.py 的 TaskRefused)没带码的拒绝按状态补上同一类的码:404 不存在;403
#: 不能这样做(不是管理员也不是应用的管理者,不能改派、催办);409 待办已经处理过、关闭了、刚被别人回复,或者所属的
#: 运行不再等这条回复——刷新看最新状态;422 回复的内容不对。带了码的照用自己的(task_not_assigned、form_invalid……)
TASK_NOT_FOUND = "task_not_found"
TASK_FORBIDDEN = "task_forbidden"
TASK_CLOSED = "task_closed"
TASK_INVALID = "task_invalid"
#: 工件(api/artifacts.py):没有权限(403)、这类工件的原文只给搭建者(403)、不存在(404)、内容和哈希对不上(409)
ARTIFACT_FORBIDDEN = "artifact_forbidden"
ARTIFACT_KIND_FORBIDDEN = "artifact_kind_forbidden"
ARTIFACT_NOT_FOUND = "artifact_not_found"
ARTIFACT_TAMPERED = "artifact_tampered"
#: 设置(api/settings.py):对外访问地址由环境变量 AGENTLAB_PUBLIC_URL 指定时,设置里那一项不能改成别的值(409)
PUBLIC_URL_ENV_LOCKED = "public_url_env_locked"
class CodedHTTPException(HTTPException):
"""field:出错的是哪一项(表单据此把报错落到那一格)。可选,没有就不出现在返回里。"""
+13 -10
View File
@@ -26,9 +26,11 @@ from dataclasses import dataclass, field
from typing import Any
from urllib.parse import unquote
from fastapi import Depends, HTTPException
from fastapi import Depends
from starlette.requests import HTTPConnection
from app.api.coded import CHAT_FORBIDDEN, ROLE_REQUIRED, RUN_FORBIDDEN, RUN_PRIVATE, CodedHTTPException
#: 登录方式
AUTH_MODES = ("off", "local", "oidc")
#: 角色由高到低
@@ -149,7 +151,7 @@ async def can_read_artifact(principal: Principal, artifact_id: str) -> bool:
def ensure_role(role: str) -> None:
"""接口内部用:至少是 role,否则 401(没登录)/ 403(角色不够)。关登录时放行。
"""接口内部用:至少是 role,否则 401(没登录,auth_required)/ 403(角色不够,role_required)。关登录时放行。
用在要看请求内容才知道要什么角色的地方(比如 MCP 的 stdio 只有管理员能建)。"""
from app.auth import require
@@ -178,8 +180,8 @@ async def ensure_conversation_access(session: Any, conversation_id: str | None)
return await check(session, conversation_id)
def _forbidden() -> HTTPException:
return HTTPException(403, "没有权限查看这次运行")
def _forbidden() -> CodedHTTPException:
return CodedHTTPException(403, "没有权限查看这次运行", RUN_FORBIDDEN)
#: user 角色在自己的问数据运行上能读的(GET /api/runs/{id} 之后的部分):运行记录、事件、运行时的图,加上实时流
@@ -244,13 +246,13 @@ async def _run_scope(conn: HTTPConnection) -> None:
return
ensure_role("user")
if not user_can_chat():
raise HTTPException(403, _RUNS_FOR_BUILDERS)
raise CodedHTTPException(403, _RUNS_FOR_BUILDERS, ROLE_REQUIRED)
method = conn.scope.get("method", "GET")
run_id = conn.path_params.get("run_id")
if run_id is None:
if method == "POST" and conn.url.path.rstrip("/") == "/api/runs":
return
raise HTTPException(403, _RUNS_FOR_BUILDERS)
raise CodedHTTPException(403, _RUNS_FOR_BUILDERS, ROLE_REQUIRED)
run = await _load_run(run_id)
rest = conn.url.path.rstrip("/").removeprefix(f"/api/runs/{run_id}")
if method == "GET" and rest in _CHAT_RUN_READS:
@@ -261,7 +263,7 @@ async def _run_scope(conn: HTTPConnection) -> None:
raise _forbidden()
if method == "POST" and rest == "/cancel":
return
raise HTTPException(403, "没有权限:这项操作需要搭建者及以上的角色")
raise CodedHTTPException(403, "没有权限:这项操作需要搭建者及以上的角色", ROLE_REQUIRED)
async def _load_run(run_id: str | None) -> Any:
@@ -291,7 +293,7 @@ async def _refuse_private(conn: HTTPConnection, principal: Principal) -> None:
from fastapi import WebSocketException, status
raise WebSocketException(code=status.WS_1008_POLICY_VIOLATION)
raise HTTPException(403, PRIVATE_RUN)
raise CodedHTTPException(403, PRIVATE_RUN, RUN_PRIVATE)
async def _private_run_scope(conn: HTTPConnection) -> None:
@@ -319,7 +321,7 @@ async def _evidence_scope(conn: HTTPConnection) -> None:
return
ensure_role("user")
if conn.scope.get("method", "GET") != "GET":
raise HTTPException(403, "没有权限:这项操作需要搭建者及以上的角色")
raise CodedHTTPException(403, "没有权限:这项操作需要搭建者及以上的角色", ROLE_REQUIRED)
run_id = conn.path_params.get("run_id")
if not run_id or not await can_read_run(principal, run_id):
raise _forbidden()
@@ -336,7 +338,8 @@ async def _chat_scope(conn: HTTPConnection) -> None:
method = conn.scope.get("method", "GET")
if user_can_chat() and (path.startswith("/api/conversations") or (method, path) in _CHAT_ROUTES):
return
raise HTTPException(403, "没有权限:问数据和助手需要搭建者及以上的角色,或由管理员为使用者开启问数据")
raise CodedHTTPException(403, "没有权限:问数据和助手需要搭建者及以上的角色,或由管理员为使用者开启问数据",
CHAT_FORBIDDEN)
#: user 角色开了问数据时能用的其余接口:问数据的生成流、读当前助手模型、问数据页选数据源用的数据源名单
+40 -27
View File
@@ -10,7 +10,8 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api import health
from app.api.coded import CodedHTTPException
from app.api.coded import PUBLIC_URL_ENV_LOCKED, CodedHTTPException
from app.core import public_url
from app.core.errors import explain, not_configured, raw
from app.core.config import settings as app_settings
from app.core.crypto import encrypt, mask
@@ -604,35 +605,32 @@ def catalog_draft_values(stored: Any) -> dict[str, Any]:
# --------------------------------------------------------------------------
# 通知:定时任务推送 Webhook 时拼运行链接用的对外访问地址
# 对外访问地址:通知、Webhook 里的链接,单点登录的回调,来源核对(core/public_url.py)
# --------------------------------------------------------------------------
#: 设置里 notify 这一组。public_base_url 是别人打开 AgentLab 用的地址(https://agentlab.example.com),通知里的
#: 运行链接 = 它 + /runs/{运行 id}。服务端不知道自己在反向代理后面叫什么,只能由人填;留空的通知里不附链接
NOTIFY_GROUP = "notify"
NOTIFY_DEFAULTS: dict[str, Any] = {"public_base_url": None}
NOTIFY_SETTING_LABEL = {"public_base_url": "对外访问地址"}
#: 运行链接 = 它 + /runs/{运行 id}。服务端不知道自己在反向代理后面叫什么,只能由人填;留空的通知里不附链接。
#: 全站只认一个对外地址(core/public_url.py):环境变量 AGENTLAB_PUBLIC_URL 设了的话这一项不生效、不能改(PUT 回 409)。
#: 键名沿用 notify 一组,界面上在「设置 → 通用」
NOTIFY_GROUP = public_url.SETTING_GROUP
NOTIFY_DEFAULTS: dict[str, Any] = {public_url.SETTING_KEY: None}
NOTIFY_SETTING_LABEL = {public_url.SETTING_KEY: "对外访问地址"}
#: GET /api/settings 另给的只读一组:{value: 生效的对外访问地址, env_locked: 是不是环境变量指定的}。设置页据此显示
#: 「由环境变量指定」;PUT 带上它也不落库(同 limits)
PUBLIC_URL_GROUP = "public_url"
def _base_url_problem(value: Any) -> str | None:
if value is None or (isinstance(value, str) and not value.strip()):
return None
label = NOTIFY_SETTING_LABEL["public_base_url"]
label = NOTIFY_SETTING_LABEL[public_url.SETTING_KEY]
hint = (f"「{label}」需要是 http:// 或 https:// 开头的完整地址(例如 https://agentlab.example.com),"
f"留空表示通知里不附运行链接,当前为「{value}」")
f"留空表示通知里不附链接,当前为「{value}」")
if not isinstance(value, str):
return hint
from urllib.parse import urlsplit
try:
parts = urlsplit(value.strip())
except ValueError:
return hint
if parts.scheme not in ("http", "https") or not parts.hostname or " " in value.strip():
return hint
if parts.query or parts.fragment:
return f"「{label}」不能带 ? 或 # 之后的部分,运行链接会接在地址后面,当前为「{value}」"
return None
if (problem := public_url.url_problem(value)) == "suffix":
return f"「{label}」不能带 ? 或 # 之后的部分,链接会接在地址后面,当前为「{value}」"
return hint if problem else None
def notify_problem(value: Any) -> str | None:
@@ -651,18 +649,23 @@ def notify_problem(value: Any) -> str | None:
def notify_values(stored: Any) -> dict[str, Any]:
"""库里存的(或刚写进来的)notify 一组 → 补齐默认、规范化:去掉首尾空白和末尾的 /,空串当没填;存坏了的按没填。"""
stored = stored if isinstance(stored, dict) else {}
url = stored.get("public_base_url")
if not isinstance(url, str) or not url.strip() or _base_url_problem(url):
return {"public_base_url": None}
return {"public_base_url": url.strip().rstrip("/")}
return {public_url.SETTING_KEY: public_url.normalize(stored.get(public_url.SETTING_KEY))}
async def notify_settings(session: AsyncSession) -> dict[str, Any]:
"""设置里的 notify 一组,没存过的补默认。定时任务推送 Webhook 时从这里读。"""
"""设置里存的 notify 一组,没存过的补默认。要拼链接的地方不读它:一律经 core/public_url(环境变量优先)。"""
row = await session.get(Setting, NOTIFY_GROUP)
return notify_values(row.value if row else None)
def public_url_values(stored_notify: Any) -> dict[str, Any]:
"""GET /api/settings 里只读的 public_url 一组:生效的那个地址(环境变量优先,其次库里存的这一项),是不是环境变量
指定的。按库里存的算,不读进程缓存:设置页看到的就是落库的样子。"""
locked = public_url.env_locked()
value = public_url.env_url() if locked else notify_values(stored_notify)[public_url.SETTING_KEY]
return {"value": value, "env_locked": locked}
# --------------------------------------------------------------------------
# 应用:按需视图的每日上限(D-j)、使用者能不能建个人应用(docs/design/normal-user-apps.md N7)
# --------------------------------------------------------------------------
@@ -754,7 +757,7 @@ DEFAULT_SETTINGS: dict[str, Any] = {
JUDGE_GROUP: dict(JUDGE_DEFAULTS),
# 核查(双盲复算、反方审稿、审批质询):核查模型、三项的开关和上限、每日金额上限。和裁判一组一样由用户定
VERIFY_GROUP: verify_values(None),
# 通知:定时任务的 Webhook 里拼运行链接用的对外访问地址
# 对外访问地址(存在 notify 一组):通知、Webhook 里的链接,单点登录的回调,来源核对。环境变量优先(core/public_url.py)
NOTIFY_GROUP: notify_values(None),
# 起草模型:数据目录起草、按配方导入的 AI 起草用的模型,留空沿用助手的模型
CATALOG_DRAFT_GROUP: catalog_draft_values(None),
@@ -808,6 +811,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
# limits 是服务端配置(环境变量),只读:库里可能存着旧版设置页写进去的一份
#(见过 max_agent_steps: 25),它从来不生效,却会盖住真实的上限,界面照着错的数许诺
stored.pop("limits", None)
stored.pop(PUBLIC_URL_GROUP, None)
# 裁判、核查的每日计数是引擎记的账,不是用户设置:设置页整组回写时带着一份旧的,会把今天的花费清零
stored.pop(JUDGE_SPEND, None)
stored.pop(VERIFY_SPEND, None)
@@ -818,6 +822,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
merged[JUDGE_GROUP] = judge_values(stored.get(JUDGE_GROUP))
merged[VERIFY_GROUP] = verify_values(stored.get(VERIFY_GROUP))
merged[NOTIFY_GROUP] = notify_values(stored.get(NOTIFY_GROUP))
merged[PUBLIC_URL_GROUP] = public_url_values(stored.get(NOTIFY_GROUP))
merged[CATALOG_DRAFT_GROUP] = catalog_draft_values(stored.get(CATALOG_DRAFT_GROUP))
merged[APPS_GROUP] = apps_values(stored.get(APPS_GROUP))
for key, value in stored.items():
@@ -910,6 +915,11 @@ async def put_settings(
raise HTTPException(422, problem)
# 存规范化之后的:末尾带 / 的地址拼链接会多出一个斜杠
payload.values[NOTIFY_GROUP] = notify_values(payload.values[NOTIFY_GROUP])
if public_url.env_locked() and payload.values[NOTIFY_GROUP] != await notify_settings(session):
# 环境变量指定了对外访问地址:改这一项不会生效,照实拒绝,不悄悄存一个用不上的值。原样写回(整组回写)不算改
raise CodedHTTPException(
409, f"对外访问地址由环境变量 {public_url.ENV_NAME} 指定({public_url.env_url()}),不能在设置里修改。"
"要改请改部署配置后重启服务", PUBLIC_URL_ENV_LOCKED, field=f"{NOTIFY_GROUP}.{public_url.SETTING_KEY}")
if APPS_GROUP in payload.values:
if problem := apps_problem(payload.values[APPS_GROUP]):
raise HTTPException(422, problem)
@@ -917,9 +927,9 @@ async def put_settings(
if found := await _provider_problem(session, payload.values):
raise CodedHTTPException(422, found[1], PROVIDER_INVALID, field=found[0])
for key, value in payload.values.items():
if key in (TRUST_KEY, "limits", JUDGE_SPEND, VERIFY_SPEND, *_hidden_groups()):
if key in (TRUST_KEY, "limits", PUBLIC_URL_GROUP, JUDGE_SPEND, VERIFY_SPEND, *_hidden_groups()):
# 信任三档只走 PUT /api/tools/trust:设置页整组回写时带着一份旧的,
# 会把刚在工具页改的档位冲掉。limits 是环境变量定的,写进库也不生效。
# 会把刚在工具页改的档位冲掉。limits 是环境变量定的、public_url 是算出来的,写进库也不生效。
# 裁判、核查的每日计数是引擎记的账,只有引擎自己写。登录设置只经 /api/auth/settings 改(_hidden_groups)
continue
row = await session.get(Setting, key)
@@ -928,6 +938,9 @@ async def put_settings(
else:
session.add(Setting(key=key, value=value))
await session.commit()
if NOTIFY_GROUP in payload.values:
# 中间件、单点登录的回调按进程缓存取对外访问地址:写完库就刷新,不用重启
public_url.remember(payload.values[NOTIFY_GROUP][public_url.SETTING_KEY])
return await get_settings(session)
+14 -9
View File
@@ -1,17 +1,20 @@
"""待办(BE-2):列表、计数、详情、回复、改派、催办。设计文档 §3.7、§3.10。
身份经 api/deps.current_principal 取;关登录时它没有名字,用自报的署名(X-Actor)补上,回复人一栏才写得出人。
回复被拒回 403 / 404 / 409 / 422,{detail, code};表单不通过时另带 errors(同应用入口的 422)。
回复被拒回 403 / 404 / 409 / 422,{detail, code},一律带 code(外部系统按它认);表单不通过时另带 errors(同应用入口的
422)。服务层没带码的拒绝按状态补上同一类的码(api/coded.py 的 TASK_*)。
"""
from __future__ import annotations
from typing import Any, Literal
from fastapi import APIRouter, Header, HTTPException, Query
from fastapi import APIRouter, Header, Query
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from app.api.coded import CodedHTTPException
from app.api.coded import (
ROLE_REQUIRED, TASK_CLOSED, TASK_FORBIDDEN, TASK_INVALID, TASK_NOT_FOUND, CodedHTTPException,
)
from app.api.deps import current_principal, decode_actor
from app.process import tasks as task_service
from app.process.tasks import FormInvalid, TaskRefused
@@ -23,12 +26,14 @@ def principal_of(x_actor: str | None) -> Any:
return task_service.principal_with_actor(current_principal(), decode_actor(x_actor))
def refused(e: TaskRefused) -> HTTPException | JSONResponse:
#: 服务层(process/tasks.py、process/actions.py、blueprints/personal.py)没带码的拒绝按状态归到哪一类
_CODE_BY_STATUS = {404: TASK_NOT_FOUND, 403: TASK_FORBIDDEN, 409: TASK_CLOSED, 422: TASK_INVALID}
def refused(e: TaskRefused) -> CodedHTTPException | JSONResponse:
if isinstance(e, FormInvalid):
return JSONResponse({"detail": str(e), "code": e.code, "errors": e.errors}, status_code=422)
if e.code:
return CodedHTTPException(e.status_code, str(e), e.code)
return HTTPException(e.status_code, str(e))
return CodedHTTPException(e.status_code, str(e), e.code or _CODE_BY_STATUS.get(e.status_code, TASK_CLOSED))
def _raise(e: TaskRefused) -> Any:
@@ -53,7 +58,7 @@ async def list_tasks(
) -> dict[str, Any]:
principal = principal_of(x_actor)
if assigned == "all" and principal.mode != "off" and not principal.at_least("builder"):
raise HTTPException(403, "只能查看派给自己的待办")
raise CodedHTTPException(403, "只能查看派给自己的待办", ROLE_REQUIRED)
statuses = None if status == "all" else _csv(status) or ["pending"]
items = await task_service.list_tasks(principal, assigned=assigned, statuses=statuses, app_id=app_id,
run_id=run_id, limit=limit)
@@ -71,7 +76,7 @@ async def get_task(task_id: str, x_actor: str | None = Header(default=None)) ->
principal = principal_of(x_actor)
out = await task_service.detail(task_id, principal)
if out is None or not task_service.visible(out, principal):
raise HTTPException(404, "待办不存在,所属的运行可能已被删除")
raise CodedHTTPException(404, "待办不存在,所属的运行可能已被删除", TASK_NOT_FOUND)
return out
+9 -3
View File
@@ -24,13 +24,19 @@ VIEW = "latest"
INSTANCE_VIEW = "inst"
ENTRY = "start"
RECONCILER = "reconciler"
#: 派生不了时的机器码:工作流、版本不存在(404)
WORKFLOW_GONE = "workflow_not_found"
VERSION_GONE = "workflow_version_not_found"
class DeriveRefused(Exception):
def __init__(self, status_code: int, message: str) -> None:
"""派生不了。code 是给程序认的机器码,接口照它回。"""
def __init__(self, status_code: int, message: str, code: str) -> None:
super().__init__(message)
self.status_code = status_code
self.message = message
self.code = code
def _safe(text: str) -> str:
@@ -45,10 +51,10 @@ async def derive_app(session: AsyncSession, workflow_id: str, version: int, *, n
workflow = await session.get(Workflow, workflow_id)
if workflow is None:
raise DeriveRefused(404, "工作流不存在,可能已被删除")
raise DeriveRefused(404, "工作流不存在,可能已被删除", WORKFLOW_GONE)
row = await version_row(session, workflow_id, version)
if row is None:
raise DeriveRefused(404, f"「{workflow.name}」不存在 v{version} 版本")
raise DeriveRefused(404, f"「{workflow.name}」不存在 v{version} 版本", VERSION_GONE)
state = PinState(alias=PIN, pin={"workflow_id": workflow_id, "version": version}, workflow=workflow, row=row)
schedule = (await session.execute(select(Schedule).where(
Schedule.workflow_id == workflow_id, Schedule.run_class == "formal", Schedule.enabled.is_(True))
+18 -9
View File
@@ -15,7 +15,9 @@ import logging
import os
from typing import Any
from fastapi import Depends, HTTPException
from fastapi import Depends
from app.api.coded import AUTH_REQUIRED, ROLE_REQUIRED, CodedHTTPException
logger = logging.getLogger(__name__)
@@ -77,17 +79,24 @@ def remember_settings(value: dict[str, Any]) -> None:
_loaded = True
def refusal(principal: Any, role: str) -> CodedHTTPException | None:
"""principal 够不够 role:够(或关登录)是 None;不够交回该回的错——没登录 401(auth_required),角色不够 403
(role_required)。路由级依赖、接口内部的 ensure_role、中间件拦 /docs 都用它,同一个判断、同一句话。"""
if principal.mode == "off":
return None
if not principal.username:
return CodedHTTPException(401, "请先登录", AUTH_REQUIRED)
if not principal.at_least(role):
return CodedHTTPException(403, f"没有权限:这项操作需要{ROLE_LABEL.get(role, role)}及以上的角色", ROLE_REQUIRED)
return None
def require(role: str) -> None:
"""当前请求至少是 role,否则 401(没登录)/ 403(角色不够)。关登录时放行。"""
"""当前请求至少是 role,否则 401(没登录)/ 403(角色不够),都带 code。关登录时放行。"""
from app.api.deps import current_principal
principal = current_principal()
if principal.mode == "off":
return
if not principal.username:
raise HTTPException(401, "请先登录")
if not principal.at_least(role):
raise HTTPException(403, f"没有权限:这项操作需要{ROLE_LABEL.get(role, role)}及以上的角色")
if (refused := refusal(current_principal(), role)) is not None:
raise refused
def role_dependency(role: str) -> Any:
+20 -6
View File
@@ -9,6 +9,8 @@
- 会话 cookie → Principal(认证过的用户名、角色、组);没有会话是匿名(username=None),X-Actor 一律不看;
- /api 下除了登录本身、健康检查、入站钩子,匿名一律 401。各路由的角色要求另由 main.py 的路由级依赖管,这里兜底:
哪个路由忘了挂依赖,也不会对没登录的人敞开;
- 接口说明(FastAPI 自带的 /docs、/redoc、/openapi.json)和 /api 一样拦:匿名 401,不到搭建者 403。它们是 FastAPI 自己
挂的路由,挂不上路由级依赖,所以角色也在这里判。以前只拦 /api/ 开头的,开了登录接口全貌也对匿名的人敞开;
- 非 GET 的请求必须带 X-AgentLab-Request 头,带了 Origin 的必须是本站或登记的前端地址(CSRF)。自定义请求头跨站发不出来
(要过 CORS 预检,只放行配置里的前端地址),SameSite=Lax 的 cookie 也不随跨站的表单提交带过去;
- WebSocket:没登录的握手前就关掉;登录了的交给路由,按运行判(api/deps.py 的 run_scope:user 角色只能连自己能读、
@@ -19,8 +21,8 @@ from __future__ import annotations
import json
from http.cookies import SimpleCookie
from typing import Any
from urllib.parse import urlsplit
from app.api.coded import AUTH_REQUIRED
from app.core.config import settings
#: 开登录时匿名也能访问的接口
@@ -28,6 +30,8 @@ _PUBLIC = frozenset({"/api/health", "/api/auth/me", "/api/auth/login", "/api/aut
"/api/auth/oidc/callback", "/api/auth/jwks", "/api/auth/bootstrap"})
#: 只认签名、不走会话的入站钩子(也不查 CSRF:调用方是外部系统,没有浏览器的 Origin)
_HOOKS = "/api/hooks/"
#: FastAPI 自带的接口说明(main.py 里 FastAPI() 的缺省地址):开登录时要搭建者
_DOCS = ("/docs", "/redoc", "/openapi.json")
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
CSRF_HEADER = "x-agentlab-request"
@@ -49,11 +53,17 @@ def _cookie(headers: dict[str, str], name: str) -> str | None:
return morsel.value if morsel else None
def _is_docs(path: str) -> bool:
return any(path == p or path.startswith(p + "/") for p in _DOCS)
def _origin_ok(origin: str, headers: dict[str, str], scope: dict[str, Any]) -> bool:
from app.core.public_url import origin_of, public_base_url
allowed = {o.rstrip("/") for o in settings.cors_origins}
if settings.public_url:
parts = urlsplit(settings.public_url)
allowed.add(f"{parts.scheme}://{parts.netloc}")
# 对外访问地址(环境变量或设置里的那一项,core/public_url.py):反向代理后面请求的 Host 不一定是它
if address := public_base_url():
allowed.add(origin_of(address))
host = headers.get("x-forwarded-host") or headers.get("host")
if host:
scheme = headers.get("x-forwarded-proto") or scope.get("scheme") or "http"
@@ -92,7 +102,7 @@ class AuthMiddleware:
await self.app(scope, receive, send)
return
from app.api.deps import reset_principal, set_principal
from app.auth import current_mode
from app.auth import current_mode, refusal
# 登录设置在启动时读进缓存(main.py 的 _platform_startup),改设置的接口改完就刷新;这里不查库
mode = current_mode()
@@ -130,7 +140,11 @@ class AuthMiddleware:
return
if (scope["type"] == "http" and is_api and not principal.username and path not in _PUBLIC
and not path.startswith(_HOOKS)):
await _reply(send, 401, "请先登录", "auth_required")
await _reply(send, 401, "请先登录", AUTH_REQUIRED)
return
if scope["type"] == "http" and _is_docs(path) and (refused := refusal(principal, "builder")) is not None:
# 和路由级依赖(auth.require)同一个判断、同一句话:没登录 401,角色不够 403
await _reply(send, refused.status_code, refused.detail, refused.code)
return
token = set_principal(principal)
+48 -4
View File
@@ -1,9 +1,10 @@
from __future__ import annotations
import ipaddress
import re
from functools import lru_cache
from pathlib import Path
from typing import Annotated
from typing import Annotated, Any
from pydantic import field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
@@ -11,6 +12,9 @@ from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
BACKEND_DIR = Path(__file__).resolve().parents[2]
PROJECT_DIR = BACKEND_DIR.parent
#: 嵌入白名单里一项的主机部分:域名(可以 *. 开头)或方括号里的 IPv6,可以带端口;不许路径、查询、用户名
_ORIGIN_HOST = re.compile(r"(?:\*\.)?(?:[A-Za-z0-9-]+\.)*[A-Za-z0-9-]+(?::\d{1,5})?|\[[0-9A-Fa-f:.]+\](?::\d{1,5})?")
# session_id 拼进路径前必须过这一关。允许的字符刻意收得很窄:字母数字加
# 连字符下划线——点号也不放行,免得出现 ".." 这种能往上走的名字。
_SESSION_MAX = 64
@@ -60,10 +64,18 @@ class Settings(BaseSettings):
auth_session_max_days: int = 7
# 会话 cookie 带不带 Secure。不设时按请求本身是不是 https 定:本机 http 开发照常能登录
auth_cookie_secure: bool | None = None
# 对外的访问地址(反向代理后面时填,如 https://agentlab.example.com)。OIDC 的回调地址按它拼;
# 不设时按请求的 Host 拼
# 对外的访问地址(反向代理后面时填,如 https://agentlab.example.com)。全站只认一个(core/public_url.py):
# 设了它,设置里的「对外访问地址」就不生效、不能改;通知和 Webhook 的链接、OIDC 的回调、来源核对都按它拼。
# 不设时用设置里的那一项,再没有就按请求的 Host 拼(通知里不附链接)
public_url: str = ""
# --- 安全响应头(app/web_security.py)---
# 允许哪些站点用 iframe 嵌整站(同站的门户,如 https://portal.example.com),逗号分隔。本站自己总是允许的。
# 不设时只有本站能嵌(frame-ancestors 'self',另加 X-Frame-Options: SAMEORIGIN)
frame_ancestors: Annotated[list[str], NoDecode] = []
# 分享页(/s)允许被哪些站点嵌,逗号分隔,写法同上。分享页只认这一项,不认上面那一项
embed_origins: Annotated[list[str], NoDecode] = []
# --- 沙箱 ---
sandbox_backend: str = "auto" # auto | microvm | seatbelt | bubblewrap | local | off
sandbox_timeout: int = 30
@@ -121,7 +133,8 @@ class Settings(BaseSettings):
"https://223.5.5.5/resolve",
]
@field_validator("http_tool_allowlist", "http_tool_fake_ip_ranges", "http_tool_doh_urls", mode="before")
@field_validator("http_tool_allowlist", "http_tool_fake_ip_ranges", "http_tool_doh_urls", "frame_ancestors",
"embed_origins", mode="before")
@classmethod
def _comma_list(cls, value: object) -> object:
# 环境变量里写逗号分隔;也兼容 JSON 数组的写法
@@ -134,6 +147,37 @@ class Settings(BaseSettings):
return [part.strip() for part in text.split(",") if part.strip()]
return value
@field_validator("public_url")
@classmethod
def _valid_public_url(cls, value: str) -> str:
# 写错了启动时就报出来:以前拼出 agentlab.example.com/api/auth/oidc/callback 这样的回调,到登录时才发现
from app.core.public_url import ENV_NAME, normalize, url_problem
if not value.strip():
return ""
if problem := url_problem(value):
what = "不能带 ? 或 # 之后的部分" if problem == "suffix" else "需要是 http:// 或 https:// 开头的完整地址"
raise ValueError(f"{ENV_NAME}={value!r} {what},例如 https://agentlab.example.com")
return normalize(value) or ""
@field_validator("frame_ancestors", "embed_origins")
@classmethod
def _valid_origins(cls, value: list[str], info: Any) -> list[str]:
# 只收「协议://主机[:端口]」,主机可以写 *.example.com。路径、裸 *、没写协议的写错了启动时就报出来,别到嵌入
# 失败(或者整站被谁都能嵌)才发现。'self' 本来就有,写了也不重复
name = f"AGENTLAB_{(info.field_name or '').upper()}"
out: list[str] = []
for raw in value:
item = raw.strip().rstrip("/")
if item == "'self'":
continue
scheme, _, host = item.partition("://")
if scheme.lower() not in ("http", "https") or not _ORIGIN_HOST.fullmatch(host):
raise ValueError(f"{name} 里的「{raw}」需要写成「协议://主机」(可以带端口),例如 https://portal.example.com")
if (origin := f"{scheme.lower()}://{host.lower()}") not in out:
out.append(origin)
return out
@field_validator("auth_cookie_secure", mode="before")
@classmethod
def _blank_is_auto(cls, value: object) -> object:
+97
View File
@@ -0,0 +1,97 @@
"""对外访问地址:别人打开 AgentLab 用的那个地址(https://agentlab.example.com),全站只有这一个说法(04 细案 B4)。
用到它的地方:通知和定时任务 Webhook 里的链接、单点登录的回调地址、非 GET 请求的来源核对(auth/middleware.py);
之后的分享链接、接口说明也从这里取。服务端不知道自己在反向代理后面叫什么,只能由人告诉它。
取值(public_base_url):
- 环境变量 AGENTLAB_PUBLIC_URL 优先:部署时定死,设置页上显示「由环境变量指定」、不能改(同登录方式的 env_locked);
- 没设时取设置里 notify 一组的 public_base_url:沿用这个键,界面上叫「对外访问地址」,在「设置 → 通用」里改;
- 都没有是 None:通知里不附链接,单点登录的回调和来源核对按请求自己的 Host 认。
以前两处各管各的:环境变量只管回调和来源核对、设置只管通知里的链接——只配了环境变量的部署,通知里不带链接;只在设置里
填了的,回调又按请求的 Host 拼。违背「一种说法」。
设置的值缓存在进程里:中间件按请求核对来源,不能每次查库。启动时读一次(main._platform_startup),改设置的接口写完库就
remember;手里本来就有会话的异步路径(通知、定时任务 Webhook)用 refresh 取,顺手按库里的值对齐缓存。和登录设置的缓存一样
以单进程部署为前提。
这个模块不在顶层导入 core/config:config 的校验器要用这里的 url_problem,顶层互相导入会在启动时绕成环。
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlsplit
#: 设置里存它的那一组、那一项(api/settings.py 的 NOTIFY_GROUP 就是这一组)
SETTING_GROUP = "notify"
SETTING_KEY = "public_base_url"
#: 环境变量的名字(core/config.Settings.public_url 按 AGENTLAB_ 前缀读它)
ENV_NAME = "AGENTLAB_PUBLIC_URL"
_stored: str | None = None
def url_problem(value: str) -> str | None:
"""地址的形状有什么问题:None 是没问题;"form" 不是 http:// 或 https:// 开头的完整地址;"suffix" 带了 ? 或 #
(链接要接在地址后面)。设置接口按它出中文的说法,环境变量按它在启动时报错。可以带路径(反向代理挂在子路径下)。"""
text = value.strip()
try:
parts = urlsplit(text)
except ValueError:
return "form"
if parts.scheme not in ("http", "https") or not parts.hostname or " " in text:
return "form"
if parts.query or parts.fragment:
return "suffix"
return None
def normalize(value: Any) -> str | None:
"""存着的(或刚填的)值 → 去掉首尾空白和末尾的 /;空的、形状不对的当没填(None)。末尾带 / 的地址拼链接会多出一个斜杠。"""
if not isinstance(value, str) or not value.strip() or url_problem(value):
return None
return value.strip().rstrip("/")
def env_url() -> str | None:
"""环境变量给的地址;没设为 None。形状在启动时由 config 的校验器查过、末尾的 / 去掉了。"""
from app.core.config import settings
return settings.public_url or None
def env_locked() -> bool:
"""环境变量设了:设置里的那一项不生效、不能改。"""
return env_url() is not None
def public_base_url() -> str | None:
"""对外访问地址:环境变量优先,其次设置(进程内缓存),都没有是 None。不查库,中间件里也能调。"""
return env_url() or _stored
def remember(value: Any) -> None:
"""改设置的接口写完库之后调:缓存换成新存的值(形状不对、空的当没填)。"""
global _stored
_stored = normalize(value)
async def refresh(session: Any = None) -> str | None:
"""按库里存的设置对齐缓存,交回对外访问地址。启动时、手里有会话的异步路径用。"""
from app.db.base import SessionLocal
from app.db.models import Setting
if session is None:
async with SessionLocal() as own:
row = await own.get(Setting, SETTING_GROUP)
else:
row = await session.get(Setting, SETTING_GROUP)
value = row.value if row is not None else None
remember(value.get(SETTING_KEY) if isinstance(value, dict) else None)
return public_base_url()
def origin_of(url: str) -> str:
"""地址里的「协议://主机[:端口]」:来源核对按它比。"""
parts = urlsplit(url)
return f"{parts.scheme}://{parts.netloc}"
+10 -3
View File
@@ -30,6 +30,7 @@ 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
from app.web_security import SecurityHeaders
logging.basicConfig(
level=logging.INFO,
@@ -145,16 +146,20 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]:
async def _platform_startup() -> None:
"""应用平台的启动整理(BE-1):登录设置读进缓存;自定义工具请求头、MCP env 和请求头里的存量明文加密一遍(幂等);
存量的内网 MCP 服务改为绑定登记端点(只在升级后第一次启动时做);能力登记同步一次。后三样出错只记日志:最坏是登记
暂时不全,不该让服务起不来。"""
"""应用平台的启动整理(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.core import public_url
from app.db.base import SessionLocal
await load_settings(force=True)
logger.info("登录方式:%s", current_mode())
# 对外访问地址(core/public_url.py)同样读进缓存:中间件核对来源时不查库
if address := await public_url.refresh():
logger.info("对外访问地址:%s(%s)", address, "环境变量" if public_url.env_locked() else "设置")
try:
if sealed := await seal_existing():
logger.info("已加密 %d 处存量的明文密钥(自定义工具请求头、MCP 环境变量和请求头)", sealed)
@@ -192,6 +197,8 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
# 安全响应头(app/web_security.py):最后加、在最外层,CORS 预检和登录检查自己回的 401 / 403 也带上
app.add_middleware(SecurityHeaders)
# 路由级权限(§3.12)。关登录时这些依赖全部放行;开了登录,中间件先挡掉没登录的,这里再按角色和运行挡
_BUILDER = [require_role("builder")]
+4 -3
View File
@@ -7,7 +7,7 @@
同一个渠道只推一次。待办改派、升级、催办都是「有一条待办要你处理」,订阅了 task.opened 的渠道一并收到。
消息里只放标题、状态、链接(P4,沿用定时任务 Webhook 的规矩):不放报告里的数字,待办的标题用节点在图上的名字
(搭图时起的),不用按运行数据渲染出来的那一句。链接要设了对外访问地址(设置里的 notify)才有。
(搭图时起的),不用按运行数据渲染出来的那一句。链接要有对外访问地址(环境变量或设置,core/public_url.py)才有。
推送放在后台(spawn):接收端挂住时超时加重试要几十秒,不能拖着开待办、运行收尾。测试用 drain 等它们推完。
@@ -153,9 +153,10 @@ def generic_body(message: dict[str, Any], msg: Message) -> dict[str, Any]:
async def _base_url(session: Any) -> str | None:
from app.api.settings import notify_settings
"""拼链接用的对外访问地址:全站同一个(环境变量优先,其次设置),手里有会话就按库里的值对齐缓存。"""
from app.core import public_url
return (await notify_settings(session))["public_base_url"]
return await public_url.refresh(session)
async def _channels(session: Any, event: str, run: Run | None, users: list[str]) -> list[NotifyChannel]:
+3 -2
View File
@@ -424,9 +424,10 @@ async def notify_due(now: datetime | None = None) -> int:
.order_by(ScheduleFire.created_at)
)).all()
if rows:
from app.api.settings import notify_settings
# 链接里的地址:全站同一个对外访问地址(环境变量优先,其次设置,core/public_url.py)
from app.core import public_url
base_url = (await notify_settings(session))["public_base_url"]
base_url = await public_url.refresh(session)
for fire, run in rows:
seen = await _state_of(session, fire, run, now)
if seen.state is None or seen.state == fire.notified_status:
+10 -1
View File
@@ -9,7 +9,9 @@
- /api 下不存在的路径照旧是 JSON 404。排除 /api 写在路由的路径规则里,不在处理函数里判断——
否则 POST /api/不存在 会因为「路径对上了、方法不对」变成 405;
- 只注册 GET / HEAD 的 HTTP 路由,WebSocket 不经过这里;
- index.html 不缓存,发版后刷新即生效;assets/ 下的文件名带内容哈希,长缓存。
- index.html 不缓存,发版后刷新即生效;assets/ 下的文件名带内容哈希,长缓存;
- index.html 另带严格 script-src 的上报策略(Content-Security-Policy-Report-Only):启动时按构建产物里的内联脚本算
sha256(web_security.script_policy),只上报、不拦。其余安全头由 web_security 的中间件统一补。
"""
from __future__ import annotations
@@ -20,6 +22,8 @@ from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from starlette.convertors import Convertor, register_url_convertor
from app.web_security import REPORT_ONLY_HEADER, script_policy
_NO_CACHE = "no-cache"
_IMMUTABLE = "public, max-age=31536000, immutable"
@@ -72,7 +76,12 @@ def mount_web(app: FastAPI, dist: Path) -> None:
"先构建前端(cd frontend && pnpm build),或者去掉这个设置"
)
# 哈希按启动这一刻的 index.html 算:换了构建产物要重启服务,哈希才跟着换(只上报不拦,对不上也只多几条上报)
page_headers = {"Cache-Control": _NO_CACHE, REPORT_ONLY_HEADER: script_policy(index.read_text(encoding="utf-8"))}
def respond(target: Path, *, immutable: bool = False) -> FileResponse:
if target in (index, index.resolve()):
return FileResponse(target, media_type=_media_type(target), headers=page_headers)
return FileResponse(
target,
media_type=_media_type(target),
+90
View File
@@ -0,0 +1,90 @@
"""安全响应头(04 细案 C1):页面不能被别的站点套进 iframe 诱导点击,浏览器不按内容猜类型,链接不把完整地址带给别的站点。
以前服务端一个安全头都不发:任何站点都能把 AgentLab 嵌进 iframe。同站的门户页面(SameSite=Lax 的会话 cookie 会随请求
带上)和关登录的部署,都有被点击劫持的可能;也没有一条「允许谁嵌」的白名单可配。
纯 ASGI 中间件(同 auth/middleware.py,main.py 里加在最外层):在 http.response.start 上补头,WebSocket 不经过它。
所有 HTTP 响应(页面、/api、登录检查自己回的 401 / 403、CORS 预检)都补:
- X-Content-Type-Options: nosniff;
- Referrer-Policy: strict-origin-when-cross-origin(分享页 /s 用 no-referrer:分享的 key 在地址的 # 后面,本来不随
Referer 走,这里再收一道);
- Content-Security-Policy: frame-ancestors。缺省只有 'self';AGENTLAB_FRAME_ANCESTORS 列出允许嵌整站的同站门户;分享页
/s 只认 AGENTLAB_EMBED_ORIGINS(分享链接第 3 波才加,路由在那时挂,头的规则先在这里定好);
- 只有 frame-ancestors 是 'self' 时另加 X-Frame-Options: SAMEORIGIN,给不认 CSP 的老浏览器。XFO 写不了白名单,有白名单
时只发 CSP:两个都发的话,只认 XFO 的老浏览器会把白名单里的门户也拦掉(认 CSP 的浏览器以 frame-ancestors 为准)。
接口自己设过的同名头(文件下载的 nosniff)不覆盖、不重复。
严格的 script-src 先只上报、不拦(Content-Security-Policy-Report-Only):页面里有一段首帧前定主题的内联脚本,强制执行
的话写错一个哈希页面就会先闪一帧深色,构建会不会产出别的内联内容也没核实。所以 mount_web(app/web.py)启动时给构建产物
index.html 里的内联脚本算 sha256(script_policy),只在页面这一个响应上发;观察一个版本没有误报,再改成强制执行。没配
上报地址:违规只在浏览器的控制台里看得到。接口返回的 HTML(导出的报告、可验证报告)文件里自带 meta CSP,不发它,也不动。
HSTS 交给终结 https 的反向代理:这里不知道对外是不是 https、要不要连子域名一起管,发错了浏览器会按 max-age 记很久,
撤不回来。
"""
from __future__ import annotations
import base64
import hashlib
import re
from typing import Any
#: 分享页的路径(/s#<分享 key>):只认 /s 这一段,/settings、/studio 不算
SHARE_PATH = "/s"
_SELF = "'self'"
_DEFAULT_REFERRER = b"strict-origin-when-cross-origin"
_SHARE_REFERRER = b"no-referrer"
#: 页面里不带 src 的 <script>:要算哈希的内联脚本
_INLINE_SCRIPT = re.compile(r"<script\b(?![^>]*\bsrc\s*=)[^>]*>(.*?)</script\s*>", re.IGNORECASE | re.DOTALL)
REPORT_ONLY_HEADER = "Content-Security-Policy-Report-Only"
def is_share_path(path: str) -> bool:
return path == SHARE_PATH or path.startswith(SHARE_PATH + "/")
def frame_policy(origins: list[str]) -> tuple[bytes, bool]:
"""(CSP 的值, 要不要另加 X-Frame-Options)。origins 是白名单(config 里校验过、去掉了 'self')。"""
sources = [_SELF, *[o for o in origins if o != _SELF]]
return f"frame-ancestors {' '.join(sources)}".encode(), len(sources) == 1
def script_policy(html: str) -> str:
"""页面的严格 script-src:本站的脚本文件('self')加上页面里每段内联脚本的 sha256(按原文的 UTF-8 字节算,同浏览器)。"""
hashes = [base64.b64encode(hashlib.sha256(body.encode()).digest()).decode()
for body in _INLINE_SCRIPT.findall(html)]
return " ".join(["script-src", _SELF, *(f"'sha256-{h}'" for h in dict.fromkeys(hashes))])
class SecurityHeaders:
"""frame_ancestors / embed_origins 不传时取 core/config 的 AGENTLAB_FRAME_ANCESTORS、AGENTLAB_EMBED_ORIGINS(测试传别的)。"""
def __init__(self, app: Any, *, frame_ancestors: list[str] | None = None,
embed_origins: list[str] | None = None) -> None:
from app.core.config import settings
self.app = app
self.site = frame_policy(settings.frame_ancestors if frame_ancestors is None else frame_ancestors)
self.share = frame_policy(settings.embed_origins if embed_origins is None else embed_origins)
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
share = is_share_path(scope.get("path") or "")
frame, xfo = self.share if share else self.site
wanted = [(b"x-content-type-options", b"nosniff"),
(b"referrer-policy", _SHARE_REFERRER if share else _DEFAULT_REFERRER),
(b"content-security-policy", frame)]
if xfo:
wanted.append((b"x-frame-options", b"SAMEORIGIN"))
async def send_with_headers(message: dict[str, Any]) -> None:
if message["type"] == "http.response.start":
headers = list(message.get("headers") or [])
present = {name.lower() for name, _ in headers}
headers += [(name, value) for name, value in wanted if name not in present]
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_with_headers)
+169
View File
@@ -0,0 +1,169 @@
"""集成接口的错误码与 /docs(04 细案 A7)。
外部系统按 code 认是哪种错,不按中文句子:应用(api/apps.py)、待办(api/tasks.py)、工件(api/artifacts.py)的错误,
和角色、运行读权限的拒绝(api/deps.py、auth.require)全部带 code;detail 还是原来那一句,老前端照常显示。
开了登录,/docs、/redoc、/openapi.json 和 /api 一样要登录、要搭建者:以前中间件只拦 /api/ 开头的路径,接口全貌对
匿名的人敞开。关登录照旧(tests/test_provenance_contract.py 的断言不受影响)。
"""
from __future__ import annotations
import ast
import uuid
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from app.api import coded
from app.db.base import SessionLocal
from app.db.models_apps import App
from app.main import app as main_app
from tests.fakes import auth_env
APP_DIR = Path(__file__).resolve().parents[1] / "app"
DOCS = ("/docs", "/redoc", "/openapi.json")
@pytest.fixture(autouse=True)
async def clean():
await auth_env.reset()
try:
yield
finally:
await auth_env.reset()
def client() -> AsyncClient:
return AsyncClient(transport=ASGITransport(app=main_app), base_url="http://test")
async def new_app() -> str:
async with SessionLocal() as session:
row = App(name=f"报销-{uuid.uuid4().hex[:6]}")
session.add(row)
await session.commit()
return row.id
def _called(node: ast.AST, name: str) -> bool:
return isinstance(node, ast.Call) and (getattr(node.func, "id", None) or getattr(node.func, "attr", None)) == name
def test_no_error_without_a_code_left_in_the_integration_modules():
"""这几个模块里不许再直接抛 HTTPException(没有 code),一律 CodedHTTPException;直接回 JSONResponse 的错误(表单
没通过校验那几处)内容里也要有 code。"""
for rel in ("api/apps.py", "api/tasks.py", "api/artifacts.py", "api/deps.py", "auth/__init__.py"):
tree = ast.parse((APP_DIR / rel).read_text())
bare = [node.lineno for node in ast.walk(tree) if _called(node, "HTTPException")]
assert not bare, f"{rel} 第 {bare} 行抛的 HTTPException 没有 code"
for node in ast.walk(tree):
if not _called(node, "JSONResponse"):
continue
content = next((k.value for k in node.keywords if k.arg == "content"), node.args[0] if node.args else None)
keys = {k.value for k in getattr(content, "keys", []) if isinstance(k, ast.Constant)}
assert "code" in keys, f"{rel} 第 {node.lineno} 行回的 JSONResponse 没有 code"
def test_codes_agree_with_the_ones_already_in_use():
from app.apps import entries
assert coded.APP_NOT_FOUND == entries.APP_GONE, "入口发起时的「应用不存在」和这里同一个码"
async def test_app_errors_carry_codes_and_keep_their_detail():
app_id = await new_app()
async with client() as c:
r = await c.get("/api/apps/nope-nope")
assert (r.status_code, r.json()) == (404, {"detail": "应用不存在,可能已被删除", "code": "app_not_found"})
r = await c.get(f"/api/apps/{app_id}/versions/9")
assert r.status_code == 404 and r.json()["code"] == "app_version_not_found" and "没有 v9" in r.json()["detail"]
r = await c.get(f"/api/apps/{app_id}/pages/home", params={"params": "[1, 2]", "draft": 1})
assert (r.status_code, r.json()) == (422, {"detail": "页面参数需要是 JSON 对象", "code": "app_params_invalid"})
r = await c.patch(f"/api/apps/{app_id}", json={"draft_bindings": {"slots": {"财务": "不是列表"}}})
assert r.status_code == 422 and r.json()["code"] == "app_bindings_invalid", r.text
r = await c.post(f"/api/apps/{app_id}/hooks/nope/rotate")
assert r.status_code == 404 and r.json()["code"] == "app_hook_not_declared"
r = await c.patch(f"/api/apps/{app_id}/hooks/nope", json={"enabled": False})
assert r.status_code == 404 and r.json()["code"] == "app_hook_not_found", r.text
r = await c.post("/api/apps/derive", json={"workflow_id": "nope-nope", "version": 1})
assert (r.status_code, r.json()["code"]) == (404, "workflow_not_found")
async def test_task_errors_carry_codes():
async with client() as c:
r = await c.get("/api/tasks/nope-nope")
assert (r.status_code, r.json()) == (404, {"detail": "待办不存在,所属的运行可能已被删除", "code": "task_not_found"})
# 服务层没带码的拒绝(这里是待办不存在)按状态补上同一类的码
r = await c.post("/api/tasks/nope-nope/respond", json={"decision": "approve"})
assert (r.status_code, r.json()["code"]) == (404, "task_not_found")
r = await c.post("/api/tasks/nope-nope/remind")
assert (r.status_code, r.json()["code"]) == (404, "task_not_found")
async def test_artifact_errors_carry_codes():
async with client() as c:
r = await c.get(f"/api/artifacts/{'0' * 64}")
assert (r.status_code, r.json()) == (404, {"detail": "工件不存在,可能已被删除", "code": "artifact_not_found"})
async def test_role_and_run_refusals_carry_codes():
await auth_env.turn_on_local()
await auth_env.make_user("uma", "user")
uma = await auth_env.login("uma")
r = await uma.get("/api/workflows")
assert r.status_code == 403 and r.json() == {"detail": "没有权限:这项操作需要搭建者及以上的角色",
"code": "role_required"}
r = await uma.get("/api/tasks", params={"assigned": "all"})
assert r.status_code == 403 and r.json()["code"] == "role_required"
r = await uma.get("/api/runs/nope-nope/evidence")
assert r.status_code == 403 and r.json() == {"detail": "没有权限查看这次运行", "code": "run_forbidden"}
r = await uma.get(f"/api/artifacts/{'0' * 64}")
assert r.status_code == 403 and r.json() == {"detail": "没有权限查看这件工件", "code": "artifact_forbidden"}
r = await uma.get("/api/runs")
assert r.status_code == 403 and r.json()["code"] == "role_required"
async def test_ensure_role_inside_an_endpoint_carries_codes():
from app.api.deps import Principal, ensure_role, reset_principal, set_principal
for principal, status, code in ((Principal(username=None, role="", mode="local"), 401, "auth_required"),
(Principal(username="bob", role="builder", verified=True, mode="local"), 403,
"role_required")):
token = set_principal(principal)
try:
with pytest.raises(coded.CodedHTTPException) as e:
ensure_role("admin")
finally:
reset_principal(token)
assert (e.value.status_code, e.value.code) == (status, code)
assert "管理员" in e.value.detail
# ---------------------------------------------------------------- /docs
async def test_docs_stay_open_when_login_is_off():
async with client() as c:
for path in DOCS:
assert (await c.get(path)).status_code == 200, path
async def test_docs_need_a_builder_once_login_is_on():
await auth_env.turn_on_local()
await auth_env.make_user("uma", "user")
await auth_env.make_user("bob", "builder")
async with client() as anonymous:
for path in DOCS:
r = await anonymous.get(path)
assert r.status_code == 401 and r.json()["code"] == "auth_required", path
# 子路径(Swagger 的 OAuth 回跳页)一样
assert (await anonymous.get("/docs/oauth2-redirect")).status_code == 401
uma = await auth_env.login("uma")
for path in DOCS:
r = await uma.get(path)
assert r.status_code == 403 and r.json()["code"] == "role_required", path
bob = await auth_env.login("bob")
for path in DOCS:
assert (await bob.get(path)).status_code == 200, path
assert "/api/apps/{app_id}" in (await bob.get("/openapi.json")).json()["paths"]
+169
View File
@@ -0,0 +1,169 @@
"""对外访问地址只有一个说法(04 细案 B4,core/public_url.py)。
以前有两个:环境变量 AGENTLAB_PUBLIC_URL 只管单点登录的回调和来源核对,设置里 notify 一组的 public_base_url 只管通知里的
链接——只配了环境变量的部署,通知里不带链接。现在全站经 public_base_url() 取:
- 环境变量优先,其次设置,都没有是 None;
- 设置的值缓存在进程里(中间件按请求核对来源,不能每次查库),改设置的接口写完就刷新,不用重启;
- 环境变量设了:设置页显示「由环境变量指定」(GET /api/settings 的 public_url 一组),改成别的值回 409、带 code;
- 通知、定时任务 Webhook 的链接,单点登录的回调地址,非 GET 请求的 Origin 核对都用它。
"""
from __future__ import annotations
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete
from app.api.settings import NOTIFY_GROUP
from app.core import public_url
from app.core.config import Settings, settings
from app.db.base import SessionLocal
from app.db.models import Setting
from app.main import app as main_app
from tests.fakes import auth_env
ENV_URL = "https://agentlab.example.com"
SET_URL = "https://intra.example.net/agentlab"
@pytest.fixture(autouse=True)
async def clean(monkeypatch):
monkeypatch.setattr(settings, "public_url", "")
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key == NOTIFY_GROUP))
await session.commit()
public_url.remember(None)
yield
async with SessionLocal() as session:
await session.execute(delete(Setting).where(Setting.key == NOTIFY_GROUP))
await session.commit()
public_url.remember(None)
def client() -> AsyncClient:
return AsyncClient(transport=ASGITransport(app=main_app), base_url="http://test")
async def put_address(c: AsyncClient, value: str | None):
return await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": value}}})
# ---------------------------------------------------------------- 取值的先后
def test_env_wins_over_the_setting(monkeypatch):
public_url.remember(SET_URL)
assert public_url.public_base_url() == SET_URL and not public_url.env_locked()
monkeypatch.setattr(settings, "public_url", ENV_URL)
assert public_url.public_base_url() == ENV_URL and public_url.env_locked()
public_url.remember(None)
assert public_url.public_base_url() == ENV_URL, "设置清空了也还是环境变量的"
monkeypatch.setattr(settings, "public_url", "")
assert public_url.public_base_url() is None, "两处都没有:None(通知里不附链接,回调按请求的 Host 拼)"
def test_remember_normalizes_and_drops_broken_values():
public_url.remember(" https://agentlab.example.com/ ")
assert public_url.public_base_url() == "https://agentlab.example.com"
for broken in ("agentlab.example.com", "ftp://files.example.com", "https://a.example.com/?x=1", 8080, ""):
public_url.remember(broken)
assert public_url.public_base_url() is None, broken
def test_env_value_is_normalized_and_checked_at_startup(monkeypatch):
monkeypatch.setenv("AGENTLAB_PUBLIC_URL", " https://agentlab.example.com/ ")
assert Settings().public_url == "https://agentlab.example.com"
monkeypatch.setenv("AGENTLAB_PUBLIC_URL", "")
assert Settings().public_url == ""
# 写错了启动时就报出来:以前拼出 agentlab.example.com/api/auth/oidc/callback 这样的回调,到登录时才发现
for broken in ("agentlab.example.com", "https://agentlab.example.com/?from=env"):
monkeypatch.setenv("AGENTLAB_PUBLIC_URL", broken)
with pytest.raises(ValueError, match="AGENTLAB_PUBLIC_URL"):
Settings()
async def test_refresh_reads_the_stored_setting():
async with SessionLocal() as session:
session.add(Setting(key=NOTIFY_GROUP, value={"public_base_url": SET_URL + "/"}))
await session.commit()
assert public_url.public_base_url() is None, "缓存还没对齐"
assert await public_url.refresh() == SET_URL
assert public_url.public_base_url() == SET_URL
# ---------------------------------------------------------------- 设置接口
async def test_saving_the_setting_refreshes_the_cache_without_restart():
async with client() as c:
assert (await put_address(c, SET_URL + "/")).status_code == 200
assert public_url.public_base_url() == SET_URL
body = (await c.get("/api/settings")).json()
assert body["public_url"] == {"value": SET_URL, "env_locked": False}
assert (await put_address(c, None)).status_code == 200
assert public_url.public_base_url() is None
async def test_env_locks_the_setting(monkeypatch):
monkeypatch.setattr(settings, "public_url", ENV_URL)
async with client() as c:
body = (await c.get("/api/settings")).json()
assert body["public_url"] == {"value": ENV_URL, "env_locked": True}
assert body[NOTIFY_GROUP] == {"public_base_url": None}, "notify 一组照旧是库里存的那一份"
r = await put_address(c, SET_URL)
assert r.status_code == 409, r.text
assert r.json()["code"] == "public_url_env_locked" and r.json()["field"] == "notify.public_base_url"
assert "AGENTLAB_PUBLIC_URL" in r.json()["detail"]
# 没改这一项(整组原样写回)不算改:不挡同一请求里别的设置
assert (await put_address(c, None)).status_code == 200
# 只读的 public_url 一组写不进库
r = await c.put("/api/settings", json={"values": {"public_url": {"value": SET_URL, "env_locked": False}}})
assert r.status_code == 200
assert (await c.get("/api/settings")).json()["public_url"] == {"value": ENV_URL, "env_locked": True}
async with SessionLocal() as session:
assert await session.get(Setting, "public_url") is None
# ---------------------------------------------------------------- 用到它的地方
async def test_notifications_link_with_the_env_address_alone(monkeypatch):
"""只配了环境变量的部署,通知里也带链接(以前只认设置里的那一项)。"""
from app.notify import service
monkeypatch.setattr(settings, "public_url", ENV_URL)
async with SessionLocal() as session:
assert await service._base_url(session) == ENV_URL
monkeypatch.setattr(settings, "public_url", "")
async with SessionLocal() as session:
session.add(Setting(key=NOTIFY_GROUP, value={"public_base_url": SET_URL}))
await session.commit()
async with SessionLocal() as session:
assert await service._base_url(session) == SET_URL
async def test_oidc_callback_uses_the_one_address(monkeypatch):
from starlette.requests import Request
from app.api.auth import _redirect_uri
request = Request({"type": "http", "method": "GET", "path": "/api/auth/oidc/start", "headers": [],
"scheme": "http", "server": ("10.0.0.5", 8000), "query_string": b""})
assert _redirect_uri(request) == "http://10.0.0.5:8000/api/auth/oidc/callback", "都没有:按请求的地址拼"
public_url.remember(SET_URL)
assert _redirect_uri(request) == f"{SET_URL}/api/auth/oidc/callback", "只在设置里填了的也按它拼"
monkeypatch.setattr(settings, "public_url", ENV_URL)
assert _redirect_uri(request) == f"{ENV_URL}/api/auth/oidc/callback"
async def test_origin_check_accepts_the_one_address():
await auth_env.reset()
try:
c = await auth_env.turn_on_local()
r = await c.post("/api/groups", json={"name": "ops"}, headers={"Origin": "https://intra.example.net"})
assert r.status_code == 403 and r.json()["code"] == "csrf_origin"
public_url.remember(SET_URL)
r = await c.post("/api/groups", json={"name": "ops"}, headers={"Origin": "https://intra.example.net"})
assert r.status_code == 201, r.text
finally:
await auth_env.reset()
+235
View File
@@ -0,0 +1,235 @@
"""安全响应头(04 细案 C1):app/web_security.py 的纯 ASGI 中间件,加上 mount_web 给页面算内联脚本的哈希。
要守住的:
- 页面、/api 的响应(连同登录检查自己回的 401)都带 nosniff、Referrer-Policy 和 CSP frame-ancestors;
- frame-ancestors 缺省 'self',只有 'self' 时另加 X-Frame-Options: SAMEORIGIN;配了 AGENTLAB_FRAME_ANCESTORS 白名单时
以 CSP 为准、不发 XFO(XFO 写不了白名单,两个都发的话老浏览器按 XFO 把白名单里的门户也拦了);
- 分享页 /s 用 AGENTLAB_EMBED_ORIGINS、Referrer-Policy: no-referrer(路由第 3 波才加,头的规则先守住);
- 严格的 script-src 只发 Content-Security-Policy-Report-Only,只在页面(index.html)上发,哈希就是构建产物里内联
脚本的 sha256;接口返回的 HTML(导出的报告自带 meta CSP)不带它;
- 接口自己设过的同名头不覆盖、不重复;WebSocket 不经过它;
- 白名单写错了启动时就报错。
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
import subprocess
import sys
from pathlib import Path
import httpx
import pytest
from fastapi import APIRouter, FastAPI, WebSocket
from fastapi.responses import HTMLResponse, Response
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.core.config import BACKEND_DIR, PROJECT_DIR, Settings
from app.main import app as main_app
from app.web_security import SecurityHeaders
from tests.fakes import auth_env
THEME = "\n try { document.documentElement.setAttribute('data-theme', 'light') } catch (e) {}\n "
INDEX = ("<!doctype html><html><head><script>" + THEME + "</script>"
"<script type=\"module\" crossorigin src=\"/assets/index-AbC123.js\"></script></head>"
"<body><div id=root></div></body></html>")
HASH = base64.b64encode(hashlib.sha256(THEME.encode()).digest()).decode()
REPORT = ("<!doctype html><meta http-equiv=\"Content-Security-Policy\" content=\"default-src 'none'\">"
"<script>console.log('report')</script>")
@pytest.fixture
def dist(tmp_path: Path) -> Path:
root = tmp_path / "dist"
(root / "assets").mkdir(parents=True)
(root / "index.html").write_text(INDEX)
(root / "assets" / "index-AbC123.js").write_text("console.log('bundle')")
return root
def _app(dist: Path | None = None, **config) -> FastAPI:
app = FastAPI()
api = APIRouter(prefix="/api")
@api.get("/ping")
async def ping() -> dict[str, str]:
return {"pong": "1"}
@api.get("/report.html")
async def report() -> HTMLResponse:
return HTMLResponse(REPORT)
@api.get("/download")
async def download() -> Response:
# 文件下载自己设了 nosniff(api/files.py):不能再加一份
return Response(b"x", headers={"X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer"})
@api.websocket("/echo")
async def echo(ws: WebSocket) -> None:
await ws.accept()
await ws.send_text(await ws.receive_text())
await ws.close()
app.include_router(api)
if dist is not None:
from app.web import mount_web
mount_web(app, dist)
app.add_middleware(SecurityHeaders, **config)
return app
async def _get(app: FastAPI, path: str) -> httpx.Response:
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
return await client.get(path)
def _single(r: httpx.Response, name: str) -> str | None:
values = r.headers.get_list(name)
assert len(values) <= 1, f"{name} 发了 {len(values)} 份:{values}"
return values[0] if values else None
async def test_api_and_page_carry_the_defaults(dist: Path) -> None:
app = _app(dist, frame_ancestors=[], embed_origins=[])
for path in ("/api/ping", "/api/nope", "/", "/studio", "/assets/index-AbC123.js"):
r = await _get(app, path)
assert _single(r, "x-content-type-options") == "nosniff", path
assert _single(r, "referrer-policy") == "strict-origin-when-cross-origin", path
assert _single(r, "content-security-policy") == "frame-ancestors 'self'", path
assert _single(r, "x-frame-options") == "SAMEORIGIN", path
async def test_report_only_script_policy_is_only_on_the_page(dist: Path) -> None:
app = _app(dist, frame_ancestors=[], embed_origins=[])
for path in ("/", "/studio", "/runs/abc/evidence"):
r = await _get(app, path)
assert r.text == INDEX
assert _single(r, "content-security-policy-report-only") == f"script-src 'self' 'sha256-{HASH}'", path
# 脚本文件、接口不带;接口返回的 HTML(导出的报告)自带 meta CSP,不加一份对不上哈希的上报策略,正文原样
for path in ("/assets/index-AbC123.js", "/api/ping", "/api/report.html"):
r = await _get(app, path)
assert "content-security-policy-report-only" not in r.headers, path
r = await _get(app, "/api/report.html")
assert r.text == REPORT
async def test_whitelist_wins_and_drops_x_frame_options() -> None:
app = _app(frame_ancestors=["https://portal.example.com", "https://*.corp.example"], embed_origins=[])
r = await _get(app, "/api/ping")
assert _single(r, "content-security-policy") == \
"frame-ancestors 'self' https://portal.example.com https://*.corp.example"
assert "x-frame-options" not in r.headers, "有白名单时以 CSP 为准:XFO 写不了白名单"
async def test_share_page_uses_the_embed_origins(dist: Path) -> None:
app = _app(dist, frame_ancestors=["https://portal.example.com"], embed_origins=["https://bi.example.org"])
for path in ("/s", "/s/", "/s/anything"):
r = await _get(app, path)
assert _single(r, "content-security-policy") == "frame-ancestors 'self' https://bi.example.org", path
assert _single(r, "referrer-policy") == "no-referrer", path
assert "x-frame-options" not in r.headers, path
# 只认 /s 这一段:/settings、/studio 照整站的规则
for path in ("/settings", "/studio", "/share"):
r = await _get(app, path)
assert _single(r, "content-security-policy") == "frame-ancestors 'self' https://portal.example.com", path
assert _single(r, "referrer-policy") == "strict-origin-when-cross-origin", path
# 没配嵌入白名单:分享页只能被本站嵌,同样另加 XFO
r = await _get(_app(dist, frame_ancestors=[], embed_origins=[]), "/s")
assert _single(r, "content-security-policy") == "frame-ancestors 'self'"
assert _single(r, "x-frame-options") == "SAMEORIGIN"
async def test_headers_already_set_are_kept() -> None:
r = await _get(_app(frame_ancestors=[], embed_origins=[]), "/api/download")
assert _single(r, "x-content-type-options") == "nosniff"
assert _single(r, "referrer-policy") == "no-referrer"
assert _single(r, "content-security-policy") == "frame-ancestors 'self'"
def test_websocket_is_not_touched() -> None:
with TestClient(_app(frame_ancestors=[], embed_origins=[])) as client:
with client.websocket_connect("/api/echo") as ws:
ws.send_text("hi")
assert ws.receive_text() == "hi"
async def test_main_app_and_its_own_refusals_carry_the_headers() -> None:
"""真实的 app.main:加在最外层,登录检查自己回的 401、CORS 预检也带。"""
app = main_app
r = await _get(app, "/api/health")
assert _single(r, "content-security-policy") == "frame-ancestors 'self'"
assert _single(r, "x-frame-options") == "SAMEORIGIN" and _single(r, "x-content-type-options") == "nosniff"
await auth_env.reset()
try:
await auth_env.turn_on_local()
r = await _get(app, "/api/workflows")
assert r.status_code == 401 and r.json()["code"] == "auth_required"
assert _single(r, "x-content-type-options") == "nosniff"
assert _single(r, "content-security-policy") == "frame-ancestors 'self'"
finally:
await auth_env.reset()
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client:
r = await client.options("/api/health", headers={"Origin": "http://localhost:5273",
"Access-Control-Request-Method": "GET"})
assert r.status_code == 200 and _single(r, "x-content-type-options") == "nosniff"
def test_origin_lists_parse_and_bad_entries_fail_at_startup(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("AGENTLAB_FRAME_ANCESTORS", raising=False)
monkeypatch.delenv("AGENTLAB_EMBED_ORIGINS", raising=False)
assert Settings().frame_ancestors == [] and Settings().embed_origins == []
monkeypatch.setenv("AGENTLAB_FRAME_ANCESTORS", " https://portal.example.com/ , https://*.corp.example,'self'")
monkeypatch.setenv("AGENTLAB_EMBED_ORIGINS", '["http://bi.example.org:8080"]')
got = Settings()
assert got.frame_ancestors == ["https://portal.example.com", "https://*.corp.example"], "末尾的 / 去掉,'self' 本来就有"
assert got.embed_origins == ["http://bi.example.org:8080"]
monkeypatch.delenv("AGENTLAB_EMBED_ORIGINS")
# 只收「协议://主机[:端口]」:路径、通配一切、没写协议、'none' 这类写法启动时就报出来,别到嵌入失败才发现
for bad in ("*", "portal.example.com", "https://portal.example.com/app", "ftp://portal.example.com", "'none'",
"https://", "https://portal.example.com?x=1"):
monkeypatch.setenv("AGENTLAB_FRAME_ANCESTORS", bad)
with pytest.raises(ValidationError, match="AGENTLAB_FRAME_ANCESTORS"):
Settings()
def test_deploy_env_documents_both_lists() -> None:
text = (PROJECT_DIR / "deploy" / "agentlab.env").read_text()
assert "# AGENTLAB_FRAME_ANCESTORS=" in text and "# AGENTLAB_EMBED_ORIGINS=" in text
_PROBE = r"""
import asyncio, json, httpx
from app.main import app
async def main():
t = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=t, base_url="http://t") as c:
out = {}
for p in ("/", "/api/health"):
r = await c.get(p)
out[p] = {k: r.headers.get(k) for k in ("content-security-policy", "x-frame-options",
"content-security-policy-report-only")}
print(json.dumps(out))
asyncio.run(main())
"""
def test_main_app_reads_the_whitelist_and_hashes_the_page(dist: Path, tmp_path: Path) -> None:
"""真实的 app.main:环境变量里的白名单生效、不发 XFO;托管页面时 index.html 带上报策略。settings、app 都是模块级
单例,起一个子进程导入。"""
env = {**os.environ, "AGENTLAB_DATA_DIR": str(tmp_path / "data"), "AGENTLAB_WEB_DIST": str(dist),
"AGENTLAB_SECRET_KEY": "test-only-not-a-real-key", "AGENTLAB_FRAME_ANCESTORS": "https://portal.example.com"}
proc = subprocess.run([sys.executable, "-c", _PROBE], cwd=BACKEND_DIR, env=env, capture_output=True, text=True,
timeout=120)
assert proc.returncode == 0, proc.stderr[-2000:]
out = json.loads(proc.stdout.strip().splitlines()[-1])
for path in ("/", "/api/health"):
assert out[path]["content-security-policy"] == "frame-ancestors 'self' https://portal.example.com", path
assert out[path]["x-frame-options"] is None, path
assert out["/"]["content-security-policy-report-only"] == f"script-src 'self' 'sha256-{HASH}'"
assert out["/api/health"]["content-security-policy-report-only"] is None
+14 -1
View File
@@ -23,7 +23,9 @@ BIND_ADDR=0.0.0.0
# 不能再改登录方式。local:第一次打开登录页时建第一个管理员(只在还没有管理员时可用);oidc 的身份提供方地址、
# 客户端、管理员映射在「设置 → 身份」里配。忘了密码:docker exec -it agentlab python -m app.auth reset-admin
# AGENTLAB_AUTH_MODE=local
# 对外的地址(在反向代理后面时填):OIDC 的回调地址按它拼,非 GET 请求的 Origin 核对也认它
# 对外访问地址(在反向代理后面时填):通知和定时任务 Webhook 里的链接、OIDC 的回调地址都按它拼,非 GET 请求的
# Origin 核对也认它。设了这一项就以它为准,界面上「设置 → 通用」里的「对外访问地址」显示「由环境变量指定」、不能改;
# 不设时用界面上填的那一项。写错了(没有 http:// 或 https://、带 ? 或 #)服务启动时报错
# AGENTLAB_PUBLIC_URL=https://agentlab.example.internal
# 会话:闲置多少小时、最长多少天过期
# AGENTLAB_AUTH_SESSION_IDLE_HOURS=12
@@ -31,6 +33,17 @@ BIND_ADDR=0.0.0.0
# 会话 cookie 是否带 Secure:留空按请求是不是 https 决定;反向代理终结了 https 时填 true
# AGENTLAB_AUTH_COOKIE_SECURE=
# ---- 嵌入(可选)----
# 默认只有 AgentLab 自己能把页面放进 iframe(响应头 Content-Security-Policy: frame-ancestors 'self',另加
# X-Frame-Options: SAMEORIGIN),别的站点嵌进去会被浏览器拦下,防点击劫持。HSTS 不在这里加,交给终结 https 的反向代理。
# 同站的门户(和 AgentLab 在同一个可注册域名下,比如 portal.example.internal 和 agentlab.example.internal)要嵌整站时,
# 把门户的地址写在下面,逗号分隔,只写 协议://主机[:端口],主机可以写 *.example.internal。填了之后只发
# frame-ancestors、不再发 X-Frame-Options。登录用的 cookie 只随同站的请求带上,跨站的门户嵌整站登录不了。
# 写错了(带路径、没写协议、只写 *)服务启动时报错。
# AGENTLAB_FRAME_ANCESTORS=https://portal.example.internal
# 分享页(/s)允许被哪些站点嵌,写法同上。分享页只认这一项、不认上面那一项。分享链接还没有上线,现在填了不起作用
# AGENTLAB_EMBED_ORIGINS=https://bi.example.internal
# ---- 数据 ----
# 数据目录:数据库、执行断点、上传的文件、沙箱工作区,还有加密密钥 .secret_key 都在这里。
# 要备份的就是这个目录(丢了 .secret_key,存进去的 API Key 就解不开了)。
+31
View File
@@ -0,0 +1,31 @@
/**
* 对外访问地址:别人打开 AgentLab 用的那个地址。服务端全站只认一个(backend/app/core/public_url.py):环境变量
* AGENTLAB_PUBLIC_URL 优先,其次设置里 notify 一组的 public_base_url(界面上在「设置 → 通用」改,定时任务的 Webhook 一节
* 有快捷入口)。通知和 Webhook 里的链接、单点登录的回调、来源核对都按它。
*
* GET /api/settings 另给只读的 public_url 一组 {value, env_locked}:环境变量指定了的话显示它、写明「由环境变量指定」、
* 不让改(改了服务端回 409)。老服务端没有这一组时按设置里存的那一项、可以改。
*/
export interface PublicAddress {
/** 生效的地址;null 是没设置(通知里不附链接) */
value: string | null
/** 环境变量指定的:设置里的那一项不生效、不能改 */
env: boolean
}
export function publicAddressOf(settings: Record<string, any> | null | undefined): PublicAddress {
const env = settings?.public_url?.env_locked === true
const raw = env ? settings?.public_url?.value : settings?.notify?.public_base_url
return { value: typeof raw === 'string' && raw ? raw : null, env }
}
/** 填的地址去掉首尾空白和末尾的 /(服务端存的也是这样) */
export function cleanPublicUrl(text: string): string {
return text.trim().replace(/\/+$/, '')
}
/** http:// 或 https:// 开头、有主机名、中间没有空白、不带 ? 或 # 之后的部分(同服务端 public_url.url_problem) */
export function isPublicUrl(text: string): boolean {
return /^https?:\/\/[^\s/?#]+(?:\/[^\s?#]*)?$/i.test(text.trim())
}
+34 -8
View File
@@ -3922,19 +3922,20 @@ export const SCHEDULE_TEXT = {
testResult: (head: string, status: number | null, detail: string) =>
`${head}${status != null ? `(HTTP ${status})` : ''}${detail ? `:${detail}` : ''}`,
// ---- 通知里的链接:运行、对比页都按这个地址拼(设置 notify 组,所有定时任务共用)
// ---- 通知里的链接:运行、对比页都按全站的对外访问地址拼(GENERAL_SETTING_TEXT,这里是它的快捷入口)
linkBase: '通知中的链接',
linkBaseValue: (url: string) => `按 ${url} 拼接`,
linkBaseUnset: '未设置服务访问地址,通知中不附带运行和对比页链接',
linkBaseUnset: '未设置对外访问地址,通知中不附带运行和对比页链接',
linkBaseEdit: '修改',
linkBaseSet: '设置',
linkBaseLabel: '服务访问地址',
linkBaseHint: '收件人打开运行、对比页链接用的地址,例如 https://agentlab.example.com。所有定时任务共用',
linkBaseLabel: '对外访问地址',
linkBaseHint: '收件人打开运行、对比页链接用的地址,例如 https://agentlab.example.com。全站共用一个,也可以在「设置 → 通用」中修改',
linkBasePlaceholder: 'https://agentlab.example.com',
linkBaseInvalid: '请输入以 http:// 或 https:// 开头的地址',
linkBaseSaved: '已保存服务访问地址',
linkBaseCleared: '已清除服务访问地址',
linkBaseFailed: '无法读取服务访问地址',
linkBaseInvalid: '请输入以 http:// 或 https:// 开头的完整地址,不带 ? 或 # 之后的部分',
linkBaseSaved: '已保存对外访问地址',
linkBaseCleared: '已清除对外访问地址',
linkBaseFailed: '无法读取对外访问地址',
linkBaseEnv: '由环境变量 AGENTLAB_PUBLIC_URL 指定,不能在这里修改',
// ---- 表单:保存
save: '保存',
@@ -5903,6 +5904,31 @@ export const APP_SETTING_TEXT = {
personalAppsHint: '开启后,使用者可以从说需求、场景模板或问数据的回答建一个只有自己看得见的应用:只读、结果遮罩,每人每天的刷新次数按上面的每日上限算;想给别人用要提交给搭建者审核发布。搭建者及以上不受这一项限制',
}
/**
* 设置 → 通用:全站共用的部署设置。对外访问地址全站只有一个(服务端 core/public_url.py):环境变量 AGENTLAB_PUBLIC_URL
* 优先,其次这里填的;定时任务 Webhook 一节里是它的快捷入口(SCHEDULE_TEXT.linkBase*)
*/
export const GENERAL_SETTING_TEXT = {
tab: '通用',
publicUrl: '对外访问地址',
publicUrlHint: '别人打开 AgentLab 用的地址。通知和定时任务 Webhook 里的链接、单点登录的回调地址都按它拼,提交操作时也按它核对请求来源。留空时通知里不附链接,回调和来源核对按浏览器访问的地址认',
address: '地址',
addressHint: '以 http:// 或 https:// 开头的完整地址,例如 https://agentlab.example.com;放在反向代理的子路径下时带上路径',
placeholder: 'https://agentlab.example.com',
invalid: '请输入以 http:// 或 https:// 开头的完整地址,不带 ? 或 # 之后的部分',
unset: '未设置',
envLocked: '由环境变量 AGENTLAB_PUBLIC_URL 指定',
envLockedHint: '部署时用环境变量定了这个地址,这里不能修改。要改请修改部署配置后重启服务',
readOnly: '全站共用,只有管理员可以修改',
save: '保存',
saved: '已保存对外访问地址',
cleared: '已清除对外访问地址',
leaveTitle: '对外访问地址还没保存',
leaveBody: '离开此页面将丢失这次修改。',
leaveConfirm: '放弃修改',
leaveCancel: '返回保存',
}
/** 自定义工具绑定登记的端点(§3.9):只填路径,地址和认证头由端点提供 */
export const TOOL_ENDPOINT_TEXT = {
bind: '端点',
+6 -1
View File
@@ -25,6 +25,7 @@ import { localActor, setLocalActor } from '../lib/actor'
import {
AGENT_GUARD_TEXT, APP_SETTING_TEXT, DRAFT_MODEL_SETTING_TEXT, IDENTITY_TEXT, JUDGE_SETTING_TEXT, NOTIFY_TEXT, PROVIDER_REF_TEXT,
SETTINGS_ROLE_TEXT, TOOL_GATE_TEXT, VERIFY_SETTING_TEXT, judgeByModelText, judgeUnlimitedText,
GENERAL_SETTING_TEXT,
} from '../lib/terms'
import { atLeast, loginOn, prefsLocalOnly, usePrincipal } from '../store/auth'
import type { ThemePref } from '../lib/theme'
@@ -32,6 +33,7 @@ import {
ProviderOptions, VerifySection, verifyChanged, verifyDraftOf, verifyErrors, verifyLabels, verifyPayload, type VerifyDraft,
} from './VerifySettings'
import { DraftModelSection } from './DraftModelSettings'
import { GeneralTab } from './settings/GeneralTab'
import { IdentityTab } from './settings/IdentityTab'
import { NotifyTab } from './settings/NotifyTab'
import { AuditTab } from './settings/AuditTab'
@@ -41,6 +43,8 @@ import { AuditTab } from './settings/AuditTab'
const TABS = [
{ key: 'providers', label: '模型接入' },
{ key: 'prefs', label: '偏好设置' },
// 全站共用的部署设置:对外访问地址(环境变量指定时只读)
{ key: 'general', label: GENERAL_SETTING_TEXT.tab },
{ key: 'system', label: '运行环境' },
// 应用平台(A7):登录方式和人、通知渠道、管理类操作的留痕
{ key: 'identity', label: IDENTITY_TEXT.tab },
@@ -74,12 +78,13 @@ export function SettingsPage() {
return (
<div className="flex h-full flex-col">
<PageHeader icon={<SettingsIcon size={13} />} title="设置"
subtitle={builder ? '模型接入、偏好、运行环境、身份和通知' : SETTINGS_ROLE_TEXT.subtitleUser} />
subtitle={builder ? '模型接入、偏好、对外访问地址、运行环境、身份和通知' : SETTINGS_ROLE_TEXT.subtitleUser} />
<Tabs tabs={tabs} active={tab} onChange={change} label="设置" idPrefix="settings" />
<TabPanel idPrefix="settings" tabKey={tab} className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto max-w-4xl p-4">
{tab === 'providers' && <ProvidersTab />}
{tab === 'prefs' && (builder ? <PrefsTab /> : <PersonalPrefs />)}
{tab === 'general' && <GeneralTab />}
{tab === 'system' && <SystemTab />}
{tab === 'identity' && <IdentityTab />}
{tab === 'notify' && <NotifyTab />}
+17 -9
View File
@@ -4,12 +4,13 @@ import clsx from 'clsx'
import { api } from '../../api/client'
import { Field, Notice, Spinner, isComposing, toast, useRadioGroup } from '../../components/ui'
import { errorMessage } from '../../lib/errors'
import { cleanPublicUrl, isPublicUrl, publicAddressOf } from '../../lib/publicUrl'
import { SCHEDULE_TEXT as T } from '../../lib/terms'
import type { ScheduleEvent, ScheduleWebhook, ScheduleWebhookTest } from '../../types'
import { SCHEDULE_EVENTS, WEBHOOK_FORMATS, isHttpUrl, webhookTestText, type FormField, type WebhookForm } from './model'
import { SCHEDULE_EVENTS, WEBHOOK_FORMATS, webhookTestText, type FormField, type WebhookForm } from './model'
/**
* Webhook 通知:格式、地址、签名密钥、订阅事件、发送测试消息,外加通知里运行、对比页链接用的服务访问地址。
* Webhook 通知:格式、地址、签名密钥、订阅事件、发送测试消息,外加通知里运行、对比页链接用的对外访问地址(快捷入口)。
*
* 地址和密钥保存之后服务端不再返回明文:地址只显示主机和末 4 位(url_hint),可以替换或删除;密钥只说「已设置」,
* 可以替换或清除。没点替换时请求里不带它们,改别的字段不会把地址清掉。测试消息用的是已保存的设置,
@@ -195,11 +196,12 @@ function SecretField({ value: w, saved, onChange, error }: {
}
/**
* 通知里运行、对比页链接用的服务访问地址:设置里 notify 一组的 public_base_url,所有定时任务共用。这里给出当前值和修改入口,
* 写的时候先重读一遍 notify 组、只换这一项,不把同组别的设置冲掉
* 通知里运行、对比页链接用的对外访问地址:全站只有一个(lib/publicUrl.ts,「设置 → 通用」),这里给出当前值和修改的快捷入口。
* 环境变量指定了的只显示、写明由环境变量指定,不给修改。写的时候先重读一遍 notify 组、只换这一项,不把同组别的设置冲掉
*/
function LinkBase() {
const [value, setValue] = useState<string | null | undefined>(undefined)
const [env, setEnv] = useState(false)
const [failed, setFailed] = useState(false)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState('')
@@ -208,13 +210,18 @@ function LinkBase() {
useEffect(() => {
let alive = true
api.settings.get()
.then((s) => { if (alive) setValue(typeof s?.notify?.public_base_url === 'string' ? s.notify.public_base_url : null) })
.then((s) => {
if (!alive) return
const address = publicAddressOf(s)
setValue(address.value)
setEnv(address.env)
})
.catch(() => { if (alive) setFailed(true) })
return () => { alive = false }
}, [])
const cleaned = draft.trim().replace(/\/+$/, '')
const invalid = !!cleaned && !isHttpUrl(cleaned)
const cleaned = cleanPublicUrl(draft)
const invalid = !!cleaned && !isPublicUrl(cleaned)
const save = async () => {
if (invalid || busy) return
setBusy(true)
@@ -222,7 +229,7 @@ function LinkBase() {
const current = await api.settings.get()
const notify = { ...(current?.notify ?? {}), public_base_url: cleaned || null }
const out = await api.settings.put({ notify })
setValue(typeof out?.notify?.public_base_url === 'string' ? out.notify.public_base_url : notify.public_base_url)
setValue(out?.notify ? publicAddressOf(out).value : notify.public_base_url)
setFailed(false)
setEditing(false)
toast.ok(cleaned ? T.linkBaseSaved : T.linkBaseCleared)
@@ -240,12 +247,13 @@ function LinkBase() {
<span className={clsx('min-w-0 break-all', value ? 'mono text-fg' : 'text-faint')}>
{failed ? T.linkBaseFailed : value === undefined ? <Spinner size={10} /> : value ? T.linkBaseValue(value) : T.linkBaseUnset}
</span>
{!editing && value !== undefined && (
{!editing && value !== undefined && !env && (
<button type="button" className="btn btn-xs" onClick={() => { setDraft(value ?? ''); setEditing(true) }} data-link-base-edit="">
{value ? T.linkBaseEdit : T.linkBaseSet}
</button>
)}
</div>
{env && <p className="mt-1 text-2xs leading-relaxed text-faint" data-link-base-env="">{T.linkBaseEnv}</p>}
{editing && (
<div className="mt-2">
<Field label={T.linkBaseLabel} htmlFor="schedule-link-base" hint={T.linkBaseHint} error={invalid ? T.linkBaseInvalid : undefined}>
+1 -1
View File
@@ -559,7 +559,7 @@ export function freqProblem(f: Freq): string | null {
return null
}
/** 以 http:// 或 https:// 开头、中间没有空白的地址(Webhook、服务访问地址都用它) */
/** 以 http:// 或 https:// 开头、中间没有空白的地址(Webhook 地址用它;对外访问地址另有 lib/publicUrl 的 isPublicUrl) */
export function isHttpUrl(text: string): boolean {
return /^https?:\/\/\S+$/i.test(text.trim())
}
@@ -0,0 +1,93 @@
import { useEffect, useState } from 'react'
import { Check, Lock } from 'lucide-react'
import { api } from '../../api/client'
import { ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, isComposing, toast } from '../../components/ui'
import { useLeaveGuard } from '../../lib/leave'
import { cleanPublicUrl, isPublicUrl, publicAddressOf } from '../../lib/publicUrl'
import { GENERAL_SETTING_TEXT as T } from '../../lib/terms'
import { useAtLeast } from '../../store/auth'
/*
* 设置 → 通用:全站共用的部署设置。现在只有「对外访问地址」一项:服务端全站只认一个对外地址(core/public_url.py),
* 环境变量 AGENTLAB_PUBLIC_URL 优先,其次这里填的(存在 notify 一组)。以前两处各管各的,设置里这一项只在定时任务的
* Webhook 一节里改得到,那里现在是它的快捷入口。
*
* 环境变量设了:只显示它,写明「由环境变量指定」,不给输入框(GET /api/settings 的 public_url 一组)。只有管理员能改
* (整个设置接口的改动都要管理员),搭建者只看。保存时先重读一遍 notify 组、只换这一项,不把同组别的设置冲掉
*/
export function GeneralTab() {
const admin = useAtLeast('admin')
const [settings, setSettings] = useState<Record<string, any> | null>(null)
const [loadError, setLoadError] = useState<unknown>(null)
// null:没在改,输入框显示存着的值
const [draft, setDraft] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const load = () => api.settings.get().then((s) => { setSettings(s); setLoadError(null) }, setLoadError)
useEffect(() => { void load() }, [])
const address = publicAddressOf(settings)
const editable = admin && !address.env
const saved = address.value ?? ''
const cleaned = cleanPublicUrl(draft ?? saved)
const invalid = !!cleaned && !isPublicUrl(cleaned)
const dirty = settings != null && editable && cleaned !== saved
useLeaveGuard(dirty, () => confirmDialog({
title: T.leaveTitle, body: T.leaveBody, confirmLabel: T.leaveConfirm, cancelLabel: T.leaveCancel, danger: true,
}))
const save = async () => {
if (!dirty || invalid || busy) return
setBusy(true)
try {
const current = await api.settings.get()
const out = await api.settings.put({ notify: { ...(current?.notify ?? {}), public_base_url: cleaned || null } })
setSettings(out)
setDraft(null)
toast.ok(cleaned ? T.saved : T.cleared)
} catch (e) {
toast.error(e)
// 环境变量这期间被设上了(服务端回 409)这类:按服务端现在的样子重读
void load()
} finally {
setBusy(false)
}
}
return (
<div className="max-w-2xl space-y-6 pb-20">
<section data-general-public-url="">
<SectionBar title={T.publicUrl} hint={T.publicUrlHint} />
{!settings ? (
loadError ? <ErrorState error={loadError} onRetry={() => void load()} /> : <Skeleton rows={2} height={14} />
) : editable ? (
<Field label={T.address} htmlFor="general-public-url" hint={T.addressHint} error={invalid ? T.invalid : undefined}>
<div className="flex flex-wrap gap-1.5">
<input id="general-public-url" type="url" inputMode="url" className="field mono min-w-0 flex-1 basis-60"
value={draft ?? saved} placeholder={T.placeholder} autoComplete="off" spellCheck={false}
aria-invalid={invalid || undefined}
aria-describedby={invalid ? 'general-public-url-error' : 'general-public-url-hint'}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && !isComposing(e)) { e.preventDefault(); void save() } }}
data-public-url-input="" />
<button type="button" className="btn btn-sm btn-primary" onClick={() => void save()}
disabled={!dirty || invalid || busy} data-public-url-save="">
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {T.save}
</button>
</div>
</Field>
) : (
<div className="rounded-md border bg-elev px-2.5 py-2" data-public-url-readonly={address.env ? 'env' : 'role'}>
<div className={address.value ? 'mono break-all text-xs text-fg' : 'text-xs text-faint'} data-public-url-value="">
{address.value ?? T.unset}
</div>
<p className="mt-1 flex items-start gap-1 text-2xs leading-relaxed text-faint">
<Lock size={11} className="mt-0.5 shrink-0" aria-hidden />
<span>{address.env ? `${T.envLocked}。${T.envLockedHint}` : T.readOnly}</span>
</p>
</div>
)}
</section>
</div>
)
}
+71
View File
@@ -731,6 +731,77 @@ await section('设置 · 应用:使用者可以建个人应用(只有管理
await builder.close()
})
await section('设置 · 通用:对外访问地址', async () => {
// 全站只认一个对外访问地址(服务端 core/public_url.py):环境变量优先,其次这里填的(存在 notify 一组)。通知和 Webhook 的
// 链接、单点登录的回调、来源核对都按它。管理员能改;环境变量指定了的只显示、写明来源;搭建者只看
let stored = await get('/settings')
stored = { ...stored, notify: { ...(stored.notify ?? {}), public_base_url: null }, public_url: { value: null, env_locked: false } }
const { page, sent, errors, natives, close } = await open('/settings/general', {
handlers: [
[/^GET \/settings$/, (route) => json(stored)(route)],
[/^PUT \/settings$/, (route, { body }) => {
stored = { ...stored, ...body.values, public_url: { value: body.values.notify?.public_base_url ?? null, env_locked: false } }
return json(stored)(route)
}],
],
})
const box = page.locator('[data-general-public-url]')
await box.waitFor()
check('设置页有「通用」标签,里面是对外访问地址', (await page.getByRole('tab', { name: '通用' }).getAttribute('aria-selected')) === 'true'
&& (await box.innerText()).includes('对外访问地址'))
const said = await box.innerText()
check('……写明它管哪几件事:通知和 Webhook 的链接、单点登录的回调、来源核对', said.includes('Webhook') && said.includes('单点登录')
&& said.includes('来源'), said.replace(/\s+/g, ' '))
const input = box.locator('[data-public-url-input]')
const save = box.locator('[data-public-url-save]')
check('没改动时保存不可点', await save.isDisabled())
await input.fill('https://agentlab.example.com/?from=x')
check('带 ? 的地址:当场标错、不让存', await save.isDisabled() && (await box.innerText()).includes('不带 ? 或 #'))
await input.fill('agentlab.example.com')
check('没写协议:同样不让存', await save.isDisabled())
await input.fill('https://agentlab.example.com/')
await page.getByRole('tab', { name: '运行环境' }).click()
await page.waitForTimeout(300)
check('改了没保存就切标签:先问', (await dialog(page).innerText().catch(() => '')).includes('还没保存'))
await dialog(page).getByRole('button', { name: '返回保存' }).click()
await page.waitForTimeout(200)
check('……选「返回保存」:还在通用页,改动还在', page.url().endsWith('/settings/general')
&& (await input.inputValue()) === 'https://agentlab.example.com/')
await save.click()
await until(() => sent.some((x) => x.key === 'PUT /settings'))
const put = sent.filter((x) => x.key === 'PUT /settings').at(-1)?.body?.values
check('保存只 PUT notify 一组、去掉末尾的斜杠,同组别的项原样带回', Object.keys(put ?? {}).join() === 'notify'
&& put.notify.public_base_url === 'https://agentlab.example.com', JSON.stringify(put))
check('……存完按钮回到不可点', await until(() => save.isDisabled()))
check('没有运行时报错、原生对话框', errors.length === 0 && natives.length === 0, [...errors, ...natives][0] ?? '')
await close()
// 环境变量指定了:只显示它、写明来源,不给输入框
const env = await open('/settings/general', {
handlers: [[/^GET \/settings$/, json({ ...stored, public_url: { value: 'https://agentlab.example.internal', env_locked: true } })]],
})
const ro = env.page.locator('[data-public-url-readonly="env"]')
await ro.waitFor()
const roText = await ro.innerText()
check('环境变量指定的:显示它、写明由 AGENTLAB_PUBLIC_URL 指定,不给输入框', roText.includes('https://agentlab.example.internal')
&& roText.includes('AGENTLAB_PUBLIC_URL') && await env.page.locator('[data-public-url-input]').count() === 0, roText)
await env.close()
// 搭建者(开了登录):只看,写明只有管理员可以修改
const builder = await open('/settings/general', {
handlers: [
[/^GET \/auth\/me$/, json({ mode: 'local', username: 'lisi', display_name: '李四', role: 'builder', groups: [], verified: true })],
[/^GET \/settings$/, json(stored)],
],
})
const ro2 = builder.page.locator('[data-public-url-readonly="role"]')
await ro2.waitFor()
const ro2Text = await ro2.innerText()
check('搭建者:只显示当前值、写明只有管理员可以修改', ro2Text.includes('https://agentlab.example.com') && ro2Text.includes('只有管理员')
&& await builder.page.locator('[data-public-url-input]').count() === 0, ro2Text)
await builder.close()
})
await section('设置 · 点名的接入已停用或不存在', async () => {
// 点名停用的接入以前照常调用,现在运行时照实报错(K1):存着的值后来被停用了,那一格下面要说;别的停用接入列着但选不了;
// 服务端按项拒收(422 带 field)时整句落到那一格下面、焦点移过去,保存条上原因不截断。接入表、设置读写都是假的
+22 -1
View File
@@ -535,7 +535,9 @@ function applyPatch(s, body) {
let realSettings = null
async function settingsOf(state) {
realSettings ??= await fetch(`${API}/settings`).then((r) => r.json()).catch(() => ({}))
return { ...realSettings, ui: { ...(realSettings.ui ?? {}), theme: state.theme ?? 'dark' }, notify: state.notify }
// 对外访问地址只读的那一组:state.publicUrl 设了就按它(环境变量指定的情形),否则照真服务端
return { ...realSettings, ui: { ...(realSettings.ui ?? {}), theme: state.theme ?? 'dark' }, notify: state.notify,
public_url: state.publicUrl ?? realSettings.public_url }
}
/** 一个请求怎么答。返回 {status, json};返回 null 表示放行(GET)或拦下(写请求) */
@@ -1245,6 +1247,25 @@ await section('详情:Webhook 与运行链接地址', async () => {
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('详情:对外访问地址由环境变量指定', async () => {
// 全站只认一个对外访问地址(服务端 core/public_url.py):环境变量设了就以它为准,这里只显示、写明来源,不给修改;
// notify 里存着的旧值不生效,也不显示
const env = 'https://agentlab.example.internal'
const { page, sent, errors } = await open('/schedules/s-weekly', {
state: freshState({ notify: { public_base_url: 'https://stale.example.com' }, publicUrl: { value: env, env_locked: true } }),
})
await page.locator('[data-link-base]').waitFor()
const shown = await until(async () => {
const t = await text(page, '[data-link-base]')
return t.includes(env) ? t : null
})
check('显示环境变量给的地址,不显示设置里存着的旧值', !!shown && !shown.includes('stale.example.com'), oneLine([shown ?? '']))
check('……写明由环境变量 AGENTLAB_PUBLIC_URL 指定', (await text(page, '[data-link-base-env]').catch(() => '')).includes('AGENTLAB_PUBLIC_URL'))
check('……没有修改按钮,也没发 PUT', await page.locator('[data-link-base-edit]').count() === 0
&& writes(sent, /^PUT \/settings$/).length === 0)
check('没有页面报错、原生对话框', !errors.length, errors.join(' | '))
})
await section('详情:保存冲突与页头操作', async () => {
const { page, sent, errors, state } = await open('/schedules/s-weekly')
await page.locator('[data-schedule-form]').waitFor()