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