From d8034ca71a49144d83a98cbcb3b8b09ee972ece6 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:18:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=B9=E5=A4=96=E8=AE=BF=E9=97=AE=E5=9C=B0?= =?UTF-8?q?=E5=9D=80=E5=8F=AA=E7=95=99=E4=B8=80=E4=B8=AA=EF=BC=9Acore/publ?= =?UTF-8?q?ic=5Furl.py=20=E7=9A=84=20public=5Fbase=5Furl()=EF=BC=8C?= =?UTF-8?q?=E9=80=9A=E7=9F=A5=E3=80=81Webhook=20=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E3=80=81OIDC=20=E5=9B=9E=E8=B0=83=E3=80=81=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9=E9=83=BD=E6=94=B9=E7=94=A8=E5=AE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以前有两个「对外地址」:环境变量 AGENTLAB_PUBLIC_URL 只管 OIDC 回调和 Origin 核对,设置里 notify.public_base_url 只管 通知里的链接。只配了环境变量的部署,通知里不带链接;只在设置里填了的,回调又按请求的 Host 拼——违背「一种说法」。 - 新增 core/public_url.py:环境变量优先,其次设置(沿用 notify.public_base_url 这个键),都没有是 None。设置的值缓存在 进程里(中间件按请求核对来源,不能每次查库):启动时 refresh 一次,PUT /api/settings 写完库就 remember,不用重启; 通知、定时任务 Webhook 手里本来就有会话,用 refresh 取,顺手按库里的值对齐缓存 - 地址的形状检查和规范化只有一份(url_problem / normalize),设置接口的中文报错和环境变量的启动校验都用它;环境变量 写错了(没有 http:// 或 https://、带 ? 或 #)启动时就报错,不再拼出坏的回调地址 - GET /api/settings 另给只读的 public_url 一组 {value, env_locked},设置页据此显示「由环境变量指定」;环境变量设了时 PUT 把这一项改成别的值回 409(public_url_env_locked,field 指到这一项),原样写回不算改;public_url 一组写不进库 - notify/service 取地址的那一处、scheduler/webhook 链接里的地址、api/auth 的 OIDC 回调、auth/middleware 的 Origin 核对 都改走 public_base_url;deploy/agentlab.env 把这个变量的说法改成全站唯一的对外访问地址 测试 tests/test_public_url.py:先后顺序(环境变量 > 设置 > 无)、规范化、启动校验、PUT 刷新缓存、环境变量锁定(409、 不挡原样写回)、只配环境变量时通知带链接、OIDC 回调和 Origin 核对认设置里的地址。去掉修复回跑,只配环境变量的通知 链接、按设置核对 Origin 两条都会失败。设置页把它挪到「通用」在后面的前端提交里。 --- backend/app/api/auth.py | 6 +- backend/app/api/coded.py | 3 + backend/app/api/settings.py | 67 +++++++----- backend/app/auth/middleware.py | 9 +- backend/app/core/config.py | 18 +++- backend/app/core/public_url.py | 97 ++++++++++++++++++ backend/app/main.py | 10 +- backend/app/notify/service.py | 7 +- backend/app/scheduler/webhook.py | 5 +- backend/tests/test_public_url.py | 169 +++++++++++++++++++++++++++++++ deploy/agentlab.env | 4 +- 11 files changed, 351 insertions(+), 44 deletions(-) create mode 100644 backend/app/core/public_url.py create mode 100644 backend/tests/test_public_url.py diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 6a4c87d9..60e58654 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -25,7 +25,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 @@ -113,7 +112,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" diff --git a/backend/app/api/coded.py b/backend/app/api/coded.py index d8fdba23..da319c42 100644 --- a/backend/app/api/coded.py +++ b/backend/app/api/coded.py @@ -29,6 +29,9 @@ CATALOG_DRAFT_BUSY = "catalog_draft_busy" CATALOG_DRAFT_MODEL_UNAVAILABLE = "catalog_draft_model_unavailable" CATALOG_DRAFT_FINISHED = "catalog_draft_finished" +#: 设置(api/settings.py):对外访问地址由环境变量 AGENTLAB_PUBLIC_URL 指定时,设置里那一项不能改成别的值(409) +PUBLIC_URL_ENV_LOCKED = "public_url_env_locked" + class CodedHTTPException(HTTPException): """field:出错的是哪一项(表单据此把报错落到那一格)。可选,没有就不出现在返回里。""" diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 83fcaaf7..c45a685a 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -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) diff --git a/backend/app/auth/middleware.py b/backend/app/auth/middleware.py index 05da0def..b3e44a2a 100644 --- a/backend/app/auth/middleware.py +++ b/backend/app/auth/middleware.py @@ -19,7 +19,6 @@ from __future__ import annotations import json from http.cookies import SimpleCookie from typing import Any -from urllib.parse import urlsplit from app.core.config import settings @@ -50,10 +49,12 @@ def _cookie(headers: dict[str, str], name: str) -> str | None: 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" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 27533ecd..955e7242 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -64,8 +64,9 @@ 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)--- @@ -146,6 +147,19 @@ 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]: diff --git a/backend/app/core/public_url.py b/backend/app/core/public_url.py new file mode 100644 index 00000000..1bd5a2cc --- /dev/null +++ b/backend/app/core/public_url.py @@ -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}" diff --git a/backend/app/main.py b/backend/app/main.py index 62cfea6f..a794593d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -146,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) diff --git a/backend/app/notify/service.py b/backend/app/notify/service.py index a836574a..466a3730 100644 --- a/backend/app/notify/service.py +++ b/backend/app/notify/service.py @@ -7,7 +7,7 @@ 同一个渠道只推一次。待办改派、升级、催办都是「有一条待办要你处理」,订阅了 task.opened 的渠道一并收到。 消息里只放标题、状态、链接(P4,沿用定时任务 Webhook 的规矩):不放报告里的数字,待办的标题用节点在图上的名字 -(搭图时起的),不用按运行数据渲染出来的那一句。链接要设了对外访问地址(设置里的 notify)才有。 +(搭图时起的),不用按运行数据渲染出来的那一句。链接要有对外访问地址(环境变量或设置,core/public_url.py)才有。 推送放在后台(spawn):接收端挂住时超时加重试要几十秒,不能拖着开待办、运行收尾。测试用 drain 等它们推完。 """ @@ -91,9 +91,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]: diff --git a/backend/app/scheduler/webhook.py b/backend/app/scheduler/webhook.py index 4a19e1c6..1a9c690d 100644 --- a/backend/app/scheduler/webhook.py +++ b/backend/app/scheduler/webhook.py @@ -395,9 +395,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: diff --git a/backend/tests/test_public_url.py b/backend/tests/test_public_url.py new file mode 100644 index 00000000..c13667dc --- /dev/null +++ b/backend/tests/test_public_url.py @@ -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() diff --git a/deploy/agentlab.env b/deploy/agentlab.env index 3a411c0b..d121040b 100644 --- a/deploy/agentlab.env +++ b/deploy/agentlab.env @@ -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