From a05efbf27a784a96052157318c9b47ef0032f6a9 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:17:37 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=93=8D=E5=BA=94?= =?UTF-8?q?=E5=A4=B4=EF=BC=9A=E6=89=80=E6=9C=89=20HTTP=20=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E8=A1=A5=20nosniff=E3=80=81Referrer-Policy=20?= =?UTF-8?q?=E5=92=8C=20CSP=20frame-ancestors=EF=BC=8C=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=8F=AA=E4=B8=8A=E6=8A=A5=E4=B8=A5=E6=A0=BC=E7=9A=84=20script?= =?UTF-8?q?-src?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以前服务端一个安全头都不发,任何站点都能把 AgentLab 嵌进 iframe:同站门户(Lax 会话 cookie 会随请求带上)和关登录的部署 都有被点击劫持的可能,也没有「允许谁嵌」的白名单可配。 - 新增 app/web_security.py,纯 ASGI 中间件,main.py 里加在最外层:CORS 预检、登录检查自己回的 401 / 403 也带上; WebSocket 不经过它;接口自己设过的同名头(文件下载的 nosniff)不覆盖、不重复 - frame-ancestors 缺省只有 'self',这时另加 X-Frame-Options: SAMEORIGIN;AGENTLAB_FRAME_ANCESTORS 配了同站门户的白名单时 只发 CSP(XFO 写不了白名单,两个都发会让只认 XFO 的老浏览器把门户也拦掉) - 分享页 /s 只认 AGENTLAB_EMBED_ORIGINS、Referrer-Policy 用 no-referrer。分享链接第 3 波才加,这一波先把配置读进来、 头的规则写好 - 严格的 script-src 先只发 Content-Security-Policy-Report-Only:mount_web 启动时给构建产物 index.html 里的内联主题 脚本算 sha256,只在页面响应上发;接口返回的 HTML(导出的报告自带 meta CSP)不发、不动。观察一个版本没有误报再强制执行 - HSTS 交给终结 https 的反向代理 - 两个白名单只收「协议://主机[:端口]」(可以 *. 开头),带路径、裸 *、没写协议的启动时就报错;deploy/agentlab.env 写明 两个变量和缺省行为 测试 tests/test_web_security.py:页面、/api、中间件自己的 401、CORS 预检都带头;白名单生效时不发 XFO;/s 的规则; 上报策略只在页面上、哈希对得上;已有的头不重复;WebSocket 不受影响;白名单写错启动报错;子进程起真实的 app.main 核对 环境变量接线。 --- backend/app/core/config.py | 34 ++++- backend/app/main.py | 3 + backend/app/web.py | 11 +- backend/app/web_security.py | 90 +++++++++++ backend/tests/test_web_security.py | 235 +++++++++++++++++++++++++++++ deploy/agentlab.env | 11 ++ 6 files changed, 381 insertions(+), 3 deletions(-) create mode 100644 backend/app/web_security.py create mode 100644 backend/tests/test_web_security.py diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 9dcf013a..27533ecd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 @@ -64,6 +68,13 @@ class Settings(BaseSettings): # 不设时按请求的 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 +132,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 +146,24 @@ class Settings(BaseSettings): return [part.strip() for part in text.split(",") if part.strip()] return value + @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: diff --git a/backend/app/main.py b/backend/app/main.py index 657c917a..62cfea6f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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, @@ -192,6 +193,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")] diff --git a/backend/app/web.py b/backend/app/web.py index d2c36605..30602b95 100644 --- a/backend/app/web.py +++ b/backend/app/web.py @@ -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), diff --git a/backend/app/web_security.py b/backend/app/web_security.py new file mode 100644 index 00000000..a7ae8646 --- /dev/null +++ b/backend/app/web_security.py @@ -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 的 " + "" + "
") +HASH = base64.b64encode(hashlib.sha256(THEME.encode()).digest()).decode() +REPORT = ("" + "") + + +@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 diff --git a/deploy/agentlab.env b/deploy/agentlab.env index 15b615d8..3a411c0b 100644 --- a/deploy/agentlab.env +++ b/deploy/agentlab.env @@ -31,6 +31,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 就解不开了)。 From d8034ca71a49144d83a98cbcb3b8b09ee972ece6 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:18:12 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E5=AF=B9=E5=A4=96=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E5=9C=B0=E5=9D=80=E5=8F=AA=E7=95=99=E4=B8=80=E4=B8=AA=EF=BC=9A?= =?UTF-8?q?core/public=5Furl.py=20=E7=9A=84=20public=5Fbase=5Furl()?= =?UTF-8?q?=EF=BC=8C=E9=80=9A=E7=9F=A5=E3=80=81Webhook=20=E9=93=BE?= =?UTF-8?q?=E6=8E=A5=E3=80=81OIDC=20=E5=9B=9E=E8=B0=83=E3=80=81=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=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 From e82d7fa55d32a4e088316a14d7fc9a142527dd75 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:19:50 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=E9=9B=86=E6=88=90=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E9=94=99=E8=AF=AF=E7=A0=81=EF=BC=8C=E5=BC=80?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=97=B6=E6=94=B6=E8=B5=B7=20/docs=E3=80=81/?= =?UTF-8?q?redoc=E3=80=81/openapi.json=EF=BC=88=E8=A6=81=E6=90=AD=E5=BB=BA?= =?UTF-8?q?=E8=80=85=E7=99=BB=E5=BD=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 外部系统只能按中文句子认是哪种错,文案一改就悄悄失灵;开了登录,接口说明仍然不用登录就能读,全部接口的结构对匿名的人 敞开(中间件只拦 /api/ 开头的路径)。 错误码(detail 文字一字不改,老前端照常显示;码都登记在 api/coded.py): - api/apps.py 13 处:应用不存在(app_not_found,同入口发起时的码;没有授权的人也回它)、版本、实例不存在,钩子没声明、 没生成密钥,岗位绑定、个人应用草稿、授权、页面参数不合规;派生时工作流、版本不存在(apps/derive.py 的 DeriveRefused 带上码);视图、入口、补算的拒绝原样带码,只有引擎不发起运行(LaunchRefused)没带码的几种补上 run_launch_refused - api/tasks.py 3 处:待办不存在、查看全部要搭建者(role_required);服务层没带码的拒绝按状态补上同一类的码 (404 task_not_found、403 task_forbidden、409 task_closed、422 task_invalid),没改 process/tasks.py - api/artifacts.py 5 处:没有权限、这类原文只给搭建者、不存在、内容和哈希对不上 - api/deps.py 的 _forbidden(run_forbidden)、运行和证据接口的角色拒绝(role_required)、个人运行只给拥有者 (run_private)、问数据(chat_forbidden);ensure_role / 路由级依赖走 auth.require,抽出 auth.refusal 一处判断, 没登录 401 auth_required、角色不够 403 role_required /docs:开登录时中间件把 /docs(含子路径)、/redoc、/openapi.json 当成 /api 一样拦,和路由级依赖用同一个 auth.refusal: 匿名 401,不到搭建者 403。关登录照旧(test_provenance_contract.py 的断言不受影响)。 测试 tests/test_integration_errors.py:这五个模块里不许再直接抛 HTTPException(静态检查);应用、待办、工件、角色、运行 拒绝逐类核对 code 和原 detail;/docs 关登录照旧 200,开登录匿名 401、使用者 403、搭建者 200。去掉中间件的改动回跑, 开登录时匿名打开 /docs 是 200,测试失败。 --- backend/app/api/apps.py | 41 +++--- backend/app/api/artifacts.py | 15 ++- backend/app/api/auth.py | 3 +- backend/app/api/coded.py | 44 +++++++ backend/app/api/deps.py | 23 ++-- backend/app/api/tasks.py | 23 ++-- backend/app/apps/derive.py | 12 +- backend/app/auth/__init__.py | 27 ++-- backend/app/auth/middleware.py | 17 ++- backend/tests/test_integration_errors.py | 159 +++++++++++++++++++++++ 10 files changed, 307 insertions(+), 57 deletions(-) create mode 100644 backend/tests/test_integration_errors.py diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index 68b04481..6d0b281f 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -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, 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}") diff --git a/backend/app/api/artifacts.py b/backend/app/api/artifacts.py index e8099dbe..e210ffba 100644 --- a/backend/app/api/artifacts.py +++ b/backend/app/api/artifacts.py @@ -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] = [] diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 60e58654..19fc9cf6 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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, @@ -59,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) diff --git a/backend/app/api/coded.py b/backend/app/api/coded.py index da319c42..27e7ea66 100644 --- a/backend/app/api/coded.py +++ b/backend/app/api/coded.py @@ -29,6 +29,50 @@ 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" +#: 发起实例、刷新视图、补算时引擎不发起运行,又没有更细的码(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" diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 6fa03e2d..4c9913f2 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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") #: 角色由高到低 @@ -140,7 +142,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 @@ -169,8 +171,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} 之后的部分):运行记录、事件、运行时的图,加上实时流 @@ -235,13 +237,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: @@ -252,7 +254,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: @@ -282,7 +284,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: @@ -310,7 +312,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() @@ -327,7 +329,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 角色开了问数据时能用的其余接口:问数据的生成流、读当前助手模型、问数据页选数据源用的数据源名单 diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py index 8418558d..8089e350 100644 --- a/backend/app/api/tasks.py +++ b/backend/app/api/tasks.py @@ -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 diff --git a/backend/app/apps/derive.py b/backend/app/apps/derive.py index 069b9672..7fbb84d4 100644 --- a/backend/app/apps/derive.py +++ b/backend/app/apps/derive.py @@ -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)) diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py index def0941d..361b5153 100644 --- a/backend/app/auth/__init__.py +++ b/backend/app/auth/__init__.py @@ -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: diff --git a/backend/app/auth/middleware.py b/backend/app/auth/middleware.py index b3e44a2a..3a9cfaa8 100644 --- a/backend/app/auth/middleware.py +++ b/backend/app/auth/middleware.py @@ -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 角色只能连自己能读、 @@ -20,6 +22,7 @@ import json from http.cookies import SimpleCookie from typing import Any +from app.api.coded import AUTH_REQUIRED from app.core.config import settings #: 开登录时匿名也能访问的接口 @@ -27,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" @@ -48,6 +53,10 @@ 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 @@ -93,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() @@ -131,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) diff --git a/backend/tests/test_integration_errors.py b/backend/tests/test_integration_errors.py new file mode 100644 index 00000000..cd8c0262 --- /dev/null +++ b/backend/tests/test_integration_errors.py @@ -0,0 +1,159 @@ +"""集成接口的错误码与 /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 test_no_bare_http_exception_left_in_the_integration_modules(): + """这几个模块里不许再直接抛 HTTPException(没有 code):一律 CodedHTTPException。""" + 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 isinstance(node, ast.Call) + and (getattr(node.func, "id", None) or getattr(node.func, "attr", None)) == "HTTPException"] + assert not bare, f"{rel} 第 {bare} 行抛的 HTTPException 没有 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"] From 2fb16c271943d6759917d54bf7385eddbddcf7e8 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:31:26 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E9=A1=B5=E5=8A=A0?= =?UTF-8?q?=E3=80=8C=E9=80=9A=E7=94=A8=E3=80=8D=E6=A0=87=E7=AD=BE=E6=94=BE?= =?UTF-8?q?=E5=AF=B9=E5=A4=96=E8=AE=BF=E9=97=AE=E5=9C=B0=E5=9D=80=EF=BC=9B?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F=E6=8C=87=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E3=80=8C=E7=94=B1=E7=8E=AF=E5=A2=83=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E6=8C=87=E5=AE=9A=E3=80=8D=E4=B8=8D=E5=8F=AF=E6=94=B9?= =?UTF-8?q?=EF=BC=8C=E5=AE=9A=E6=97=B6=E4=BB=BB=E5=8A=A1=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E8=B7=9F=E7=9D=80=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 服务端已经只认一个对外访问地址(core/public_url.py),界面上这一项以前只在定时任务的 Webhook 一节里改得到,叫「服务 访问地址」,和设置接口、通知里的「对外访问地址」两种说法;环境变量设了时那里照样能改,改了也不生效。 - 新增「设置 → 通用」(pages/settings/GeneralTab.tsx):写明它管哪几件事(通知和 Webhook 的链接、单点登录的回调、来源 核对);管理员能改,保存前先重读 notify 一组、只换这一项,末尾的 / 去掉,带 ? 或 # 的当场标错;改了没保存离开先问; 环境变量指定了的只显示它、写明「由环境变量 AGENTLAB_PUBLIC_URL 指定」,不给输入框;搭建者只看。使用者看不到这一页 - 定时任务 Webhook 一节的快捷入口改叫「对外访问地址」,按 GET /api/settings 的 public_url 一组认:环境变量指定的只显示、 不给修改按钮,设置里存着的旧值不再冒充生效的那个;地址校验和「通用」同一个(lib/publicUrl.ts) - 文案进 lib/terms.ts(GENERAL_SETTING_TEXT,SCHEDULE_TEXT 的 linkBase 一组改说法) 检查:check-manage 加「设置 · 通用:对外访问地址」一节(标签、说明、校验、离开先问、只写 notify 一组、环境变量只读、 搭建者只读);check-schedules 加「对外访问地址由环境变量指定」一节。check-manage、check-schedules 在隔离的一套后端 + vite 上全部通过,tsc、check-copy 通过。 --- frontend/src/lib/publicUrl.ts | 31 +++++++ frontend/src/lib/terms.ts | 42 +++++++-- frontend/src/pages/SettingsPage.tsx | 7 +- .../src/pages/schedules/WebhookFields.tsx | 26 ++++-- frontend/src/pages/schedules/model.ts | 2 +- frontend/src/pages/settings/GeneralTab.tsx | 93 +++++++++++++++++++ scripts/check-manage.mjs | 71 ++++++++++++++ scripts/check-schedules.mjs | 23 ++++- 8 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/publicUrl.ts create mode 100644 frontend/src/pages/settings/GeneralTab.tsx diff --git a/frontend/src/lib/publicUrl.ts b/frontend/src/lib/publicUrl.ts new file mode 100644 index 00000000..781500a9 --- /dev/null +++ b/frontend/src/lib/publicUrl.ts @@ -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 | 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()) +} diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index 51fb6dba..0f0a6dad 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -3889,19 +3889,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: '保存', @@ -5825,6 +5826,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: '端点', diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 68314672..4eb66a44 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -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 (
} title="设置" - subtitle={builder ? '模型接入、偏好、运行环境、身份和通知' : SETTINGS_ROLE_TEXT.subtitleUser} /> + subtitle={builder ? '模型接入、偏好、对外访问地址、运行环境、身份和通知' : SETTINGS_ROLE_TEXT.subtitleUser} />
{tab === 'providers' && } {tab === 'prefs' && (builder ? : )} + {tab === 'general' && } {tab === 'system' && } {tab === 'identity' && } {tab === 'notify' && } diff --git a/frontend/src/pages/schedules/WebhookFields.tsx b/frontend/src/pages/schedules/WebhookFields.tsx index 2041d5c9..ab27bcb4 100644 --- a/frontend/src/pages/schedules/WebhookFields.tsx +++ b/frontend/src/pages/schedules/WebhookFields.tsx @@ -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(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() { {failed ? T.linkBaseFailed : value === undefined ? : value ? T.linkBaseValue(value) : T.linkBaseUnset} - {!editing && value !== undefined && ( + {!editing && value !== undefined && !env && ( )}
+ {env &&

{T.linkBaseEnv}

} {editing && (
diff --git a/frontend/src/pages/schedules/model.ts b/frontend/src/pages/schedules/model.ts index 30b3ab7d..b6a68b15 100644 --- a/frontend/src/pages/schedules/model.ts +++ b/frontend/src/pages/schedules/model.ts @@ -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()) } diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx new file mode 100644 index 00000000..a29fd693 --- /dev/null +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -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 | null>(null) + const [loadError, setLoadError] = useState(null) + // null:没在改,输入框显示存着的值 + const [draft, setDraft] = useState(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 ( +
+
+ + {!settings ? ( + loadError ? void load()} /> : + ) : editable ? ( + +
+ setDraft(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && !isComposing(e)) { e.preventDefault(); void save() } }} + data-public-url-input="" /> + +
+
+ ) : ( +
+
+ {address.value ?? T.unset} +
+

+ + {address.env ? `${T.envLocked}。${T.envLockedHint}` : T.readOnly} +

+
+ )} +
+
+ ) +} diff --git a/scripts/check-manage.mjs b/scripts/check-manage.mjs index d5546dbd..eb02c474 100644 --- a/scripts/check-manage.mjs +++ b/scripts/check-manage.mjs @@ -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)时整句落到那一格下面、焦点移过去,保存条上原因不截断。接入表、设置读写都是假的 diff --git a/scripts/check-schedules.mjs b/scripts/check-schedules.mjs index 32723b47..b43fd823 100644 --- a/scripts/check-schedules.mjs +++ b/scripts/check-schedules.mjs @@ -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() From d14fc97da7f19c2c3bc6d321278295599805c410 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 10:33:41 +0800 Subject: [PATCH 5/5] =?UTF-8?q?=E5=BA=94=E7=94=A8=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E3=80=81=E6=8C=89=E9=9C=80=E8=A7=86=E5=9B=BE=E7=9A=84=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E6=B2=A1=E9=80=9A=E8=BF=87=E6=A0=A1=E9=AA=8C=E4=B9=9F?= =?UTF-8?q?=E5=B8=A6=20code=EF=BC=88form=5Finvalid=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=92=8C=E5=BE=85=E5=8A=9E=E5=9B=9E=E5=A4=8D=E3=80=81=E5=85=A5?= =?UTF-8?q?=E7=AB=99=E9=92=A9=E5=AD=90=E5=90=8C=E4=B8=80=E4=B8=AA=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/apps.py 里这两处直接回 JSONResponse(detail + errors),上一次补错误码时只查了 HTTPException,漏了它们:外部系统发起 实例时表单不对,仍然只能按中文句子认。detail 和 errors 不变。 tests/test_integration_errors.py 的静态检查跟着扩:这几个模块里直接回的 JSONResponse 错误,内容里也要有 code(改之前 这条检查报出了这两处)。 --- backend/app/api/apps.py | 6 +++--- backend/app/api/coded.py | 2 ++ backend/tests/test_integration_errors.py | 18 ++++++++++++++---- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/backend/app/api/apps.py b/backend/app/api/apps.py index 6d0b281f..8d97e99d 100644 --- a/backend/app/api/apps.py +++ b/backend/app/api/apps.py @@ -21,7 +21,7 @@ from sqlalchemy.ext.asyncio import AsyncSession 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, RUN_LAUNCH_REFUSED, + 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 @@ -841,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)} @@ -912,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} diff --git a/backend/app/api/coded.py b/backend/app/api/coded.py index 27e7ea66..4227b648 100644 --- a/backend/app/api/coded.py +++ b/backend/app/api/coded.py @@ -54,6 +54,8 @@ 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);入口、视图、补算的码 diff --git a/backend/tests/test_integration_errors.py b/backend/tests/test_integration_errors.py index cd8c0262..f3e39b6f 100644 --- a/backend/tests/test_integration_errors.py +++ b/backend/tests/test_integration_errors.py @@ -46,13 +46,23 @@ async def new_app() -> str: return row.id -def test_no_bare_http_exception_left_in_the_integration_modules(): - """这几个模块里不许再直接抛 HTTPException(没有 code):一律 CodedHTTPException。""" +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 isinstance(node, ast.Call) - and (getattr(node.func, "id", None) or getattr(node.func, "attr", None)) == "HTTPException"] + 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():