对外访问地址只留一个:core/public_url.py 的 public_base_url(),通知、Webhook 链接、OIDC 回调、来源核对都改用它

以前有两个「对外地址」:环境变量 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 两条都会失败。设置页把它挪到「通用」在后面的前端提交里。
This commit is contained in:
ThearchyHelios
2026-10-16 10:18:12 +08:00
parent a05efbf27a
commit d8034ca71a
11 changed files with 351 additions and 44 deletions
+4 -2
View File
@@ -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"
+3
View File
@@ -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:出错的是哪一项(表单据此把报错落到那一格)。可选,没有就不出现在返回里。"""
+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)
+5 -4
View File
@@ -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"
+16 -2
View File
@@ -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]:
+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}"
+7 -3
View File
@@ -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)
+4 -3
View File
@@ -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]:
+3 -2
View File
@@ -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:
+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()
+3 -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