合入 W3-d 邮件、待办汇总、分享链接:邮件渠道(SMTP 可选、口令加密、退信不追踪);每天的待办汇总与访问密钥到期提醒;分享链接(只存哈希、必设到期、只显示定时出数的视图、/s 页不进外壳、下载报告);「最近一次封存」按来源白名单认;使用者拿不到邮件渠道的收件地址;同站门户嵌整站的配置说明

This commit is contained in:
ThearchyHelios
2026-10-16 18:48:07 +08:00
40 changed files with 4168 additions and 171 deletions
+120 -39
View File
@@ -7,6 +7,11 @@
个人渠道谁都能建(权限最低的使用者也能),地址另过一遍 SSRF 检查(tools/net.py,不开内网),保存和发送时各一次:
不然服务端替它往内网地址发 POST,测试接口交回的状态码和报错就成了探测内网的信号(A6.2:只有登记的端点能访问内网)。
全局、应用的渠道只有搭建者以上能建,和定时任务的 Webhook 同一个信任级别,地址照旧只查格式(内网的接收端很常见)。
邮件(第五种格式,notify/mail.py,04 细案 B2):个人邮件渠道不收地址,只发到本人在名录里的账号邮箱(能填任意地址的话,个人
渠道就成了谁都能用的邮件中继);全局、应用的邮件渠道填固定的收件地址,最多 20 个。邮件服务(设置的 mail 一组)没启用时
不收新的邮件渠道:建了也发不出去,就是「看着生效、其实没生效」。渠道列表另给 mail:邮件服务启没启用、本人的账号邮箱,
设置页据此决定列不列「邮件」这一格式、个人邮件渠道写「发到账号邮箱 …」。
"""
from __future__ import annotations
@@ -20,7 +25,7 @@ from app.api.deps import current_principal
from app.core.crypto import encrypt
from app.db.base import SessionLocal
from app.db.models import NotifyChannel, NotifyDelivery
from app.notify import core
from app.notify import core, mail
from app.notify.service import EVENTS, channel_out, subscribe_problem
router = APIRouter(prefix="/api/notify", tags=["notify"])
@@ -64,25 +69,76 @@ async def _safe_personal(scope: str, url: str) -> None:
def _validated(payload: ChannelIn, *, url: str | None, saved: list[str] | None = None) -> None:
"""saved 是渠道已经订阅着的事件:只核对这次新勾的(notify/service.subscribe_problem)。以前存下的照旧——当时界面对
哪种渠道都列全部事件,现在才按事件推给哪几条路核对,不能让这些渠道连开关、改名都 422。"""
if payload.format not in core.FORMATS:
raise HTTPException(422, f"消息格式只能是{'、'.join(core.FORMAT_LABEL.values())}之一")
if payload.format not in core.CHANNEL_FORMATS:
raise HTTPException(422, f"消息格式只能是{'、'.join(core.FORMAT_LABEL[f] for f in core.CHANNEL_FORMATS)}之一")
for event in dict.fromkeys(payload.events):
if event not in (saved or ()) and (problem := subscribe_problem(event, payload.scope)):
raise HTTPException(422, problem)
if url is not None and (problem := core.format_problem(payload.format, url)):
if url is not None and payload.format != "email" and (problem := core.format_problem(payload.format, url)):
raise HTTPException(422, problem)
#: 个人邮件渠道带了地址:不收(见模块说明)
PERSONAL_MAIL_NO_URL = "个人邮件渠道只发到你的账号邮箱,不用填地址"
async def _mail_target(scope: str, url: str | None, *, saved: str | None = None) -> str:
"""邮件渠道 url 列存什么(明文,调用方加密):个人渠道存占位,全局、应用的是校验过的收件地址(逗号分隔)。
url 没给、原来就是邮件渠道的(saved)沿用原来的地址。邮件服务没启用时不收(见模块说明)。"""
from app.db.base import SessionLocal
async with SessionLocal() as session:
cfg = await mail.load_config(session)
if not cfg.enabled:
raise HTTPException(422, mail.NOT_ENABLED)
if scope == "user":
if url and url.strip():
raise HTTPException(422, PERSONAL_MAIL_NO_URL)
return mail.PERSONAL_PLACEHOLDER
if not (url and url.strip()):
if saved:
return saved
raise HTTPException(422, "请填写收件地址,多个地址用逗号或换行分隔")
try:
return ", ".join(mail.parse_recipients(url))
except ValueError as e:
raise HTTPException(422, str(e)) from e
async def _owner_emails(rows: list[NotifyChannel]) -> dict[str, str | None]:
"""个人邮件渠道的拥有者 → 账号邮箱(channel_out 写「发到账号邮箱 …」用)。"""
from app.db.models import User
owners = {r.owner_id for r in rows if r.scope == "user" and r.format == "email" and r.owner_id}
if not owners:
return {}
async with SessionLocal() as session:
found = (await session.execute(select(User.id, User.email).where(User.id.in_(owners)))).all()
return {uid: email for uid, email in found}
@router.get("/channels")
async def list_channels() -> dict[str, Any]:
"""{items}:形状同前端的接口约定(frontend/src/api/capabilities.ts)。"""
"""{items, mail}:形状同前端的接口约定(frontend/src/api/capabilities.ts)。mail 是邮件服务启没启用、本人的账号邮箱
(关登录时没有账号,为 null):设置页据此列不列「邮件」格式、个人邮件渠道发到哪。
全局、应用渠道对谁都列(应用的通知里要挑渠道),但邮件渠道的收件地址只给改得了它的人(同 _can_edit:关登录时谁都能改,
开了登录要搭建者以上),别人只拿到条数(channel_out 的 recipients)。"""
from app.db.models import User
principal = current_principal()
stmt = select(NotifyChannel).order_by(NotifyChannel.created_at)
if principal.mode != "off":
# 个人渠道只给本人看
stmt = stmt.where(or_(NotifyChannel.scope != "user", NotifyChannel.owner_id == principal.user_id))
async with SessionLocal() as session:
return {"items": [channel_out(r) for r in (await session.execute(stmt)).scalars()]}
rows = list((await session.execute(stmt)).scalars())
cfg = await mail.load_config(session)
mine = await session.get(User, principal.user_id) if principal.mode != "off" and principal.user_id else None
emails = await _owner_emails(rows)
editor = principal.mode == "off" or principal.at_least("builder")
return {"items": [channel_out(r, emails.get(r.owner_id or ""), recipients=editor) for r in rows],
"mail": {"enabled": cfg.enabled, "account_email": (mine.email or None) if mine is not None else None}}
@router.get("/events")
@@ -96,19 +152,25 @@ async def list_events() -> list[dict[str, Any]]:
async def create_channel(payload: ChannelIn) -> dict[str, Any]:
owner = current_principal().user_id if payload.scope == "user" else payload.owner_id
_can_edit(payload.scope, owner)
try:
url = core.check_url(payload.url)
except ValueError as e:
raise HTTPException(422, str(e)) from e
_validated(payload, url=url)
await _safe_personal(payload.scope, url)
if payload.format == "email":
_validated(payload, url=None)
url = await _mail_target(payload.scope, payload.url)
secret = None # 邮件没有签名密钥
else:
try:
url = core.check_url(payload.url)
except ValueError as e:
raise HTTPException(422, str(e)) from e
_validated(payload, url=url)
await _safe_personal(payload.scope, url)
secret = encrypt(payload.secret) if payload.secret else None
row = NotifyChannel(name=payload.name.strip(), scope=payload.scope, owner_id=owner, format=payload.format,
url=encrypt(url), secret=encrypt(payload.secret) if payload.secret else None,
events=list(dict.fromkeys(payload.events)), enabled=payload.enabled)
url=encrypt(url), secret=secret, events=list(dict.fromkeys(payload.events)),
enabled=payload.enabled)
async with SessionLocal() as session:
session.add(row)
await session.commit()
return channel_out(row)
return channel_out(row, (await _owner_emails([row])).get(row.owner_id or ""))
class ChannelPatch(BaseModel):
@@ -144,29 +206,40 @@ async def update_channel(channel_id: str, payload: ChannelIn) -> dict[str, Any]:
_can_edit(row.scope, row.owner_id)
if payload.scope != row.scope:
raise HTTPException(422, "通知渠道的范围建好之后不能修改,请新建一个")
url = None
if payload.url:
try:
url = core.check_url(payload.url)
except ValueError as e:
raise HTTPException(422, str(e)) from e
if url is None:
from app.core.crypto import decrypt
from app.core.crypto import decrypt
current = decrypt(row.url) or ""
_validated(payload, url=current or None, saved=list(row.events or []))
url = None
if payload.format == "email":
_validated(payload, url=None, saved=list(row.events or []))
# 只开关、改名、改订阅(地址不传)的不重新核对邮件服务:停用了邮件服务,已有的渠道照样能关掉、改名
if payload.url or row.format != "email":
saved = decrypt(row.url) if row.format == "email" and row.scope != "user" else None
row.url = encrypt(await _mail_target(row.scope, payload.url, saved=saved))
row.secret = None
else:
_validated(payload, url=url, saved=list(row.events or []))
await _safe_personal(row.scope, url)
row.url = encrypt(url)
if payload.url:
try:
url = core.check_url(payload.url)
except ValueError as e:
raise HTTPException(422, str(e)) from e
elif row.format == "email":
# 邮件渠道换成 Webhook:原来存的是收件地址,不是能发的 Webhook 地址
raise HTTPException(422, "换成 Webhook 格式要填写 Webhook 地址")
if url is None:
current = decrypt(row.url) or ""
_validated(payload, url=current or None, saved=list(row.events or []))
else:
_validated(payload, url=url, saved=list(row.events or []))
await _safe_personal(row.scope, url)
row.url = encrypt(url)
if payload.secret is not None:
row.secret = encrypt(payload.secret) if payload.secret else None
row.name, row.format = payload.name.strip(), payload.format
row.events, row.enabled = list(dict.fromkeys(payload.events)), payload.enabled
if payload.secret is not None:
row.secret = encrypt(payload.secret) if payload.secret else None
if row.scope == "app":
row.owner_id = payload.owner_id
await session.commit()
return channel_out(row)
return channel_out(row, (await _owner_emails([row])).get(row.owner_id or ""))
@router.delete("/channels/{channel_id}", status_code=204)
@@ -191,7 +264,8 @@ async def test_channel(channel_id: str) -> dict[str, Any]:
raise HTTPException(404, "通知渠道不存在,可能已被删除")
_can_edit(row.scope, row.owner_id)
message = {"event": "test", "title": f"通知渠道「{row.name}」测试", "ok": True, "retry": False,
"lines": [("说明", "这是一条测试消息。收到即表示渠道地址配置正确"
"lines": [("说明", ("这是一封测试邮件。收到即表示邮件服务和收件地址配置正确" if row.format == "email" else
"这是一条测试消息。收到即表示渠道地址配置正确")
+ (";请求头带有签名,接收端可据此核对签名密钥" if row.secret and row.format == "generic"
else ""))]}
outcome = await send(row, message)
@@ -201,6 +275,10 @@ async def test_channel(channel_id: str) -> dict[str, Any]:
attempts=outcome.attempts, error=outcome.error))
await session.commit()
if outcome.ok:
if row.format == "email":
# 交给了邮件服务器不等于进了收件箱:退信不追踪,照实说
return {"ok": True, "http_status": None,
"message": f"{outcome.error or mail.HANDED_OFF},是否送达以邮件服务器为准,请到收件箱确认"}
return {"ok": True, "http_status": outcome.http_status, "message": "请到接收端确认已收到"}
return {"ok": False, "http_status": outcome.http_status, "message": outcome.error or "发送失败"}
@@ -212,12 +290,15 @@ async def list_deliveries(
task_id: str | None = None,
limit: int = Query(default=100, ge=1, le=500),
) -> dict[str, Any]:
"""投递记录。开登录后 user 角色只看发往自己的个人渠道的:别的渠道的投递记录里有所有应用、所有运行的待办和动作。"""
stmt = select(NotifyDelivery).order_by(NotifyDelivery.created_at.desc()).limit(limit)
"""投递记录。开登录后 user 角色只看发往自己的个人渠道的:别的渠道的投递记录里有所有应用、所有运行的待办和动作。
format 是渠道的格式:邮件渠道成功的那条界面上写「已交给邮件服务器」,不写「已送达」(退信不追踪)。"""
stmt = (select(NotifyDelivery, NotifyChannel.format)
.join(NotifyChannel, NotifyChannel.id == NotifyDelivery.channel_id)
.order_by(NotifyDelivery.created_at.desc()).limit(limit))
principal = current_principal()
if principal.mode != "off" and not principal.at_least("builder"):
stmt = stmt.join(NotifyChannel, NotifyChannel.id == NotifyDelivery.channel_id).where(
NotifyChannel.scope == "user", NotifyChannel.owner_id == principal.user_id)
stmt = stmt.where(NotifyChannel.scope == "user", NotifyChannel.owner_id == principal.user_id)
if channel_id:
stmt = stmt.where(NotifyDelivery.channel_id == channel_id)
if run_id:
@@ -225,9 +306,9 @@ async def list_deliveries(
if task_id:
stmt = stmt.where(NotifyDelivery.task_id == task_id)
async with SessionLocal() as session:
rows = (await session.execute(stmt)).scalars()
rows = (await session.execute(stmt)).all()
return {"items": [{
"id": r.id, "channel_id": r.channel_id, "subject_kind": r.subject_kind, "subject_id": r.subject_id,
"event": r.event, "run_id": r.run_id, "task_id": r.task_id, "status": r.status,
"http_status": r.http_status, "attempts": r.attempts, "error": r.error,
"created_at": r.created_at.isoformat() if r.created_at else None} for r in rows]}
"http_status": r.http_status, "attempts": r.attempts, "error": r.error, "format": fmt,
"created_at": r.created_at.isoformat() if r.created_at else None} for r, fmt in rows]}
+95 -13
View File
@@ -613,8 +613,9 @@ def catalog_draft_values(stored: Any) -> dict[str, Any]:
#: 全站只认一个对外地址(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: "对外访问地址"}
#: digest_at:每天几点给订阅了「每天的待办汇总」的个人渠道发汇总(HH:MM,按服务端所在时区;notify/digest.py,04 细案 B3)
NOTIFY_DEFAULTS: dict[str, Any] = {public_url.SETTING_KEY: None, "digest_at": "09:00"}
NOTIFY_SETTING_LABEL = {public_url.SETTING_KEY: "对外访问地址", "digest_at": "待办汇总的发送时间"}
#: GET /api/settings 另给的只读一组:{value: 生效的对外访问地址, env_locked: 是不是环境变量指定的}。设置页据此显示
#: 「由环境变量指定」;PUT 带上它也不落库(同 limits)
PUBLIC_URL_GROUP = "public_url"
@@ -641,15 +642,25 @@ def notify_problem(value: Any) -> str | None:
if key not in NOTIFY_DEFAULTS:
return (f"「通知」设置中不支持「{key}」,可设置的项:"
+ "、".join(f"「{NOTIFY_SETTING_LABEL[k]}」" for k in NOTIFY_DEFAULTS))
if problem := _base_url_problem(item):
if key == "digest_at":
from app.notify.digest import time_ok
if not time_ok(item):
return f"「{NOTIFY_SETTING_LABEL[key]}」需要是 24 小时制的时间,例如 09:00(从 00:00 到 23:59),当前为「{item}」"
elif problem := _base_url_problem(item):
return problem
return None
def notify_values(stored: Any) -> dict[str, Any]:
"""库里存的(或刚写进来的)notify 一组 → 补齐默认、规范化:去掉首尾空白和末尾的 /,空串当没填;存坏了的按没填。"""
"""库里存的(或刚写进来的)notify 一组 → 补齐默认、规范化:去掉首尾空白和末尾的 /,空串当没填;存坏了的按没填。
汇总时间存坏了按缺省。"""
from app.notify.digest import time_ok
stored = stored if isinstance(stored, dict) else {}
return {public_url.SETTING_KEY: public_url.normalize(stored.get(public_url.SETTING_KEY))}
at = stored.get("digest_at")
return {public_url.SETTING_KEY: public_url.normalize(stored.get(public_url.SETTING_KEY)),
"digest_at": at.strip() if time_ok(at) else NOTIFY_DEFAULTS["digest_at"]}
async def notify_settings(session: AsyncSession) -> dict[str, Any]:
@@ -676,8 +687,11 @@ def public_url_values(stored_notify: Any) -> dict[str, Any]:
#: user_personal_apps:开了登录时使用者能不能建个人应用、刷新、提交审核(默认关,同问数据的开关;搭建者及以上不受它管)。
#: 关登录时它照样生效:关着就没有个人应用的入口。只有管理员能改(整个设置接口的改动都要管理员)
APPS_GROUP = "apps"
APPS_DEFAULTS: dict[str, Any] = {"on_demand_daily_limit": 200, "user_personal_apps": False}
APPS_SETTING_LABEL = {"on_demand_daily_limit": "按需视图每日上限", "user_personal_apps": "使用者可以建个人应用"}
#: share_links:能不能建分享链接(只读、不用登录就能看的视图页,api/shares.py,04 细案 C2、集成-I6)。缺省关;关掉时已有的
#: 分享链接一并打不开(管理员随时收得回来),撤销照常能做
APPS_DEFAULTS: dict[str, Any] = {"on_demand_daily_limit": 200, "user_personal_apps": False, "share_links": False}
APPS_SETTING_LABEL = {"on_demand_daily_limit": "按需视图每日上限", "user_personal_apps": "使用者可以建个人应用",
"share_links": "允许建分享链接"}
#: 上限的上限:再大就等于不限,要不限的不该靠这一项
APPS_DAILY_LIMIT_MAX = 100_000
@@ -694,7 +708,7 @@ def apps_problem(value: Any) -> str | None:
if key not in APPS_DEFAULTS:
return (f"「应用」设置中不支持「{key}」,可设置的项:"
+ "、".join(f"「{APPS_SETTING_LABEL[k]}」" for k in APPS_DEFAULTS))
if key == "user_personal_apps":
if key in ("user_personal_apps", "share_links"):
if not isinstance(item, bool):
return f"「{APPS_SETTING_LABEL[key]}」只能是开或关,当前为「{item}」"
elif not _daily_limit_ok(item):
@@ -708,8 +722,10 @@ def apps_values(stored: Any) -> dict[str, Any]:
stored = stored if isinstance(stored, dict) else {}
limit = stored.get("on_demand_daily_limit")
personal = stored.get("user_personal_apps")
shares = stored.get("share_links")
return {"on_demand_daily_limit": limit if _daily_limit_ok(limit) else APPS_DEFAULTS["on_demand_daily_limit"],
"user_personal_apps": personal if isinstance(personal, bool) else APPS_DEFAULTS["user_personal_apps"]}
"user_personal_apps": personal if isinstance(personal, bool) else APPS_DEFAULTS["user_personal_apps"],
"share_links": shares if isinstance(shares, bool) else APPS_DEFAULTS["share_links"]}
async def apps_settings(session: AsyncSession) -> dict[str, Any]:
@@ -843,6 +859,15 @@ async def eval_settings(session: AsyncSession) -> dict[str, Any]:
return eval_values(row.value if row else None)
# --------------------------------------------------------------------------
# 邮件服务(notify/mail.py,04 细案 B2):只有管理员能改(整个设置接口的改动都要管理员),口令加密存、读的时候只说配没配
# --------------------------------------------------------------------------
MAIL_GROUP = "mail"
#: 发测试邮件没有可发的账号(关登录)、账号没有邮箱时 POST /api/settings/mail/test 回 422 的机读码
MAIL_TEST_NO_ADDRESS = "mail_test_no_address"
# --------------------------------------------------------------------------
# 用户设置
# --------------------------------------------------------------------------
@@ -856,6 +881,19 @@ def _hidden_groups() -> tuple[str, ...]:
return (AUTH_KEY,)
def _mail_public(stored: Any) -> dict[str, Any]:
from app.notify import mail
return mail.public_value(stored)
def _internal(key: str) -> bool:
"""通知自己记的账(每天汇总的认领行、自动建过邮件渠道的人,notify/digest.STATE_PREFIX):不是用户设置,不列、不收。"""
from app.notify.digest import STATE_PREFIX
return key.startswith(STATE_PREFIX)
DEFAULT_SETTINGS: dict[str, Any] = {
"ui": {"theme": "system", "language": "zh", "canvas_snap": True, "autosave": True},
"run": {
@@ -886,12 +924,14 @@ DEFAULT_SETTINGS: dict[str, Any] = {
NOTIFY_GROUP: notify_values(None),
# 起草模型:数据目录起草、按配方导入的 AI 起草用的模型,留空沿用助手的模型
CATALOG_DRAFT_GROUP: catalog_draft_values(None),
# 应用:按需视图的每日上限、使用者能不能建个人应用(管理员在设置页改)
# 应用:按需视图的每日上限、使用者能不能建个人应用、能不能建分享链接(管理员在设置页改)
APPS_GROUP: apps_values(None),
# 口径账本:自动按口径正式出具的开关、每日上限,口径比对的时限
LEDGER_GROUP: ledger_values(None),
# 评测:预算的全站缺省(题集上可以改)、保留几次明细、结论裁判的缺省开关
EVAL_GROUP: eval_values(None),
# 邮件服务(SMTP):通知渠道的邮件格式用它发信。缺省关
MAIL_GROUP: _mail_public(None),
}
@@ -947,6 +987,7 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
stored.pop(EVAL_SPEND, None)
for key in _hidden_groups():
stored.pop(key, None)
stored = {k: v for k, v in stored.items() if not _internal(k)}
merged = {k: {**v, **(stored.get(k) or {})} for k, v in DEFAULT_SETTINGS.items()}
# 裁判、核查设置按引擎实际生效的样子给:存坏了的项显示默认,存了 null 的上限显示不限
merged[JUDGE_GROUP] = judge_values(stored.get(JUDGE_GROUP))
@@ -957,6 +998,8 @@ async def get_settings(session: AsyncSession = Depends(get_session)) -> dict[str
merged[APPS_GROUP] = apps_values(stored.get(APPS_GROUP))
merged[LEDGER_GROUP] = ledger_values(stored.get(LEDGER_GROUP))
merged[EVAL_GROUP] = eval_values(stored.get(EVAL_GROUP))
# 口令不回(password 恒为 null),只说配没配(password_set)
merged[MAIL_GROUP] = _mail_public(stored.get(MAIL_GROUP))
for key, value in stored.items():
merged.setdefault(key, value)
return merged
@@ -1046,8 +1089,10 @@ async def put_settings(
if problem := notify_problem(payload.values[NOTIFY_GROUP]):
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):
# 只给了一部分的(只改汇总时间)补上存着的另几项,不把对外访问地址冲成没填
payload.values[NOTIFY_GROUP] = notify_values({**await notify_settings(session), **payload.values[NOTIFY_GROUP]})
current_url = (await notify_settings(session))[public_url.SETTING_KEY]
if public_url.env_locked() and payload.values[NOTIFY_GROUP][public_url.SETTING_KEY] != current_url:
# 环境变量指定了对外访问地址:改这一项不会生效,照实拒绝,不悄悄存一个用不上的值。原样写回(整组回写)不算改
raise CodedHTTPException(
409, f"对外访问地址由环境变量 {public_url.ENV_NAME} 指定({public_url.env_url()}),不能在设置里修改。"
@@ -1064,10 +1109,18 @@ async def put_settings(
if problem := eval_problem(payload.values[EVAL_GROUP]):
raise HTTPException(422, problem)
payload.values[EVAL_GROUP] = eval_values(payload.values[EVAL_GROUP])
if MAIL_GROUP in payload.values:
from app.notify import mail
saved_mail = await session.get(Setting, MAIL_GROUP)
saved_value = saved_mail.value if saved_mail is not None else None
if problem := mail.settings_problem(payload.values[MAIL_GROUP], saved_value):
raise HTTPException(422, problem)
payload.values[MAIL_GROUP] = mail.stored_value(payload.values[MAIL_GROUP], saved_value)
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", PUBLIC_URL_GROUP, JUDGE_SPEND, VERIFY_SPEND, EVAL_SPEND, *_hidden_groups()):
if key in (TRUST_KEY, "limits", PUBLIC_URL_GROUP, JUDGE_SPEND, VERIFY_SPEND, EVAL_SPEND, *_hidden_groups()) or _internal(key):
# 信任三档只走 PUT /api/tools/trust:设置页整组回写时带着一份旧的,
# 会把刚在工具页改的档位冲掉。limits 是环境变量定的、public_url 是算出来的,写进库也不生效。
# 裁判、核查的每日计数是引擎记的账,只有引擎自己写。登录设置只经 /api/auth/settings 改(_hidden_groups)
@@ -1084,6 +1137,35 @@ async def put_settings(
return await get_settings(session)
@router.post("/settings/mail/test")
async def test_mail(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""按存着的邮件服务设置发一封测试邮件,发到管理员自己的账号邮箱(只发到认证过的账号,不收任意地址:不让这个接口成了
邮件中继)。没启用也能测(配好了先试一封再启用);不重试。{ok, message}:成功说「已交给邮件服务器」,是否送达以邮件服务器
为准(退信不追踪)。关登录时没有账号、账号没有邮箱:422(mail_test_no_address),说清怎么办。"""
from app.api.deps import current_principal
from app.db.models import User
from app.notify import mail
from app.notify.core import Message
principal = current_principal()
if principal.mode == "off" or not principal.user_id:
raise CodedHTTPException(422, "关闭登录时没有账号邮箱,测试邮件发不到人:请建一个全局的邮件渠道、填上收件地址,"
"用渠道的「发送测试消息」试", MAIL_TEST_NO_ADDRESS)
user = await session.get(User, principal.user_id)
to = (user.email or "").strip() if user is not None else ""
if not mail.address_ok(to):
raise CodedHTTPException(422, f"{mail.NO_EMAIL},再发测试邮件", MAIL_TEST_NO_ADDRESS)
row = await session.get(Setting, MAIL_GROUP)
cfg = mail.config_of(row.value if row is not None else None)
if not cfg.ready:
return {"ok": False, "message": f"请先填写并保存「{mail.LABEL['host']}」和「{mail.LABEL['from_addr']}」,再发测试邮件"}
msg = Message(title="邮件服务测试", ok=True, lines=[("说明", "这是一封测试邮件。收到即表示邮件服务配置正确")])
outcome = await mail.send(cfg, [to], msg, label="测试邮件", retry=False)
if outcome.ok:
return {"ok": True, "message": f"{mail.HANDED_OFF}(发到 {to}),是否送达以邮件服务器为准,请到收件箱确认"}
return {"ok": False, "message": outcome.error or "发送失败"}
@router.get("/settings/judge/spend")
async def get_judge_spend(session: AsyncSession = Depends(get_session)) -> dict[str, Any]:
"""今天(本地日期)结论句裁判花了多少,设置页放在每日上限旁边。只读:计数只有裁判自己记。
+326 -5
View File
@@ -1,10 +1,331 @@
"""分享链接(docs/design/next-round/04-对外集成与触达.md C2)。
"""分享链接(docs/design/next-round/04-对外集成与触达.md C2,集成-I6):拿到链接、不用登录就能看应用的一页(只读,可以嵌进门户)。
读分享页的接口不用登录,只认请求头里的分享 key(中间件把 /api/shares/ 当公开前缀,第 3 波 W3-d 加);建、撤销分享在
/api/apps/{id}/shares 下,要应用的 manage 权限。第 3 波 W3-d 填。
为什么要有:会话 cookie 是 SameSite=Lax,跨站的 iframe 里带不上——想把一块看板放进别的门户,只能让每个看的人都开账号,
而且门户和 AgentLab 必须同站(同站门户嵌整站见 deploy/agentlab.env 的 AGENTLAB_FRAME_ANCESTORS,C3)。分享链接是跨站嵌入
唯一走得通的办法,所以做;但它等于把数据交出了授权范围,所以每一步都往窄处取:
- 管理员开关 apps.share_links,缺省关;关掉时已有的链接一并打不开(403 share_links_off),撤销照常能做;
- 只有应用的 manage 权限能建、能撤销;个人应用(personal.not_shareable)、没发布的、归档了的不能分享;
- 到期必填,最长 EXPIRES_MAX_DAYS 天;撤销、到期之后打开回 410,写明是撤销了还是到期了、什么时候;
- 只读:分享页只解析定时出数(latest_sealed)的视图,按需视图、实例、待办、发起按钮、记录列表换成写明原因的说明,从不
发起运行(apps/resolve.py 的 share 旁路);看到的就是使用者看到的那份遮罩视图(视图对所有人都遮);
- 出处不走在线的证据接口(那等于给匿名的人一个按运行读证据的身份):分享页给「下载报告」,GET /api/shares/report.html 只认
这条分享此刻解析出来的那几次运行,内部用的就是报告导出 export_report_html(同样遮过,文件自己离线点得开出处);
- key 放在链接的 # 后面(/s#<key>):打开页面时不发给服务器、不进代理日志和 Referer;读的接口只认请求头 X-AgentLab-Share,
地址里带的一概不认。库里只存 key 的 sha256 和开头一截;
- 建、撤销写审计(app.share_create / app.share_revoke);打开只计数(open_count、last_opened_at)。
接口:
- POST / GET /api/apps/{app_id}/shares、DELETE /api/apps/{app_id}/shares/{share_id}:登录即可进来,接口里要应用的 manage;
- GET /api/shares/page、GET /api/shares/report.html:公开路由(中间件把 /api/shares/ 当公开前缀,不用登录、不看会话),
只认分享 key。
都不放给访问密钥(不在 auth/keys.KEY_ROUTES 里,门禁一律 403):分享是给人看的页面,集成要拉数走视图数据接口。
安全头:分享页 /s 用嵌入白名单 AGENTLAB_EMBED_ORIGINS 和 Referrer-Policy: no-referrer(web_security.py);这一条分享没有允许
嵌入(embed_allowed 为假)时,页面在别的页面的 iframe 里不显示内容(服务端在 /s 上只能按全站的白名单发头:那一刻还不知道是
哪一条分享,key 不会发到服务器)。
"""
from __future__ import annotations
from fastapi import APIRouter
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
router = APIRouter(prefix="/api/shares", tags=["shares"])
from fastapi import APIRouter, Depends, Header, Query
from pydantic import BaseModel
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.coded import CodedHTTPException
from app.api.deps import Principal, actor_of, require_role
from app.db.base import get_session, utcnow
from app.db.models_apps import App, AppVersion, ViewShare
router = APIRouter(tags=["shares"])
_manage = APIRouter(prefix="/api/apps")
_public = APIRouter(prefix="/api/shares")
#: 读接口认的请求头
SHARE_HEADER = "X-AgentLab-Share"
#: 到期最长多少天
EXPIRES_MAX_DAYS = 90
#: key 的随机字节数、库里存的开头几位
KEY_BYTES = 32
PREFIX_LEN = 6
#: 分享页的地址(key 接在 # 后面)
SHARE_PATH = "/s"
SHARE_FORMAT = "agentlab.share/1"
SHARE_LINKS_OFF = "share_links_off"
SHARE_KEY_MISSING = "share_key_missing"
SHARE_NOT_FOUND = "share_not_found"
SHARE_REVOKED = "share_revoked"
SHARE_EXPIRED = "share_expired"
SHARE_GONE = "share_gone"
SHARE_EXPIRES_INVALID = "share_expires_invalid"
SHARE_NO_SEALED_VIEW = "share_no_sealed_view"
SHARE_RUN_NOT_SHARED = "share_run_not_shared"
SHARE_ID_NOT_FOUND = "share_id_not_found"
LINKS_OFF_CREATE = "管理员没有打开分享链接:请管理员在「设置 → 偏好设置」的「应用」一节里打开「允许建分享链接」"
LINKS_OFF_OPEN = "管理员已关闭分享链接,这条链接暂时打不开"
def key_hash(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
def _when(moment: datetime | None) -> str:
if moment is None:
return ""
aware = moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
return aware.astimezone().strftime("%Y-%m-%d %H:%M")
def _aware(moment: datetime) -> datetime:
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
def state_of(row: ViewShare, now: datetime | None = None) -> str:
"""active 能打开;revoked 撤销了;expired 过了到期时间。"""
if row.revoked_at is not None:
return "revoked"
if _aware(row.expires_at) <= (now or utcnow()):
return "expired"
return "active"
def share_out(row: ViewShare, titles: dict[str, str] | None = None) -> dict[str, Any]:
"""一条分享的接口形状(typesApp.ts 的 ViewShareRow):key 不回(只在建的时候给一次),给开头一截认是哪一条。"""
iso = lambda m: _aware(m).isoformat() if m else None # noqa: E731
return {"id": row.id, "prefix": row.prefix, "app_id": row.app_id, "page_id": row.page_id,
"page_title": (titles or {}).get(row.page_id) or row.page_id, "mode": row.mode,
"app_version": row.app_version, "embed_allowed": row.embed_allowed, "created_by": row.created_by,
"created_by_name": row.created_by_name, "created_at": iso(row.created_at),
"expires_at": iso(row.expires_at), "revoked_at": iso(row.revoked_at), "revoked_by": row.revoked_by,
"open_count": row.open_count, "last_opened_at": iso(row.last_opened_at), "state": state_of(row)}
async def _enabled(session: AsyncSession) -> bool:
from app.api.settings import apps_settings
return bool((await apps_settings(session)).get("share_links"))
def _titles(spec: dict[str, Any] | None) -> dict[str, str]:
return {str(p.get("id")): str(p.get("title") or p.get("id")) for p in (spec or {}).get("pages") or []
if isinstance(p, dict) and p.get("id")}
# --------------------------------------------------------------------------
# 建、列、撤销(要应用的 manage)
# --------------------------------------------------------------------------
class ShareIn(BaseModel):
page_id: str
mode: Literal["latest", "fixed"] = "latest"
#: 到期时间,必填(不给回 422 share_expires_invalid);不带时区的按 UTC
expires_at: datetime | None = None
embed_allowed: bool = False
async def _managed(session: AsyncSession, app_id: str, x_actor: str | None) -> tuple[App, Principal]:
from app.api.apps import _app, _principal
principal = _principal(x_actor)
app, _ = await _app(session, app_id, principal, "manage")
return app, principal
@_manage.post("/{app_id}/shares", status_code=201)
async def create_share(app_id: str, payload: ShareIn, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""建一条分享链接:要应用的 manage、管理员开了 apps.share_links。key 只在这里交回一次(path 是 /s#<key>,url 是拼上
对外访问地址的完整链接,没设对外访问地址时为 null,界面按当前页面的地址拼)。"""
from app.api.apps import _current_version, _refuse_archived, _refuse_personal
from app.apps.audit import record
from app.apps.resolve import SharedView, share_runs, sealed_views
from app.apps.tree import page_of
from app.core import public_url
app, principal = await _managed(session, app_id, x_actor)
if not await _enabled(session):
raise CodedHTTPException(403, LINKS_OFF_CREATE, SHARE_LINKS_OFF)
_refuse_personal(app, "建分享链接")
_refuse_archived(app, "建分享链接")
version = await _current_version(session, app)
now = utcnow()
if payload.expires_at is None:
raise CodedHTTPException(422, f"分享链接必须设到期时间,最长 {EXPIRES_MAX_DAYS} 天", SHARE_EXPIRES_INVALID,
field="expires_at")
expires = _aware(payload.expires_at)
if expires <= now + timedelta(minutes=1):
raise CodedHTTPException(422, "到期时间要在现在之后", SHARE_EXPIRES_INVALID, field="expires_at")
# 留一小时的余量:界面按本机时间算「90 天后」,本机比服务端快一点不该被拒
if expires > now + timedelta(days=EXPIRES_MAX_DAYS, hours=1):
raise CodedHTTPException(422, f"分享链接最长 {EXPIRES_MAX_DAYS} 天到期,请选更早的到期时间",
SHARE_EXPIRES_INVALID, field="expires_at")
spec = version.spec or {}
if page_of(spec, payload.page_id) is None:
raise CodedHTTPException(404, f"应用的当前版本里没有页面「{payload.page_id}」", "app_page_not_found")
if not sealed_views(spec, payload.page_id):
raise CodedHTTPException(409, "这一页没有定时出数的视图(取最近一次封存运行的视图):分享链接只显示这种视图,"
"分享出去什么都看不到", SHARE_NO_SEALED_VIEW)
runs: dict[str, str | None] | None = None
if payload.mode == "fixed":
found = await share_runs(session, spec, payload.page_id, SharedView(mode="latest", key=""))
runs = {vid: (run.id if run is not None else None) for vid, run in found.items()}
if not any(runs.values()):
raise CodedHTTPException(409, "这一页定时出数的视图都还没有封存的运行,没有可以固定的数:先用「始终最新」,"
"或者等第一次出数之后再建", SHARE_NO_SEALED_VIEW)
key = secrets.token_urlsafe(KEY_BYTES)
row = ViewShare(key_hash=key_hash(key), prefix=key[:PREFIX_LEN], app_id=app.id, page_id=payload.page_id,
mode=payload.mode, app_version=version.version, runs=runs, embed_allowed=payload.embed_allowed,
created_by=actor_of(x_actor), created_by_name=principal.display_name or actor_of(x_actor),
created_at=now, expires_at=expires)
session.add(row)
await session.flush()
await record(session, action="app.share_create", subject_kind="view_share", subject_id=row.id,
actor=actor_of(x_actor), data={"app_id": app.id, "page_id": payload.page_id, "mode": payload.mode,
"app_version": version.version, "expires_at": expires.isoformat(),
"embed_allowed": payload.embed_allowed, "prefix": row.prefix})
await session.commit()
path = f"{SHARE_PATH}#{key}"
base = public_url.public_base_url()
return {**share_out(row, _titles(spec)), "key": key, "path": path, "url": f"{base}{path}" if base else None}
@_manage.get("/{app_id}/shares")
async def list_shares(app_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""这个应用的分享链接(新的在前)。enabled 是管理员开没开(关着时界面不给新建,已有的照列、能撤销);embed_origins 是部署
配没配允许嵌入分享页的站点(AGENTLAB_EMBED_ORIGINS);max_days 是到期最长几天。"""
from app.api.apps import _previous
from app.core.config import settings
app, _ = await _managed(session, app_id, x_actor)
rows = list((await session.execute(select(ViewShare).where(ViewShare.app_id == app.id)
.order_by(ViewShare.created_at.desc()))).scalars())
version = await _previous(session, app)
titles = _titles(version.spec if version is not None else None)
return {"enabled": await _enabled(session), "embed_origins": bool(settings.embed_origins),
"max_days": EXPIRES_MAX_DAYS, "items": [share_out(r, titles) for r in rows]}
@_manage.delete("/{app_id}/shares/{share_id}")
async def revoke_share(app_id: str, share_id: str, session: AsyncSession = Depends(get_session),
x_actor: str | None = Header(default=None)) -> dict[str, Any]:
"""撤销:之后打开回 410「链接已撤销」。撤销过的再撤销照旧交回它(不重复记审计)。管理员关了开关也能撤销。"""
from app.apps.audit import record
app, _ = await _managed(session, app_id, x_actor)
row = await session.get(ViewShare, share_id)
if row is None or row.app_id != app.id:
raise CodedHTTPException(404, "分享链接不存在,可能已被删除", SHARE_ID_NOT_FOUND)
if row.revoked_at is None:
row.revoked_at, row.revoked_by = utcnow(), actor_of(x_actor)
await record(session, action="app.share_revoke", subject_kind="view_share", subject_id=row.id,
actor=actor_of(x_actor), data={"app_id": app.id, "page_id": row.page_id, "prefix": row.prefix})
await session.commit()
return share_out(row)
# --------------------------------------------------------------------------
# 打开(公开,只认请求头里的分享 key)
# --------------------------------------------------------------------------
def _share_principal(row: ViewShare) -> Principal:
"""分享身份:不是任何一个人。按使用者算(role=user),用户名为空:审计里不冒充谁,遮罩和使用者看到的一样。"""
from app.auth import current_mode
return Principal(username=None, role="user", display_name=f"分享链接 {row.prefix}", verified=False,
mode=current_mode(), via="share")
async def _opened(session: AsyncSession, key: str | None) -> tuple[ViewShare, App, AppVersion]:
"""按请求头里的 key 认出分享,核对开关、撤销、到期、应用还在不在,交回 (分享, 应用, 要解析的应用版本)。"""
from app.api.apps import _previous
if not key or not key.strip():
raise CodedHTTPException(400, "缺少分享 key:请用完整的分享链接打开(链接里 # 后面的那一段)", SHARE_KEY_MISSING)
row = (await session.execute(select(ViewShare).where(ViewShare.key_hash == key_hash(key.strip())))).scalar_one_or_none()
if row is None:
raise CodedHTTPException(404, "链接无效:可能复制得不完整,请向分享的人确认链接", SHARE_NOT_FOUND)
if not await _enabled(session):
raise CodedHTTPException(403, LINKS_OFF_OPEN, SHARE_LINKS_OFF)
state = state_of(row)
if state == "revoked":
raise CodedHTTPException(410, f"链接已撤销({_when(row.revoked_at)}):请向分享的人要新的链接", SHARE_REVOKED)
if state == "expired":
raise CodedHTTPException(410, f"链接已到期({_when(row.expires_at)}):请向分享的人要新的链接", SHARE_EXPIRED)
app = await session.get(App, row.app_id)
if app is None or app.status == "archived" or getattr(app, "scope", "shared") == "personal" \
or not app.published_version:
raise CodedHTTPException(410, "分享的应用已归档或不再发布,这条链接打不开了", SHARE_GONE)
version = None
if row.mode == "fixed" and row.app_version:
version = (await session.execute(select(AppVersion).where(
AppVersion.app_id == app.id, AppVersion.version == row.app_version))).scalar_one_or_none()
version = version or await _previous(session, app)
if version is None:
raise CodedHTTPException(410, "分享的应用已归档或不再发布,这条链接打不开了", SHARE_GONE)
return row, app, version
def _shared(row: ViewShare) -> Any:
from app.apps.resolve import SharedView
return SharedView(mode=row.mode, key=row.id, runs=dict(row.runs or {}) if row.mode == "fixed" else None)
@_public.get("/page")
async def share_page(session: AsyncSession = Depends(get_session),
share_key: str | None = Header(default=None, alias=SHARE_HEADER)) -> dict[str, Any]:
"""分享页(agentlab.share/1):{format, share: {页脚要的:谁分享、什么时候、什么时候到期、能不能嵌入、模式}, view:
agentlab.view/1(share 旁路解析), downloads: [{view, run_id, reports, as_of_text}](下载报告用)}。打开一次计一次数。"""
from app.apps.resolve import ViewRefused, resolve_page
row, app, version = await _opened(session, share_key)
try:
view = await resolve_page(session, app, page_id=row.page_id, principal=_share_principal(row), role=None,
version=version, share=_shared(row))
except ViewRefused as e:
if e.code == "app_page_not_found":
raise CodedHTTPException(410, "分享的页面在应用的当前版本里已经没有了,这条链接打不开了", SHARE_GONE) from e
raise CodedHTTPException(e.status_code, e.message, e.code or SHARE_GONE) from e
await session.execute(update(ViewShare).where(ViewShare.id == row.id).values(
open_count=ViewShare.open_count + 1, last_opened_at=utcnow()))
await session.commit()
downloads = (view.pop("share", None) or {}).get("runs") or []
return {"format": SHARE_FORMAT,
"share": {"mode": row.mode, "created_by": row.created_by_name or row.created_by,
"created_at": _aware(row.created_at).isoformat(), "expires_at": _aware(row.expires_at).isoformat(),
"embed_allowed": row.embed_allowed},
"view": view, "downloads": downloads}
@_public.get("/report.html")
async def share_report(run: str = Query(...), report: str | None = None, session: AsyncSession = Depends(get_session),
share_key: str | None = Header(default=None, alias=SHARE_HEADER)) -> Any:
"""下载报告:只认这条分享此刻解析出来的那几次运行(apps/resolve.share_runs),别的运行 id 一律 404。内部就是报告导出
(api/evidence.export_report_html:同样遮过,报告文件自己离线点得开出处;导出照常记 report.exported 审计,导出人记成
分享身份——用户名为空,不冒充谁)。"""
from app.api.deps import reset_principal, set_principal
from app.api.evidence import export_report_html
from app.apps.resolve import share_runs
row, _, version = await _opened(session, share_key)
found = await share_runs(session, version.spec or {}, row.page_id, _shared(row))
if run not in {r.id for r in found.values() if r is not None}:
raise CodedHTTPException(404, "这次运行不在这条分享链接里", SHARE_RUN_NOT_SHARED)
token = set_principal(_share_principal(row))
try:
return await export_report_html(run, report)
finally:
reset_principal(token)
router.include_router(_manage, dependencies=[require_role("user")])
router.include_router(_public)
+142 -8
View File
@@ -12,6 +12,14 @@
- instance:实例自己的那次运行。
印章(截至 / 口径 / 出具档位 / 来源)由这里算;定时任务改跑了新版本而应用还钉在旧版本这类错位照实写进 notes。
分享链接(docs/design/next-round/04-对外集成与触达.md C2,api/shares.py)是解析时的另一条旁路(resolve_page 的 share):
拿到链接、不用登录的人看到的就是使用者看到的那份遮罩视图,但只有定时出数的那部分——
- 只解析 latest_sealed 视图(fixed 模式取建分享时存下的那几次运行 id,SharedView.runs);按需、实例、个人应用的探索视图换成
写明原因的空态(SHARE_SEALED_ONLY),从不发起运行(按需视图不自动发起,因为它根本不按按需解析);
- 流程组件(入口按钮、表单、实例、进度、待办、会签、生效记录、记录列表)换成写明原因的说明块(SHARE_PLACEHOLDER),报告
组件也换成说明(正文和出处在「下载报告」里:分享页不开在线证据,那等于给匿名的人一个按运行读证据的身份);
- 印章照常算,界面上常显;页面缓存的键另加分享的标识。
"""
from __future__ import annotations
@@ -44,6 +52,18 @@ PAGE_CACHE_SIZE = 256
#: 按新口径补算的运行来源(engine/origin.py)
BACKFILL_ORIGIN = "app_backfill"
#: 「最近一次封存」认哪几种来源的正式运行:latest_sealed 视图、趋势、分享链接(latest,以及建 fixed 时取的那几次)、视图
#: 数据接口、补算判断「新版本算过没有」都经 sealed_runs,这里改一处都跟上。按白名单认,以后加的来源默认不算,要算的在这里
#: 登记(tests/test_shares.py 核对 ORIGIN_KINDS 每种都归了类)。算的:定时、手动的正式运行、应用的入口和按需视图、入站
#: 钩子、直接调接口;加来源这一列之前的老运行(origin_kind 为空)也算。
SEALED_ORIGINS = ("launcher", "schedule", "app_entry", "app_view", "hook", "api")
#: 不算的,各有原因:
#: - app_backfill:按新口径补算的是现在才算的历史期,不是「最近一次」;趋势另经 trend_runs 按它对应的输入期排进去;
#: - ledger_issue:问数据的答案按已治理口径正式出具(docs/design/next-round/02-口径账本.md §5),参数是从某人的提问里
#: 绑出来的。取到它,看板、趋势、分享链接就悄悄换成了那个人问的那组数;
#: - 其余是探索运行(问数据、画布试跑、试运行、个人应用的刷新,评测、口径对账、口径召回),本来就不是正式运行。
NOT_SEALED_ORIGINS = (BACKFILL_ORIGIN, "ledger_issue", "chat", "canvas", "trial", "personal_app", "eval", "reconcile",
"recall")
VIEW_REFUSED = "app_view_refused"
DAILY_LIMIT = "app_view_daily_limit"
@@ -76,6 +96,36 @@ _PERIOD = re.compile(r"(period|week|month|quarter|year|date|day|期|周|月|季|
_DIGIT = re.compile(r"\d")
#: 分享链接里(share 旁路)不是定时出数的视图、流程组件、报告写什么
SHARE_SEALED_ONLY = "分享链接只显示定时出数的视图:这一块的数要打开应用才看得到"
SHARE_PLACEHOLDER = {
"entry_button": "分享链接是只读的,不能在这里发起",
"form": "分享链接是只读的,不能在这里换参数",
"instance_list": "分享链接不列实例:要看实例请打开应用",
"instance_header": "分享链接不显示实例:要看实例请打开应用",
"instance_timeline": "分享链接不显示实例进度:要看请打开应用",
"task_list": "分享链接不列待办:待办只给派给的人,请打开应用处理",
"task_form": "分享链接不显示待办:待办只给派给的人,请打开应用处理",
"signoffs": "分享链接不显示会签进度:要看请打开应用",
"action_status": "分享链接不显示写入外部系统的记录:要看请打开应用",
"records_list": "分享链接不列登记的记录:要看请打开应用",
"report": "报告正文和每个数的出处在「下载报告」里:下载的文件离线也能点开出处",
}
SHARE_FIXED_NEW_VIEW = "这个视图是建分享之后才加的:固定在当时那几次运行的分享链接里没有它"
SHARE_FIXED_EMPTY = "建分享时这个视图还没有封存的运行"
SHARE_FIXED_GONE = "建分享时固定的那次运行已经不在了"
@dataclass(frozen=True)
class SharedView:
"""分享链接的解析方式(api/shares.py 建好交进来)。mode:latest 始终取最近一次封存;fixed 取 runs 里存的运行
({视图 id: 运行 id,建分享时还没有封存运行的是 None})。key 进页面缓存的键(分享的 id)。"""
mode: str
key: str
runs: dict[str, str | None] | None = None
class ViewRefused(Exception):
def __init__(self, status_code: int, message: str, code: str | None = None) -> None:
super().__init__(message)
@@ -100,8 +150,8 @@ async def sealed_runs(session: AsyncSession, workflow_id: Any, version: Any, gra
"""钉住版本(同一 graph_hash)最近 n 次成功且已封存的正式运行,新的在前。
sealed_now 认的是「这一次结束时封的」:失败后接着跑完、新封存还没补上的那一刻,带着的是旧封存,不算。
按新口径补算的运行(app_backfill)不算:它们是现在才算的历史期,「最近一次」不能取到它们;趋势另经 trend_runs
按它们对应的输入期排进去。"""
来源只认 SEALED_ORIGINS(和老运行):按新口径补算的(app_backfill)、问数据按口径出具的(ledger_issue)都不是
「最近一次」,原因见 NOT_SEALED_ORIGINS。"""
from sqlalchemy import or_
from app.scheduler.auto_compare import sealed_now
@@ -110,7 +160,7 @@ async def sealed_runs(session: AsyncSession, workflow_id: Any, version: Any, gra
return []
query = select(Run).where(Run.workflow_id == workflow_id, Run.version == version, Run.run_class == "formal",
Run.status == "succeeded", Run.version_hash == graph_hash,
or_(Run.origin_kind.is_(None), Run.origin_kind != BACKFILL_ORIGIN))
or_(Run.origin_kind.is_(None), Run.origin_kind.in_(SEALED_ORIGINS)))
if isinstance(origin, str) and origin.startswith("schedule:"):
query = query.where(Run.schedule_id == origin[len("schedule:"):])
rows = list((await session.execute(query.order_by(Run.finished_at.desc(), Run.created_at.desc())
@@ -567,6 +617,8 @@ class _Page:
page: dict[str, Any] = field(default_factory=dict)
#: 蓝图的试运行(trial 旁路):草稿里所有视图来源都解析到这次运行,流程组件也对着它
trial: Run | None = None
#: 分享链接(share 旁路):只解析定时出数的视图,流程组件换成说明
share: SharedView | None = None
pins: dict[str, PinState] = field(default_factory=dict)
sources: dict[str, Source] = field(default_factory=dict)
problems: list[dict[str, Any]] = field(default_factory=list)
@@ -606,13 +658,16 @@ async def _backfilled(session: AsyncSession, app_id: str) -> int:
async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, principal: Principal, role: str | None,
instance: Run | None = None, params: dict[str, Any] | None = None, draft: bool = False,
version: AppVersion | None = None, actor: str | None = None,
trial: Run | None = None) -> dict[str, Any]:
trial: Run | None = None, share: SharedView | None = None) -> dict[str, Any]:
"""解析一页,返回 agentlab.view/1。draft 为真时按草稿解析:不发起任何运行,视图取钉住版本已有的封存运行。
trial 是蓝图的一次试运行(docs/design/normal-user-apps.md N4,只和 draft 一起用):草稿里所有视图来源(最近封存、
按需、本实例)都解析到这次运行,流程组件(实例进度、待办)也对着它;印章的来源写「试运行」、trial 为真、说明里写
「这不是正式结果」;趋势只有这一个点。不改 agentlab.app/1 的视图来源,只是解析时的旁路。
share 是分享链接(见模块说明,只和发布版一起用:草稿、个人应用、试运行都不能分享):只解析定时出数的视图,流程组件和
报告换成写明原因的说明,从不发起运行;out 里另给 share.runs(各视图取到的运行、有哪几份报告可下载)。
个人应用(N7)没有应用版本:草稿就是在用的那一份,不按草稿预览时也解析它(version 为空)。作者隐藏了的组件(hidden)
不下发;按草稿解析(编辑状态)时照常解析、带上 hidden,界面画成半透明。"""
import copy
@@ -625,8 +680,11 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
if page is None:
raise ViewRefused(404, f"应用里没有页面「{page_id}」", "app_page_not_found")
trial = trial if draft else None
if share is not None and (draft or personal or version is None or instance is not None):
raise ViewRefused(409, "分享链接只解析已发布的共享应用", "personal.not_shareable" if personal else VIEW_REFUSED)
ctx = _Page(app=app, spec=spec, version=None if draft else version, principal=principal, role=role,
instance=instance or trial, params=params or {}, draft=draft, actor=actor, page=page, trial=trial)
instance=instance or trial, params=params or {}, draft=draft, actor=actor, page=page, trial=trial,
share=share)
ctx.pins = await load_pins(session, spec)
# 试运行的钉住项本来就没有版本(还没发布),不是「需要升级」
upgrade = needs_upgrade(ctx.pins) if trial is None else False
@@ -644,9 +702,10 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
tuple((a, p.workflow.published_version if p.workflow else None, p.retired)
for a, p in sorted(ctx.pins.items())),
role, principal.username, principal.role, await _mask_fingerprint(session),
await _backfilled(session, app.id))
await _backfilled(session, app.id), ("share", share.key) if share is not None else None)
# 分享链接里流程组件都换成了说明,页面上没有随人、随时变的东西:照样进缓存
cacheable = (not draft and instance is None and all(s.state in ("ok", "empty") for s in ctx.sources.values())
and not _dynamic(page))
and (share is not None or not _dynamic(page)))
if cacheable and key in _PAGE_CACHE:
_PAGE_CACHE.move_to_end(key)
return copy.deepcopy(_PAGE_CACHE[key])
@@ -654,7 +713,8 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
# 会有(Source.message),不进 problems——进了,页面顶上挂一块「无法显示」,使用者以为应用坏了
for source in ctx.sources.values():
await _load(session, source)
for entry in spec.get("entries") or []:
# 分享链接里不能发起:入口的表单不下发
for entry in (spec.get("entries") or []) if share is None else []:
form = resolve_form(spec, entry.get("form"))
if form is not None and entry.get("id"):
ctx.forms[str(entry["id"])] = form
@@ -673,6 +733,7 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
"problems": ctx.problems,
**({"trial": {"run_id": trial.id, "status": trial.status,
"status_text": STATUS_TEXT.get(trial.status, trial.status)}} if trial is not None else {}),
**({"share": {"mode": share.mode, "runs": _share_runs_out(ctx)}} if share is not None else {}),
}
if cacheable:
_PAGE_CACHE[key] = copy.deepcopy(out)
@@ -681,6 +742,49 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip
return out
def _share_runs_out(ctx: _Page) -> list[dict[str, Any]]:
"""分享页上「下载报告」用:各视图取到的、成功封存的那次运行,带有哪几份没被改过的报告(节点 id)。同一次运行只列一次。"""
from app.api.evidence import _reports
out: list[dict[str, Any]] = []
seen: set[str] = set()
for vid, source in ctx.sources.items():
if source.state != "ok" or source.run is None or source.sealed is None or source.run.id in seen:
continue
seen.add(source.run.id)
reports = [r.node_id for r in _reports(source.sealed) if r.hash_ok is not False and r.doc is not None]
out.append({"view": vid, "run_id": source.run.id, "reports": reports,
"as_of_text": (source.stamp or {}).get("as_of_text")})
return out
async def share_runs(session: AsyncSession, spec: dict[str, Any], page_id: str, share: SharedView) -> dict[str, Run | None]:
"""这条分享链接现在解析得到的运行:{视图 id: 运行}(只有这一页上定时出数的视图,取不到的是 None)。下载报告只认这几次
(api/shares.py),和页面上 _source 的分享旁路同一个取法。"""
page = page_of(spec, page_id)
if page is None:
return {}
ctx = _Page(app=None, spec=spec, version=None, principal=Principal(username=None, role="user", via="share"),
role=None, instance=None, params={}, draft=False, actor=None, page=page, share=share)
ctx.pins = await load_pins(session, spec)
out: dict[str, Run | None] = {}
for vid in _views_of(page):
if (((spec.get("views") or {}).get(vid) or {}).get("source") or {}).get("kind") != "latest_sealed":
continue
source = await _source(session, ctx, vid)
out[vid] = source.run if source.state == "ok" else None
return out
def sealed_views(spec: dict[str, Any], page_id: str) -> list[str]:
"""这一页上取最近封存运行(定时出数)的视图:能不能建分享链接看它(一个都没有,分享出去什么都看不到)。"""
page = page_of(spec, page_id)
if page is None:
return []
return [vid for vid in _views_of(page)
if (((spec.get("views") or {}).get(vid) or {}).get("source") or {}).get("kind") == "latest_sealed"]
_DYNAMIC = frozenset({"instance_list", "task_list", "instance_header", "instance_timeline", "task_form", "signoffs",
"action_status", "entry_button", "records_list"})
@@ -724,6 +828,12 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source:
return Source(id=vid, kind="", state="error", message=f"视图「{vid}」不存在")
if ctx.trial is not None:
return await _trial_source(session, ctx, vid, kind, src)
if ctx.share is not None:
if kind != "latest_sealed":
# 分享链接只显示定时出数的视图:按需视图不发起、实例和探索视图不取
return Source(id=vid, kind=kind, state="empty", message=SHARE_SEALED_ONLY)
if ctx.share.mode == "fixed":
return await _fixed_source(session, ctx, vid, src)
state = ctx.pins.get(src.get("pin")) if kind != "instance" else None
run: Run | None = None
if kind == "instance":
@@ -755,6 +865,21 @@ async def _source(session: AsyncSession, ctx: _Page, vid: str) -> Source:
return source
async def _fixed_source(session: AsyncSession, ctx: _Page, vid: str, src: dict[str, Any]) -> Source:
"""fixed 模式的分享链接:取建分享时这个视图的那次运行(还得是成功封存的正式运行),之后封存的新运行不换上来。"""
assert ctx.share is not None
runs = ctx.share.runs or {}
state = ctx.pins.get(src.get("pin"))
if vid not in runs:
return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_NEW_VIEW)
if runs[vid] is None:
return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_EMPTY)
run = await session.get(Run, runs[vid])
if run is None or run.status != "succeeded" or run.run_class != "formal":
return Source(id=vid, kind="latest_sealed", state="empty", pin=state, message=SHARE_FIXED_GONE)
return Source(id=vid, kind="latest_sealed", state="ok", run=run, pin=state, origin=src.get("from"))
async def _exploratory_source(session: AsyncSession, ctx: _Page, vid: str, state: PinState) -> Source:
"""个人应用的视图(docs/design/normal-user-apps.md N7):这个应用、这个拥有者、这张图下最近一次成功封存的刷新
(blueprints/personal.exploratory_runs)。只在个人应用里合法(应用检查报 app.view_source_personal):共享应用里写了的
@@ -867,6 +992,10 @@ async def _node(session: AsyncSession, ctx: _Page, comp: dict[str, Any]) -> dict
return None
if comp.get("visible") and not await _visible(session, ctx, comp):
return None
if ctx.share is not None and (reason := SHARE_PLACEHOLDER.get(str(comp.get("type")))):
# 分享链接是只读的:流程组件、报告换成一块写明原因的说明(前端照说明块画),不取实例、待办、报告正文
return {"id": cid, "type": "notice", "props": {"text": reason, "tone": "info"},
"share_placeholder": comp.get("type")}
node: dict[str, Any] = {"id": cid, "type": comp.get("type"), "props": dict(comp.get("props") or {}),
**({"hidden": True} if comp.get("hidden") else {})}
entry = BY_NAME.get(str(comp.get("type")))
@@ -1082,6 +1211,11 @@ async def _series(session: AsyncSession, ctx: _Page, source: Source, cid: str, r
runs = await trend_runs(session, app_id, pin.pin.get("workflow_id"), pin.pin.get("version"),
pin.pin.get("graph_hash"), origin, n)
segments.append((int(pin.pin.get("version")), str(pin.pin.get("graph_hash")), runs))
if ctx.share is not None and ctx.share.mode == "fixed" and source.run is not None:
# 固定在建分享那一刻的分享链接:趋势也停在那一期,之后封存的不画进来
cutoff = period_of(source.run)
segments = [(v, h, [r for r in rows if cutoff is None or (period_of(r) or cutoff) <= cutoff])
for v, h, rows in segments]
series: list[dict[str, Any]] = []
unit = ""
for version, _, rows in segments:
+5 -2
View File
@@ -7,7 +7,8 @@
和加这一层之前一模一样。
开登录:
- 会话 cookie → Principal(认证过的用户名、角色、组);没有会话是匿名(username=None),X-Actor 一律不看;
- /api 下除了登录本身、健康检查、入站钩子,匿名一律 401。各路由的角色要求另由 main.py 的路由级依赖管,这里兜底:
- /api 下除了登录本身、健康检查、入站钩子、分享链接的读接口(/api/shares/,只认请求头里的分享 key,api/shares.py),匿名
一律 401。各路由的角色要求另由 main.py 的路由级依赖管,这里兜底:
哪个路由忘了挂依赖,也不会对没登录的人敞开;
- 接口说明(FastAPI 自带的 /docs、/redoc、/openapi.json)和 /api 一样拦:匿名 401,不到搭建者 403。它们是 FastAPI 自己
挂的路由,挂不上路由级依赖,所以角色也在这里判。以前只拦 /api/ 开头的,开了登录接口全貌也对匿名的人敞开;
@@ -38,6 +39,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/"
#: 分享链接的读接口(04 细案 C2):不用登录,接口里只认请求头 X-AgentLab-Share,会话有没有都不看。只有 GET:非 GET 照常查 CSRF
_SHARES = "/api/shares/"
#: FastAPI 自带的接口说明(main.py 里 FastAPI() 的缺省地址):开登录时要搭建者
_DOCS = ("/docs", "/redoc", "/openapi.json")
_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
@@ -153,7 +156,7 @@ class AuthMiddleware:
await send({"type": "websocket.close", "code": 1008})
return
if (scope["type"] == "http" and is_api and not principal.username and path not in _PUBLIC
and not path.startswith(_HOOKS)):
and not path.startswith(_HOOKS) and not path.startswith(_SHARES)):
await _reply(send, 401, "请先登录", AUTH_REQUIRED)
return
if scope["type"] == "http" and _is_docs(path) and (refused := refusal(principal, "builder")) is not None:
+34
View File
@@ -146,3 +146,37 @@ class ApiReceipt(Base):
status: Mapped[str] = mapped_column(String(20))
detail: Mapped[str | None] = mapped_column(Text, default=None)
received_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
class ViewShare(Base):
"""分享链接(docs/design/next-round/04-对外集成与触达.md C2,集成-I6):拿到链接、不用登录就能看应用的一页(只读,
可以嵌进门户)。
链接写成 /s#<分享 key>:key 在 # 后面,打开页面时不发给服务器、不进访问日志和 Referer,页面读出来再放进请求头
X-AgentLab-Share(api/shares.py)。库里只存 key 的 sha256(key_hash)和开头一截(prefix,认是哪一条),同访问密钥。
mode:latest 始终取最近一次封存;fixed 取建分享那一刻每个视图的运行(runs:{视图 id: 运行 id},还没有封存运行的是
null),解析建分享时的应用版本(app_version)。到期必填、最长 90 天;撤销、到期之后打开回 410 并写明。建、撤销写审计;
打开只计数(open_count、last_opened_at),不一次写一行。embed_allowed 为假时分享页不肯在别的页面的 iframe 里显示(服务端
在 /s 上只能按全站的嵌入白名单 AGENTLAB_EMBED_ORIGINS 发头:打开页面那一刻还不知道是哪一条分享)。"""
__tablename__ = "view_shares"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
key_hash: Mapped[str] = mapped_column(String(64), unique=True)
prefix: Mapped[str] = mapped_column(String(16))
app_id: Mapped[str] = mapped_column(ForeignKey("apps.id", ondelete="CASCADE"), index=True)
page_id: Mapped[str] = mapped_column(String(100))
mode: Mapped[str] = mapped_column(String(20), default="latest") # latest | fixed
app_version: Mapped[int | None] = mapped_column(Integer, default=None)
runs: Mapped[dict[str, Any] | None] = mapped_column(default=None)
embed_allowed: Mapped[bool] = mapped_column(default=False)
created_by: Mapped[str | None] = mapped_column(String(100), default=None)
#: 建分享的人当时的显示名:分享页页脚写「由 X 分享」
created_by_name: Mapped[str | None] = mapped_column(String(100), default=None)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow)
expires_at: Mapped[datetime] = mapped_column(UTCDateTime)
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
revoked_by: Mapped[str | None] = mapped_column(String(100), default=None)
open_count: Mapped[int] = mapped_column(Integer, default=0)
last_opened_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None)
+6 -1
View File
@@ -1,6 +1,7 @@
"""通知的发送核心(附录 C,BE-2 提供)。从 scheduler/webhook.py 抽出:四种格式、签名、发送、脱敏(core.py);
webhook.py 照原名再导出,定时任务的通知存储不迁移(F14)。新对象(待办、实例、动作)的订阅和投递记录在
service.py:渠道(notify_channels)、投递记录(notify_deliveries)。"""
service.py:渠道(notify_channels)、投递记录(notify_deliveries)。渠道的第五种格式「邮件」走 mail.py(SMTP),订阅、
投递记录、消息结构都和 Webhook 同一套,只是在这里按格式分到另一条发送路上。"""
from __future__ import annotations
from typing import Any
@@ -22,6 +23,10 @@ async def send(channel: Any, message: dict[str, Any]) -> Any:
lines=[(str(k), str(v)) for k, v in message.get("lines") or []],
links=[(str(k), str(v)) for k, v in message.get("links") or []],
ok=bool(message.get("ok")))
if channel.format == "email":
from app.notify import mail
return await mail.deliver(channel, message, msg)
if not url:
return Outcome(False, None, 0, "渠道的地址无法解密(加密密钥可能已更换),请重新填写地址")
if getattr(channel, "scope", None) == "user":
+6 -2
View File
@@ -1,6 +1,7 @@
"""通知的发送核心:四种格式、签名、发送、脱敏(从 scheduler/webhook.py 抽出,定时任务和待办、实例、动作共用一份)。
四种格式:通用 JSON、飞书(文本消息)、企业微信(markdown)、钉钉(markdown)。配了签名密钥时:通用格式在请求头
四种格式:通用 JSON、飞书(文本消息)、企业微信(markdown)、钉钉(markdown)。通知渠道另有第五种「邮件」,发送在
notify/mail.py(SMTP,不经这里的 HTTP 推送),消息结构(Message、text_of)共用这里的。配了签名密钥时:通用格式在请求头
里带 HMAC-SHA256 签名,飞书、钉钉按各自平台的算法签(飞书放在请求体,钉钉放在地址上);企业微信的 key 本来就在
地址里,没有另外的签名。入站钩子(app/process/hooks.py)验的也是通用格式这一套签名。
@@ -37,8 +38,11 @@ from app.db.base import utcnow
logger = logging.getLogger(__name__)
FORMATS = ("generic", "feishu", "wecom", "dingtalk")
#: 通知渠道能选的格式:Webhook 的四种,加上邮件(notify/mail.py,04 细案 B2,第五种格式)。定时任务的 Webhook 只有前四种
#: (它只会发 HTTP),所以邮件不进 FORMATS:scheduler/webhook 再导出的、定时任务接口校验的都是那一份
CHANNEL_FORMATS = (*FORMATS, "email")
#: 格式在报错里的叫法,和界面上的选项一致
FORMAT_LABEL = {"generic": "通用", "feishu": "飞书", "wecom": "企业微信", "dingtalk": "钉钉"}
FORMAT_LABEL = {"generic": "通用", "feishu": "飞书", "wecom": "企业微信", "dingtalk": "钉钉", "email": "邮件"}
#: 单次请求的超时(秒)
TIMEOUT_S = 10.0
#: 失败后再试 3 次,每次之前等这么久(秒)。4xx(429 除外)不重试:地址或参数错了,重试也一样
+190
View File
@@ -0,0 +1,190 @@
"""每天的待办汇总、访问密钥快到期:什么时候推(docs/design/next-round/04-对外集成与触达.md B3)。
调度循环每一拍调一次(scheduler/loop.process_due 的步骤列表);拼消息、推送在 notify/service(notify_digest、
notify_key_expiring),推送放在后台(service.spawn):接收端挂住时超时加重试要几十秒,不能拖着这一拍别的步骤。
待办汇总(task.digest):
- 每天到设置里的 notify.digest_at(缺省 09:00,按服务端所在时区)之后的第一拍,给订阅了 task.digest 的个人渠道的拥有者各发
一条(没有待办的人不发);
- 按日期在 settings 表里认领一行(键 STATE_PREFIX + "digest:<日期>",主键冲突就是已经认领过):同一天只发一次,重启了也不
重发。认领之后推送失败不补发——通知本来就是至多一次,投递记录里照实记失败。停机错过的那一天不补;
- 认领行只留最近 KEEP_DAYS 天;
- 关登录时不发:「派给我的」要认得出是谁(关登录时人人等同管理员,待办箱里是全部待办),个人渠道本来也只挂在认证过的账号上。
访问密钥快到期(key.expiring):
- 到期前 7 天、1 天各推一次(THRESHOLDS):个人访问密钥推给拥有者的个人渠道,服务账号的推给订阅了的全局渠道;
- 去重靠审计:推之前先记一条 access_key.expiring(data.days 是哪一档),记过的不再推(进程里另记一份,省得每拍查审计);
- 不推的:建的时候离到期已经不到这一档(自己设了短有效期,知道它快到期);轮换下来、24 小时后到期的旧密钥(新的那把已经
在用);撤销了的;拥有者停用了的;个人密钥开关关着时的个人密钥(本来就不认了);关登录时的一概不推(访问密钥只在开了登录时
生效)。
这两种状态(认领行、自动建过邮件渠道的人)存在 settings 表里,键都以 STATE_PREFIX 开头:它们是通知自己记的账,不是用户设置,
GET /api/settings 不列、PUT 不收(api/settings.py)。
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import delete, select
from sqlalchemy.exc import IntegrityError
from app.db.base import SessionLocal, utcnow
from app.db.models import NotifyChannel, Setting, User
logger = logging.getLogger(__name__)
#: settings 表里通知自己记的账(不是用户设置)的键的开头
STATE_PREFIX = "notify_state:"
DIGEST_CLAIM = STATE_PREFIX + "digest:"
#: 汇总的缺省发送时间(服务端所在时区)
DEFAULT_AT = "09:00"
#: 认领行留几天
KEEP_DAYS = 7
#: 访问密钥到期前几天各推一次
THRESHOLDS = (7, 1)
_TIME = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
#: 这个进程里已经认领(或者看到别人认领了)的日期:同一天不再去插认领行
_claimed: set[str] = set()
#: 这个进程里已经推过(或者审计里已经有)的 (密钥 id, 档)
_noted: set[tuple[str, int]] = set()
def time_ok(value: Any) -> bool:
return isinstance(value, str) and bool(_TIME.match(value.strip()))
def at_of(stored_notify: Any) -> tuple[int, int]:
"""设置 notify 一组里的 digest_at → (时, 分);没填、存坏了按缺省。"""
raw = stored_notify.get("digest_at") if isinstance(stored_notify, dict) else None
match = _TIME.match(raw.strip()) if isinstance(raw, str) else None
if match is None:
match = _TIME.match(DEFAULT_AT)
assert match is not None
return int(match.group(1)), int(match.group(2))
async def _claim(day: str, now: datetime) -> bool:
"""认领这一天的汇总:插一行,主键冲突就是已经有人认领过。顺带清掉 KEEP_DAYS 天以前的认领行。"""
key = DIGEST_CLAIM + day
async with SessionLocal() as session:
if await session.get(Setting, key) is not None:
return False
session.add(Setting(key=key, value={"claimed_at": now.isoformat()}))
try:
await session.commit()
except IntegrityError:
return False
floor = DIGEST_CLAIM + (now.astimezone().date() - timedelta(days=KEEP_DAYS)).isoformat()
await session.execute(delete(Setting).where(Setting.key.like(DIGEST_CLAIM + "%"), Setting.key < floor))
await session.commit()
return True
async def digest_due(now: datetime | None = None) -> list[str]:
"""到点了就认领今天、给订阅了汇总的人各发一条(后台推)。交回这一拍要发给谁(用户 id),没到点、已经发过是空的。"""
from app.api.settings import NOTIFY_GROUP
from app.auth import current_mode
from app.notify import service
now = now or utcnow()
local = now.astimezone()
day = local.date().isoformat()
if day in _claimed or current_mode() == "off":
return []
async with SessionLocal() as session:
row = await session.get(Setting, NOTIFY_GROUP)
if (local.hour, local.minute) < at_of(row.value if row is not None else None):
return []
claimed = await _claim(day, now)
_claimed.add(day)
if not claimed:
return []
async with SessionLocal() as session:
rows = (await session.execute(select(NotifyChannel.owner_id, NotifyChannel.events).where(
NotifyChannel.scope == "user", NotifyChannel.enabled.is_(True),
NotifyChannel.owner_id.is_not(None)))).all()
owners = list(dict.fromkeys(owner for owner, events in rows if "task.digest" in (events or [])))
for owner in owners:
service.spawn(_digest_one(owner))
if owners:
logger.info("今天(%s)的待办汇总:发给 %d 个人", day, len(owners))
return owners
async def _digest_one(user_id: str) -> None:
from app.notify import service
try:
await service.notify_digest(user_id)
except Exception: # noqa: BLE001 - 一个人的汇总出错不影响别人,待办本身也不受影响
logger.exception("给用户 %s 发待办汇总失败", user_id)
async def keys_due(now: datetime | None = None) -> list[str]:
"""访问密钥快到期了的,按档推一次(见模块说明)。交回这一拍推了哪几把(密钥 id)。"""
from app.auth import current_mode, personal_keys_enabled
from app.auth.audit import record
from app.auth.keys import SERVICE_KIND
from app.db.models import AuditEvent
from app.db.models_identity import AccessKey
from app.notify import service
if current_mode() == "off":
return []
now = now or utcnow()
horizon = now + timedelta(days=max(THRESHOLDS))
async with SessionLocal() as session:
rows = (await session.execute(select(AccessKey, User).join(User, User.id == AccessKey.owner_user_id).where(
AccessKey.revoked_at.is_(None), AccessKey.expires_at > now, AccessKey.expires_at <= horizon))).all()
if not rows:
return []
ids = [key.id for key, _ in rows]
replaced = set((await session.execute(select(AccessKey.rotated_from).where(
AccessKey.rotated_from.in_(ids)))).scalars())
out: list[str] = []
for key, owner in rows:
if owner.disabled or key.id in replaced:
continue
if owner.kind != SERVICE_KIND and not personal_keys_enabled():
continue
expires = service._utc(key.expires_at)
left = expires - now
days = next(d for d in sorted(THRESHOLDS) if left <= timedelta(days=d))
created = service._utc(key.created_at) if key.created_at else None
if created is not None and created > expires - timedelta(days=days):
continue
if (key.id, days) in _noted:
continue
async with SessionLocal() as session:
seen = (await session.execute(select(AuditEvent.data).where(
AuditEvent.action == "access_key.expiring", AuditEvent.subject_id == key.id))).scalars()
if any(isinstance(d, dict) and d.get("days") == days for d in seen):
_noted.add((key.id, days))
continue
await record(session, "access_key.expiring", "access_key", key.id, actor=None, verified=False,
data={"days": days, "expires_at": expires.isoformat(), "name": key.name,
"owner": owner.username})
await session.commit()
_noted.add((key.id, days))
service.spawn(_key_one(key.id, days))
out.append(key.id)
return out
async def _key_one(key_id: str, days: int) -> None:
from app.notify import service
try:
await service.notify_key_expiring(key_id, days)
except Exception: # noqa: BLE001
logger.exception("访问密钥 %s 快到期的提醒推送失败", key_id)
def forget() -> None:
"""测试用:清掉进程里记的(认领过的日期、推过的密钥档)。"""
_claimed.clear()
_noted.clear()
+425
View File
@@ -0,0 +1,425 @@
"""邮件:通知渠道的第五种格式(docs/design/next-round/04-对外集成与触达.md B2,总方案 X12、集成-I4)。
为什么改写应用平台的 Q6(「待办通知只用 Webhook 的四种格式,不做邮件——SMTP 要另配服务器、还要处理退信」):
- 待办派给了人却找不到人:群机器人群里人人看得见,算不上个人;用户表里本来就有 email(单点登录时自动填,管理员在
「设置 → 身份」里也能改),邮件是真正能触达个人、成本最低的办法;
- Q6 担心的两件事这里都照实处理,不假装做到:SMTP 是可选配置(设置里的 mail 一组,缺省关,不配就没有邮件这一格式);
退信不追踪——投递记录成功时写的是「已交给邮件服务器」,是否进了收件箱以邮件服务器为准,界面上也照这样写;
- IM 应用消息(企业微信、飞书、钉钉的应用消息)这次不做:要企业应用凭据、令牌刷新和用户 id 映射,和 IM 登录是同一套,
放到一起做。
一种说法:邮件只是渠道的一种格式,订阅、投递记录(notify_deliveries)、测试、消息结构(core.Message:只有标题、状态、
条数、链接,不放数字)都走同一套;不另建邮件表。
收件人:
- 个人邮件渠道(scope=user)不填地址,只发到拥有者在名录里的账号邮箱(users.email):可以填任意地址的话,个人渠道就成了
谁都能用的垃圾邮件中继,而且发到的不是认证过的身份。账号没有邮箱时记一条失败的投递并写明原因(NO_EMAIL);
- 全局、应用的邮件渠道(搭建者以上能建)填固定的收件地址,最多 MAX_RECIPIENTS 个,和全局 Webhook 同一个信任级别。
地址同 Webhook 一样加密落库(渠道表不改结构:个人渠道存一个占位)。
发送:标准库 smtplib,放在 asyncio.to_thread 里(不多一个依赖),单次超时 TIMEOUT_S 秒。4xx(临时错误)和连不上按
1/2/4 秒重试,5xx(地址、认证、内容被拒)不重试——重试也一样。报错里只写主机名和服务器的回话,绝不出现口令(scrub)。
测试把 _smtp_factory 换成假的(同 core._transport 的做法)。
邮件内容:纯文本一份、简单的 HTML 一份(全部转义);带 List-Unsubscribe(指向「设置 → 通知」,没有对外访问地址时不带:
这个头只认完整的地址),Auto-Submitted: auto-generated。
设置的 mail 一组只有管理员能改(整个设置接口的改动都要管理员);口令用 core/crypto 加密存,读设置时只说配没配。
内网的 SMTP 由管理员配置,不做 SSRF 检查(同全局 Webhook)。
"""
from __future__ import annotations
import asyncio
import html
import logging
import re
import smtplib
import socket
import ssl
from collections.abc import Callable
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr, formatdate, make_msgid
from typing import Any
from app.notify.core import Message, Outcome, text_of
logger = logging.getLogger(__name__)
#: 设置里的这一组
GROUP = "mail"
#: 加密方式:starttls(587,明文连上再升级)、ssl(465,一上来就是加密连接)、none(内网明文中继)
SECURITY = ("starttls", "ssl", "none")
SECURITY_LABEL = {"starttls": "STARTTLS", "ssl": "SSL/TLS", "none": "不加密"}
DEFAULTS: dict[str, Any] = {"enabled": False, "host": None, "port": 587, "security": "starttls", "username": None,
"password": None, "from_addr": None, "from_name": "AgentLab",
"default_for_assignees": False}
LABEL = {"enabled": "启用邮件", "host": "SMTP 服务器", "port": "端口", "security": "加密方式", "username": "用户名",
"password": "口令", "from_addr": "发件地址", "from_name": "发件人名称",
"default_for_assignees": "默认发邮件给待办处理人"}
#: 单次连接、读写的超时(秒)
TIMEOUT_S = 10.0
#: 临时错误、连不上时再试 3 次,每次之前等这么久(秒)
RETRY_DELAYS: tuple[float, ...] = (1.0, 2.0, 4.0)
#: 全局、应用的邮件渠道最多几个收件地址
MAX_RECIPIENTS = 20
#: 个人邮件渠道 url 列存的占位(加密后落库):收件人一律取拥有者的账号邮箱
PERSONAL_PLACEHOLDER = "account-email"
#: 管理员开了「默认发邮件给待办处理人」时,自动建的个人邮件渠道默认订阅这几项(本人之后可以改、可以关)
ASSIGNEE_EVENTS = ("task.opened", "action.unknown", "review.done")
ASSIGNEE_CHANNEL_NAME = "待办邮件(自动建)"
NO_EMAIL = "你的账号没有邮箱地址,请联系管理员在「设置 → 身份」里填写"
NOT_ENABLED = "邮件服务没有启用:请管理员在「设置 → 通知 → 邮件服务」里配置并启用"
HANDED_OFF = "已交给邮件服务器"
#: 测试用:换成假的 SMTP 工厂,(host, port, security, timeout) → 有 starttls / login / send_message / quit / close 的对象
_smtp_factory: Callable[[str, int, str, float], Any] | None = None
# 地址只认最常见的写法:本地部分不含空白和尖括号,域名至少有一个点。换行一律拒收(防邮件头注入)
_ADDRESS = re.compile(r"^[^@\s<>\"(),;:\\]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$")
_HOST = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?$|^\[[0-9A-Fa-f:.]+\]$")
# --------------------------------------------------------------------------
# 地址、设置
# --------------------------------------------------------------------------
def address_ok(text: Any) -> bool:
return isinstance(text, str) and len(text) <= 254 and bool(_ADDRESS.match(text))
def parse_recipients(raw: Any) -> list[str]:
"""全局、应用邮件渠道填的收件地址(逗号、分号、空白、换行分隔都行)→ 去重后的列表。不对抛 ValueError(中文说明)。"""
if isinstance(raw, list):
parts = [str(p) for p in raw]
elif isinstance(raw, str):
parts = re.split(r"[\s,;,;、]+", raw)
else:
parts = []
out = list(dict.fromkeys(p.strip() for p in parts if p and p.strip()))
if not out:
raise ValueError("请填写至少一个收件地址,多个地址用逗号或换行分隔")
if len(out) > MAX_RECIPIENTS:
raise ValueError(f"收件地址最多 {MAX_RECIPIENTS} 个,当前填了 {len(out)} 个;人多的请改用邮件组的地址")
bad = [p for p in out if not address_ok(p)]
if bad:
raise ValueError(f"收件地址「{bad[0][:80]}」不是有效的邮件地址")
return out
def _text(value: Any) -> str | None:
return value.strip() if isinstance(value, str) and value.strip() else None
def _port_ok(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 65535
def settings_problem(value: Any, saved: Any = None) -> str | None:
"""设置页写进来的 mail 一组有什么问题,没问题是 None。saved 是库里存着的那份(口令没给就沿用它)。
启用时要配全:服务器、发件地址;没启用时可以只填一部分先存着。发件人名称、主机名里不许有换行(邮件头注入)。"""
if not isinstance(value, dict):
return f"「邮件服务」设置格式有误,需要是一个对象,当前为「{value}」"
for key, item in value.items():
if key == "password_set":
continue # GET 给的只读项,整组回写时带着它不算错
if key not in DEFAULTS:
return "「邮件服务」设置中不支持「" + key + "」,可设置的项:" + "、".join(f"「{LABEL[k]}」" for k in DEFAULTS)
label = LABEL[key]
if key in ("enabled", "default_for_assignees"):
if not isinstance(item, bool):
return f"「{label}」只能是开或关,当前为「{item}」"
elif key == "port":
if not _port_ok(item):
return f"「{label}」需要是 1 到 65535 之间的整数,当前为「{item}」"
elif key == "security":
if item not in SECURITY:
return f"「{label}」只能是 {'、'.join(SECURITY_LABEL.values())} 之一"
elif item is not None and not isinstance(item, str):
return f"「{label}」需要是文字,当前为「{item}」"
elif isinstance(item, str) and any(c in item for c in "\r\n"):
return f"「{label}」里不能有换行"
elif key == "host" and _text(item) and not _HOST.match(item.strip()):
return f"「{label}」需要是主机名或 IP 地址(例如 smtp.example.com),不带协议和端口,当前为「{item}」"
elif key == "from_addr" and _text(item) and not address_ok(item.strip()):
return f"「{label}」不是有效的邮件地址,当前为「{item}」"
elif key in ("username", "from_name") and isinstance(item, str) and len(item) > 200:
return f"「{label}」最长 200 个字"
elif key == "password" and isinstance(item, str) and len(item) > 500:
return f"「{label}」过长"
merged = {**public_value(saved), **{k: v for k, v in value.items() if k in DEFAULTS and k != "password"}}
if merged.get("enabled"):
if not _text(merged.get("host")):
return f"启用邮件之前请填写「{LABEL['host']}」"
if not _text(merged.get("from_addr")):
return f"启用邮件之前请填写「{LABEL['from_addr']}」"
return None
def stored_value(value: dict[str, Any], saved: Any = None) -> dict[str, Any]:
"""校验过的 mail 一组 → 落库的样子:补齐默认、去掉首尾空白;口令没给(或给 null)沿用存着的,给空串表示去掉,
给了新的就加密。"""
from app.core.crypto import encrypt
old = saved if isinstance(saved, dict) else {}
out = {**DEFAULTS, **{k: v for k, v in old.items() if k in DEFAULTS}}
for key, item in value.items():
if key not in DEFAULTS or key == "password":
continue
out[key] = item.strip() if isinstance(item, str) else item
for key in ("host", "username", "from_addr", "from_name"):
out[key] = _text(out.get(key))
out["from_name"] = out["from_name"] or DEFAULTS["from_name"]
given = value.get("password")
if isinstance(given, str):
out["password"] = encrypt(given) if given else None
return out
def public_value(stored: Any) -> dict[str, Any]:
"""读设置时给的样子(GET /api/settings 的 mail 一组):口令不回,只说配没配(password_set)。存坏了的项按默认。"""
stored = stored if isinstance(stored, dict) else {}
out: dict[str, Any] = {}
for key, default in DEFAULTS.items():
item = stored.get(key)
if key in ("enabled", "default_for_assignees"):
out[key] = item if isinstance(item, bool) else default
elif key == "port":
out[key] = item if _port_ok(item) else default
elif key == "security":
out[key] = item if item in SECURITY else default
elif key == "password":
out[key] = None
else:
out[key] = _text(item) or default
out["password_set"] = bool(stored.get("password"))
return out
@dataclass(frozen=True)
class MailConfig:
"""发信用的配置(口令已解密,只在发送时用)。"""
enabled: bool
host: str | None
port: int
security: str
username: str | None
password: str | None
from_addr: str | None
from_name: str
default_for_assignees: bool
@property
def ready(self) -> bool:
"""配全了能发:服务器、发件地址都有。启不启用另看 enabled。"""
return bool(self.host and self.from_addr)
def config_of(stored: Any) -> MailConfig:
from app.core.crypto import decrypt
shown = public_value(stored)
secret = stored.get("password") if isinstance(stored, dict) else None
return MailConfig(enabled=shown["enabled"], host=shown["host"], port=shown["port"], security=shown["security"],
username=shown["username"], password=decrypt(secret) if isinstance(secret, str) else None,
from_addr=shown["from_addr"], from_name=shown["from_name"],
default_for_assignees=shown["default_for_assignees"])
async def load_config(session: Any = None) -> MailConfig:
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, GROUP)
else:
row = await session.get(Setting, GROUP)
return config_of(row.value if row is not None else None)
# --------------------------------------------------------------------------
# 邮件内容
# --------------------------------------------------------------------------
_FOOTER = "这封邮件由 AgentLab 自动发出,请勿直接回复。不想再收到:在 AgentLab「设置 → 通知」里关掉或删除这个渠道"
def build(cfg: MailConfig, to: list[str], msg: Message, *, unsubscribe: str | None) -> EmailMessage:
"""一封邮件:纯文本一份、HTML 一份(全部转义)。unsubscribe 是「设置 → 通知」的完整地址,没有就不带退订头。"""
em = EmailMessage()
# 标题里的换行(应用名、节点名是人起的)合成空格:邮件头里有换行就是头注入,标准库也会直接拒收
em["Subject"] = f"【AgentLab】{' '.join(msg.title.split())}"[:200]
em["From"] = formataddr((cfg.from_name or DEFAULTS["from_name"], cfg.from_addr or ""))
em["To"] = ", ".join(to)
em["Date"] = formatdate(localtime=True)
em["Message-ID"] = make_msgid(domain=(cfg.from_addr or "agentlab.invalid").rpartition("@")[2] or None)
# 自动发出的通知:自动回复不该回给它(RFC 3834)
em["Auto-Submitted"] = "auto-generated"
if unsubscribe:
em["List-Unsubscribe"] = f"<{unsubscribe}>"
footer = _FOOTER + (f":{unsubscribe}" if unsubscribe else "")
em.set_content(f"{text_of(msg)}\n\n——\n{footer}\n")
em.add_alternative(_html(msg, unsubscribe), subtype="html")
return em
def _html(msg: Message, unsubscribe: str | None) -> str:
esc = html.escape
color = "#1a7f37" if msg.ok else "#9a6700"
rows = "".join(
f'<tr><td style="padding:2px 12px 2px 0;color:#666;white-space:nowrap">{esc(k)}</td>'
f'<td style="padding:2px 0;{"color:" + color + ";font-weight:600" if k == "状态" else ""}">{esc(v)}</td></tr>'
for k, v in msg.lines)
links = "".join(f'<p style="margin:8px 0"><a href="{esc(url, quote=True)}">{esc(label)}</a></p>'
for label, url in msg.links if url.startswith(("http://", "https://")))
tail = esc(_FOOTER) + (f':<a href="{esc(unsubscribe, quote=True)}">设置 → 通知</a>' if unsubscribe else "")
return ('<!doctype html><html><body style="font-family:-apple-system,Segoe UI,PingFang SC,Microsoft YaHei,sans-serif;'
'font-size:14px;line-height:1.6;color:#222">'
f'<p style="margin:0 0 8px"><strong>【AgentLab】{esc(msg.title)}</strong></p>'
f'<table style="border-collapse:collapse">{rows}</table>{links}'
f'<p style="margin-top:16px;color:#888;font-size:12px">{tail}</p></body></html>')
# --------------------------------------------------------------------------
# 发送
# --------------------------------------------------------------------------
def _default_factory(host: str, port: int, security: str, timeout: float) -> Any:
if security == "ssl":
return smtplib.SMTP_SSL(host, port, timeout=timeout, context=ssl.create_default_context())
return smtplib.SMTP(host, port, timeout=timeout)
def scrub(text: str, cfg: MailConfig) -> str:
"""报错里不出现口令(服务器回话里偶尔会把收到的内容原样带回来)。"""
if cfg.password and len(cfg.password) >= 3:
text = text.replace(cfg.password, "…")
return text
def _reply(error: Any) -> str:
raw = getattr(error, "smtp_error", b"")
text = raw.decode("utf-8", "replace") if isinstance(raw, bytes) else str(raw or "")
return " ".join(text.split())[:200]
def _send_once(cfg: MailConfig, to: list[str], em: EmailMessage) -> Outcome:
"""连一次、发一次(同步,在线程里跑)。结果按 SMTP 的回码分:2xx 已交给邮件服务器;4xx、连不上值得再试;5xx 不再试。"""
host = f"{cfg.host}:{cfg.port}"
factory = _smtp_factory or _default_factory
client = None
try:
client = factory(cfg.host or "", cfg.port, cfg.security, TIMEOUT_S)
if cfg.security == "starttls":
client.starttls(context=ssl.create_default_context())
if cfg.username:
client.login(cfg.username, cfg.password or "")
refused = client.send_message(em, from_addr=cfg.from_addr, to_addrs=to) or {}
except smtplib.SMTPNotSupportedError:
return Outcome(False, None, 1, f"邮件服务器 {host} 不支持 STARTTLS:请把「加密方式」改成 SSL/TLS 或不加密")
except smtplib.SMTPRecipientsRefused as e:
codes = [code for code, _ in (e.recipients or {}).values()]
temporary = any(400 <= int(c) < 500 for c in codes)
return Outcome(False, None, 1, f"邮件服务器 {host} 拒收了全部 {len(codes) or len(to)} 个收件地址"
+ ("(临时错误)" if temporary else ""), retryable=temporary)
except smtplib.SMTPAuthenticationError as e:
code = int(e.smtp_code or 0)
return Outcome(False, None, 1, scrub(f"邮件服务器 {host} 不接受这组用户名和口令({code})"
+ (f":{_reply(e)}" if _reply(e) else ""), cfg)[:300],
retryable=400 <= code < 500)
except smtplib.SMTPResponseException as e:
code = int(e.smtp_code or 0)
detail = _reply(e)
text = scrub(f"邮件服务器 {host} 回复 {code}" + (f":{detail}" if detail else ""), cfg)[:300]
# SMTPConnectError 也走这里(连上了但对方回了错误码);回码不是 5xx 的都当临时错误
return Outcome(False, None, 1, text, retryable=not 500 <= code < 600)
except smtplib.SMTPServerDisconnected:
return Outcome(False, None, 1, f"邮件服务器 {host} 断开了连接", retryable=True)
except ssl.SSLError:
return Outcome(False, None, 1, f"和邮件服务器 {host} 建立加密连接失败:请核对「加密方式」和端口(SSL/TLS 常用 465,"
"STARTTLS 常用 587)")
except (socket.timeout, TimeoutError):
return Outcome(False, None, 1, f"连接邮件服务器 {host} 超时({TIMEOUT_S:g} 秒)", retryable=True)
except socket.gaierror:
return Outcome(False, None, 1, f"找不到邮件服务器 {cfg.host}(域名无法解析)", retryable=True)
except OSError:
return Outcome(False, None, 1, f"无法连接邮件服务器 {host}", retryable=True)
except Exception as e: # noqa: BLE001 - 拼信、编码出错也只记一条失败,不让推送任务抛出去
logger.exception("发邮件时出错(%s)", host)
return Outcome(False, None, 1, f"向邮件服务器 {host} 发信时出错({type(e).__name__})")
finally:
if client is not None:
try:
client.quit()
except Exception: # noqa: BLE001 - 已经断开的连接 quit 会抛,关掉就行
try:
client.close()
except Exception: # noqa: BLE001
pass
if refused:
# 部分地址被拒:其余的已经交给邮件服务器,算成功,照实写有几个没收
return Outcome(True, None, 1, f"{HANDED_OFF},其中 {len(refused)} 个收件地址被拒收")
return Outcome(True, None, 1)
async def send(cfg: MailConfig, to: list[str], msg: Message, *, label: str, unsubscribe: str | None = None,
retry: bool = True, delays: tuple[float, ...] | None = None) -> Outcome:
"""发一封邮件(含重试)。label 是日志里的「谁的推送」。没配全的照实回失败,不连服务器。"""
if not cfg.ready:
return Outcome(False, None, 0, NOT_ENABLED)
em = build(cfg, to, msg, unsubscribe=unsubscribe)
waits = (RETRY_DELAYS if delays is None else delays) if retry else ()
last = Outcome(False, None, 0, "没有发出邮件")
for attempt in range(len(waits) + 1):
if attempt:
await asyncio.sleep(waits[attempt - 1])
last = await asyncio.to_thread(_send_once, cfg, to, em)
last.attempts = attempt + 1
if last.ok:
return last
logger.warning("%s(邮件服务器 %s)第 %d 次发信失败:%s", label, cfg.host, attempt + 1, last.error)
if not last.retryable:
break
return last
async def recipients_of(session: Any, channel: Any) -> tuple[list[str], str | None]:
"""这个邮件渠道发给谁:(地址, 发不了的原因)。个人渠道取拥有者的账号邮箱,其余解密填的那几个。"""
from app.core.crypto import decrypt
from app.db.models import User
if getattr(channel, "scope", None) == "user":
user = await session.get(User, channel.owner_id) if channel.owner_id else None
email = (user.email or "").strip() if user is not None else ""
if not email or not address_ok(email):
return [], NO_EMAIL
return [email], None
try:
return parse_recipients(decrypt(channel.url) or ""), None
except ValueError as e:
return [], f"收件地址无法使用:{e}"
async def deliver(channel: Any, message: dict[str, Any], msg: Message) -> Outcome:
"""按渠道发一封邮件(notify/__init__.send 的邮件分支):配置没启用、收件人取不出来都记成失败、写明原因。"""
from app.core import public_url
from app.db.base import SessionLocal
async with SessionLocal() as session:
cfg = await load_config(session)
to, problem = await recipients_of(session, channel)
base = await public_url.refresh(session)
if not cfg.enabled:
return Outcome(False, None, 0, NOT_ENABLED)
if problem:
return Outcome(False, None, 0, problem)
return await send(cfg, to, msg, label=f"通知渠道「{channel.name}」",
unsubscribe=f"{base}/settings/notify" if base else None,
retry=bool(message.get("retry", True)))
+187 -12
View File
@@ -35,7 +35,7 @@ from typing import Any
from sqlalchemy import select
from app.db.base import SessionLocal, utcnow
from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, Task, TaskAssignment, User
from app.db.models import App, AppVersion, NotifyChannel, NotifyDelivery, Run, Setting, Task, TaskAssignment, User
from app.notify.core import Message, Outcome, host_of
logger = logging.getLogger(__name__)
@@ -66,7 +66,7 @@ EVENTS: dict[str, Event] = {
"action.unknown": Event("动作结果未知", _EVERY),
# 个人应用提交审核之后,搭建者发布了或退回了(docs/design/normal-user-apps.md N7):发给提交人,不归哪个应用
"review.done": Event("我提交审核的应用有结果", ("global", "user")),
# ---- 本轮登记、还没有推送(总方案 X6):实现推送的那一块只改自己这一行的 scopes,消息照模块说明的规矩 ----
# ---- 本轮登记的(总方案 X6):scopes 为空的还没有推送;实现推送的那一块只改自己这一行的 scopes,消息照模块说明的规矩 ----
# 数据源有要处理的结构 / 取值变化(03 B3):只推 severity 为 action 的,按源汇总;源名、各级别条数、链接
"source.changed": Event("数据源有变化要处理", ()),
# 口径升版后召回重算,数真变了(02 第 7 项):答案的主人有 recall 待办;口径名、版本、链接,不放新旧值和差值
@@ -76,10 +76,11 @@ EVENTS: dict[str, Event] = {
# 叫法就照实带「定时」;没评完包括失败、到了上限、止损停下,所以不叫「失败」
"eval.regressed": Event("定时评测有退步", ("global",)),
"eval.failed": Event("定时评测没有评完", ("global",)),
# 每天一条待办汇总(04 B3):只推个人渠道;条数、最早的时限、最多 5 条(节点在图上的名字)、去待办箱的链接
"task.digest": Event("每天的待办汇总", ()),
# 访问密钥到期前 7 天、1 天各一次(04 B3):个人密钥推本人,服务账号的推全局渠道;密钥名、到期日、链接
"key.expiring": Event("访问密钥快到期", ()),
# 每天一条待办汇总(04 B3,notify_digest):只推个人渠道;条数、最早的时限、最多 5 条(节点在图上的名字)、去待办箱的链接
"task.digest": Event("每天的待办汇总", ("user",)),
# 访问密钥到期前 7 天、1 天各一次(04 B3,notify_key_expiring):个人密钥推本人的个人渠道,服务账号的推全局渠道;
# 密钥名、到期日、链接
"key.expiring": Event("访问密钥快到期", ("global", "user")),
}
EVENT_LABEL = {name: event.label for name, event in EVENTS.items()}
@@ -135,14 +136,29 @@ async def personal_url_problem(url: str) -> str | None:
return None
def channel_out(row: NotifyChannel) -> dict[str, Any]:
"""渠道的接口形状(typesApp.ts 的 NotifyChannelRow):地址只回主机名,密钥只说配没配。"""
def channel_out(row: NotifyChannel, owner_email: str | None = None, *, recipients: bool = True) -> dict[str, Any]:
"""渠道的接口形状(typesApp.ts 的 NotifyChannelRow):地址只回主机名,密钥只说配没配。
邮件渠道(notify/mail.py)没有主机名:mail_to 是发给谁——个人渠道是拥有者的账号邮箱(owner_email,没有邮箱是空的),
全局、应用的是填的那几个收件地址:改地址时要整份重填,所以给能改它的人照原样给。recipients 为假(调用方判定看的人改不了
全局、应用的渠道,api/notify.list_channels)时不给地址、只给条数 mail_count:渠道列表对使用者照样列全局、应用渠道(应用
的通知里要挑一个,只用名字和 id),Webhook 只给主机名,邮件的收件地址不能比它多露一层。"""
from app.core.crypto import decrypt
url = decrypt(row.url) or ""
return {"id": row.id, "name": row.name, "scope": row.scope, "owner_id": row.owner_id, "format": row.format,
"url_host": host_of(url) if url else "", "signed": bool(row.secret), "events": list(row.events or []),
"enabled": row.enabled}
out = {"id": row.id, "name": row.name, "scope": row.scope, "owner_id": row.owner_id, "format": row.format,
"url_host": host_of(url) if url and row.format != "email" else "", "signed": bool(row.secret),
"events": list(row.events or []), "enabled": row.enabled}
if row.format == "email":
if row.scope == "user":
out["mail_to"] = [owner_email] if owner_email else []
else:
to = [p.strip() for p in url.split(",") if p.strip()]
if recipients:
out["mail_to"] = to
else:
out["mail_count"] = len(to)
return out
def generic_body(message: dict[str, Any], msg: Message) -> dict[str, Any]:
@@ -239,8 +255,61 @@ async def _where(session: Any, run: Run | None) -> str:
return f"工作流「{run.workflow_name}」" if run.workflow_name else ""
#: 管理员开了「默认发邮件给待办处理人」(设置 mail.default_for_assignees,04 细案 B2、集成-I5)之后,给哪些人自动建过个人
#: 邮件渠道:设置表里一行 {用户 id: 建的时间}(通知自己记的账,键的开头同 notify/digest.STATE_PREFIX,设置接口不列)。只在
#: 第一次派到待办时建一次——本人删掉了就是不要,不再替他建回来
MAIL_AUTO_KEY = "notify_state:mail_auto"
_mail_auto_lock = asyncio.Lock()
async def ensure_assignee_mail(users: list[str]) -> int:
"""派到待办的人还没有个人邮件渠道时自动给他建一个(默认订阅 mail.ASSIGNEE_EVENTS),本人之后可以改订阅、可以关。
只在开了登录(个人渠道挂在认证过的账号上)、邮件服务启用、管理员开了这个开关时做;服务账号、停用的人不建。缺省关:刚配好
SMTP 的部署不该突然往外发邮件(照实说、不意外)。交回这次建了几个。同一时刻只做一份:两条待办同时派给同一个人不建两个。"""
from app.auth import current_mode
from app.core.crypto import encrypt
from app.notify import mail
if not users or current_mode() == "off":
return 0
async with _mail_auto_lock, SessionLocal() as session:
cfg = await mail.load_config(session)
if not (cfg.enabled and cfg.default_for_assignees):
return 0
ids = list((await session.execute(select(User.id).where(
User.username.in_(users), User.kind != "service", User.disabled.is_(False)))).scalars())
record = await session.get(Setting, MAIL_AUTO_KEY)
done = dict(record.value or {}) if record is not None else {}
fresh = [uid for uid in ids if uid not in done]
if not fresh:
return 0
have = set((await session.execute(select(NotifyChannel.owner_id).where(
NotifyChannel.scope == "user", NotifyChannel.format == "email",
NotifyChannel.owner_id.in_(fresh)))).scalars())
made = 0
for uid in fresh:
if uid not in have:
session.add(NotifyChannel(name=mail.ASSIGNEE_CHANNEL_NAME, scope="user", owner_id=uid, format="email",
url=encrypt(mail.PERSONAL_PLACEHOLDER), events=list(mail.ASSIGNEE_EVENTS)))
made += 1
done[uid] = utcnow().isoformat()
if record is None:
session.add(Setting(key=MAIL_AUTO_KEY, value=done))
else:
record.value = done
await session.commit()
return made
async def notify_task(task_id: str, event: str, *, users: list[str]) -> int:
"""待办的通知(task.opened / reassigned / escalated / reminded)。users 是这次要通知到的人(派给的用户名)。返回推了几个渠道。"""
"""待办的通知(task.opened / reassigned / escalated / reminded)。users 是这次要通知到的人(派给的用户名)。返回推了几个渠道。
管理员开了「默认发邮件给待办处理人」时,先给还没有个人邮件渠道的处理人建一个(ensure_assignee_mail),这一条就推得到。"""
try:
await ensure_assignee_mail(users)
except Exception: # noqa: BLE001 - 自动建渠道出错不挡这条待办的通知
logger.exception("给待办 %s 的处理人自动建邮件渠道失败", task_id)
async with SessionLocal() as session:
task = await session.get(Task, task_id)
if task is None:
@@ -422,3 +491,109 @@ async def notify_eval(eval_run_id: str, event: str, *, passed: int, graded: int,
"subject": {"eval_run_id": eval_run_id, "suite_id": run.suite_id, "schedule_id": run.schedule_id}}
return await _push(channels, message, subject_kind="eval_run", subject_id=eval_run_id, event=event, run_id=None,
task_id=None)
# --------------------------------------------------------------------------
# 每天的待办汇总、访问密钥快到期(04 细案 B3):什么时候推由 notify/digest.py 在调度循环里定,这里只拼消息、推送
# --------------------------------------------------------------------------
#: 汇总里最多列几条
DIGEST_ITEMS = 5
def _utc(moment: Any) -> Any:
"""库里的时间一律按 UTC 认(待办的时限是不带时区的 UTC),比较前统一成带时区的。"""
from datetime import timezone
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
def _local(moment: Any) -> str:
return _utc(moment).astimezone().strftime("%Y-%m-%d %H:%M")
async def notify_digest(user_id: str) -> int:
"""给一个人发今天的待办汇总(task.digest):只推他自己订阅了这件事的个人渠道。没有待办不发(不每天发一条「0 条」)。
内容(P4、X6):条数、最早的时限、最多 DIGEST_ITEMS 条(节点在图上的名字,加所在应用和时限)、去待办箱的链接;不放按运行
数据渲染出来的待办标题。「派给我的」和待办箱同一个判断(process/tasks._mine)。返回推了几个渠道。"""
from app.auth import current_mode
from app.auth.sessions import principal_of
from app.process.tasks import _mine
event = "task.digest"
async with SessionLocal() as session:
user = await session.get(User, user_id)
if user is None or user.disabled or user.kind == "service":
return 0
channels = [c for c in await _channels(session, event, None, [user.username])
if c.scope == "user" and c.owner_id == user.id]
if not channels:
return 0
principal = await principal_of(session, user, current_mode())
query = select(Task).where(Task.status == "pending")
if (clause := _mine(principal)) is not None:
query = query.where(clause)
tasks = list((await session.execute(query)).scalars())
if not tasks:
return 0
# 有时限的在前、时限早的在前;没有时限的按开出的先后
far = _utc(utcnow().replace(year=9999))
tasks.sort(key=lambda t: (_utc(t.due_at) if t.due_at else far, _utc(t.created_at) if t.created_at else far))
runs = {r.id: r for r in (await session.execute(select(Run).where(
Run.id.in_({t.run_id for t in tasks[:DIGEST_ITEMS]})))).scalars()}
names = {a.id: a.name for a in (await session.execute(select(App).where(
App.id.in_({t.app_id for t in tasks[:DIGEST_ITEMS] if t.app_id})))).scalars()}
base = await _base_url(session)
lines = [("状态", "待处理"), ("条数", f"{len(tasks)} 条")]
dues = [t.due_at for t in tasks if t.due_at is not None]
if dues:
lines.append(("最早时限", _local(min(dues, key=_utc))))
for task in tasks[:DIGEST_ITEMS]:
run = runs.get(task.run_id)
node = _label(run.graph if run else None, task.node_id) if task.node_id else task.title[:80]
where = f"应用「{names[task.app_id]}」" if task.app_id in names else (
f"工作流「{run.workflow_name}」" if run is not None and run.workflow_name else "")
tail = [where] if where else []
if task.due_at is not None:
tail.append(f"时限 {_local(task.due_at)}")
lines.append(("待办", f"「{node}」" + (f"({','.join(tail)})" if tail else "")))
if len(tasks) > DIGEST_ITEMS:
lines.append(("另有", f"{len(tasks) - DIGEST_ITEMS} 条,见待办箱"))
message = {"event": event, "status": "pending", "title": f"你有 {len(tasks)} 条待办",
"lines": lines, "links": [("去待办箱", f"{base}/apps?tab=inbox")] if base else [],
"subject": {"user_id": user_id, "tasks": len(tasks)}}
return await _push(channels, message, subject_kind="digest", subject_id=user_id, event=event, run_id=None,
task_id=None)
async def notify_key_expiring(key_id: str, days: int) -> int:
"""访问密钥快到期了(key.expiring,到期前 7 天、1 天各一次,由 notify/digest 判断时候到没到、记审计)。
个人访问密钥推给拥有者自己订阅了这件事的个人渠道;服务账号的密钥推给订阅了它的全局渠道(服务账号不能登录,没有个人
渠道)。消息只有密钥名、开头一截、到期时间和去轮换的链接,不放密钥本身。返回推了几个渠道。"""
from app.db.models_identity import AccessKey
event = "key.expiring"
async with SessionLocal() as session:
key = await session.get(AccessKey, key_id)
if key is None:
return 0
owner = await session.get(User, key.owner_user_id)
if owner is None:
return 0
service = owner.kind == "service"
found = await _channels(session, event, None, [] if service else [owner.username])
channels = [c for c in found if (c.scope == "global" if service else c.scope == "user" and c.owner_id == owner.id)]
if not channels:
return 0
base = await _base_url(session)
left = "1 天内" if days <= 1 else f"{days} 天后"
lines = [("状态", "快到期"), ("密钥", f"「{key.name[:80]}」({key.prefix}…)"),
("拥有者", f"服务账号 {owner.username}" if service else (owner.display_name or owner.username)),
("到期", _local(key.expires_at))]
message = {"event": event, "status": "expiring", "title": f"访问密钥「{key.name[:80]}」将在{left}到期",
"lines": lines, "links": [("去轮换", f"{base}/settings/keys")] if base else [],
"subject": {"key_id": key_id, "days": days}}
return await _push(channels, message, subject_kind="access_key", subject_id=key_id, event=event, run_id=None,
task_id=None)
+5 -2
View File
@@ -123,14 +123,17 @@ async def tick(now: datetime | None = None) -> None:
async def process_due(now: datetime | None = None) -> None:
"""流程的一拍(app/process):待办的提醒、升级、超时驳回(永不自动批准);结果未知的写入按退避去查;
上传了却一直没提交的文件过了时限清掉(app/files,每小时一次);访问密钥的请求记录批量写库、过了保留期的清掉
(auth/keys.py,每小时清一次:每个请求同步写一行会和运行抢 SQLite 的写锁,所以攒到这一拍写)。
(auth/keys.py,每小时清一次:每个请求同步写一行会和运行抢 SQLite 的写锁,所以攒到这一拍写);每天到点发待办汇总、
访问密钥到期前 7 天和 1 天各提醒一次(notify/digest.py,04 细案 B3:按日期认领、按审计去重,推送放在后台,不拖这一拍)。
各自出错只记日志:这一拍出错不该让调度循环停下,下一拍照常再查。"""
from app.auth import keys
from app.files import store as files_store
from app.notify import digest
from app.process import actions, tasks
for name, step in (("待办的时限处理", tasks.tick), ("写入结果的查询", actions.tick),
("没有提交的上传文件清理", files_store.cleanup_tick), ("访问密钥的请求记录", keys.tick)):
("没有提交的上传文件清理", files_store.cleanup_tick), ("访问密钥的请求记录", keys.tick),
("每天的待办汇总", digest.digest_due), ("访问密钥快到期的提醒", digest.keys_due)):
try:
done = await step(now)
if isinstance(done, list) and done:
+10 -1
View File
@@ -9,7 +9,7 @@
- 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 波才加,路由在那时挂,头的规则先在这里定好);
/s 只认 AGENTLAB_EMBED_ORIGINS(分享链接,api/shares.py;页面是前端不进外壳的那一页,由 mount_web 照常托管);
- 只有 frame-ancestors 是 'self' 时另加 X-Frame-Options: SAMEORIGIN,给不认 CSP 的老浏览器。XFO 写不了白名单,有白名单
时只发 CSP:两个都发的话,只认 XFO 的老浏览器会把白名单里的门户也拦掉(认 CSP 的浏览器以 frame-ancestors 为准)。
接口自己设过的同名头(文件下载的 nosniff)不覆盖、不重复。
@@ -19,6 +19,15 @@
index.html 里的内联脚本算 sha256(script_policy),只在页面这一个响应上发;观察一个版本没有误报,再改成强制执行。没配
上报地址:违规只在浏览器的控制台里看得到。接口返回的 HTML(导出的报告、可验证报告)文件里自带 meta CSP,不发它,也不动。
嵌入的两种办法(04 细案 C3,只做配置和文档):
- 同站门户嵌整站:门户和 AgentLab 在同一个可注册域名下(portal.corp.example 和 agentlab.corp.example)时,把门户的源写进
AGENTLAB_FRAME_ANCESTORS。会话 cookie 是 SameSite=Lax,同站的 iframe 里会随请求带上:看的人照常登录、单点登录照常,权限
和直接打开一样。不另做嵌入票据;
- 跨站嵌:嵌整站明确不支持——跨站的 iframe 里带不上 Lax 的会话 cookie,登录不了;改成 SameSite=None 会削弱整站防跨站请求
伪造的那一层,换来的嵌入在 Safari、Firefox 上默认仍然被拦。跨站要嵌,用分享链接(只读、必设到期、可撤销,/s 只认
AGENTLAB_EMBED_ORIGINS)。
deploy/agentlab.env 的「嵌入」一节写的是同一套。
HSTS 交给终结 https 的反向代理:这里不知道对外是不是 https、要不要连子域名一起管,发错了浏览器会按 max-age 记很久,
撤不回来。
"""
+285
View File
@@ -0,0 +1,285 @@
"""每天的待办汇总、访问密钥快到期(notify/digest.py + notify/service,docs/design/next-round/04-对外集成与触达.md B3)。
守的是:
- 待办汇总到设置的时间(notify.digest_at,按服务端时区)才发,只发给订阅了 task.digest 的个人渠道;内容是条数、最早时限、
最多 5 条节点在图上的名字(不是按运行数据渲染出来的待办标题)、去待办箱的链接,不放数;
- 同一天只发一次,重启(进程里记的清掉)也不重发:靠 settings 表里按日期认领的一行;第二天照常发;
- 没有待办的人不发;
- 访问密钥到期前 7 天、1 天各推一次,不多推;个人密钥推本人的个人渠道,服务账号的推全局渠道;每次推都记审计;
- 轮换下来的旧密钥、撤销了的、建的时候就已经在这一档里的(自己设了短有效期),不推。
都用 Webhook 渠道和 httpx.MockTransport 接收;人名、应用名是虚构的。
"""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
import httpx
import pytest
from sqlalchemy import delete, select
from app.core.crypto import encrypt
from app.db.base import SessionLocal, utcnow
from app.db.models import App, AuditEvent, NotifyChannel, NotifyDelivery, Run, Setting, Task, TaskAssignment, User
from app.notify import core, digest, service
from tests.fakes import auth_env
URL = "https://hooks.example.com/agentlab/digest-91c2"
@pytest.fixture
def received(monkeypatch):
got: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
got.append(json.loads(request.content))
return httpx.Response(200, json={"ok": True})
monkeypatch.setattr(core, "_transport", httpx.MockTransport(handler))
monkeypatch.setattr(core, "RETRY_DELAYS", (0, 0, 0))
async def public(url: str) -> None:
return None
monkeypatch.setattr(service, "personal_url_problem", public)
return got
@pytest.fixture(autouse=True)
async def login_on(received):
"""开着账号密码登录(个人渠道挂在认证过的账号上)。依赖 received:收尾时等推送推完,假的接收端还在。"""
await auth_env.reset()
digest.forget()
async with SessionLocal() as session:
# 别的测试可能留下了 notify 一组、渠道和投递记录:整组换成这里要的,渠道和记录先清掉
await session.execute(delete(Setting).where(Setting.key == "notify"))
await session.execute(delete(NotifyDelivery))
await session.execute(delete(NotifyChannel))
session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example.com",
"digest_at": "09:00"}))
await session.commit()
await auth_env.turn_on_local()
try:
yield
finally:
await _cleanup()
async def _cleanup() -> None:
await service.drain()
async with SessionLocal() as session:
mine = select(Run.id).where(Run.workflow_name == "汇总测试")
tasks = select(Task.id).where(Task.run_id.in_(mine))
await session.execute(delete(TaskAssignment).where(TaskAssignment.task_id.in_(tasks)))
await session.execute(delete(Task).where(Task.run_id.in_(mine)))
for model in (NotifyDelivery, NotifyChannel):
await session.execute(delete(model))
await session.execute(delete(Run).where(Run.workflow_name == "汇总测试"))
await session.execute(delete(App).where(App.name == "报销审批"))
await session.execute(delete(Setting).where(Setting.key.like(digest.STATE_PREFIX + "%")))
await session.execute(delete(Setting).where(Setting.key == "notify"))
await session.commit()
from app.core import public_url
public_url.remember(None)
digest.forget()
await auth_env.reset()
def local(hour: int, minute: int, *, day: int = 16) -> datetime:
"""服务端所在时区的某个时刻(汇总按服务端时区算)→ UTC。"""
return datetime(2026, 10, day, hour, minute).astimezone().astimezone(timezone.utc)
async def personal(user: User, events: list[str]) -> NotifyChannel:
async with SessionLocal() as session:
row = NotifyChannel(name=f"个人-{user.username}", scope="user", owner_id=user.id, format="generic",
url=encrypt(URL), events=events)
session.add(row)
await session.commit()
return row
async def tasks_for(username: str, n: int, *, label: str = "主管审批") -> None:
graph = {"nodes": [{"id": "review", "type": "human", "data": {"label": label}}], "edges": []}
async with SessionLocal() as session:
app = App(name="报销审批", published_version=1)
session.add(app)
run = Run(workflow_name="汇总测试", status="interrupted", graph=graph, input={"amount": 45678})
session.add(run)
await session.flush()
for i in range(n):
task = Task(run_id=run.id, node_id="review", app_id=app.id, kind="approve",
title=f"审批 45678 元的报销 #{i}", due_at=(utcnow() + timedelta(hours=i + 1)).replace(tzinfo=None))
session.add(task)
await session.flush()
session.add(TaskAssignment(task_id=task.id, principal_kind="user", principal=username))
await session.commit()
# --------------------------------------------------------------------------
# 待办汇总
# --------------------------------------------------------------------------
async def test_digest_is_sent_once_a_day_at_the_set_time(received):
ana = await auth_env.make_user("ana", "user")
bo = await auth_env.make_user("bo", "user")
await personal(ana, ["task.digest"])
await personal(bo, ["task.opened"]) # 没订阅汇总
await tasks_for("ana", 7)
await tasks_for("bo", 2)
assert await digest.digest_due(local(8, 59)) == [], "还没到点"
assert await digest.digest_due(local(9, 0)) == [ana.id]
await service.drain()
[body] = received
assert body["event"] == "task.digest" and body["title"] == "你有 7 条待办"
text = body["text"]
assert "条数:7 条" in text and "最早时限" in text
assert text.count("「主管审批」") == 5, "最多列 5 条,用节点在图上的名字"
assert "另有:2 条" in text and "报销审批" in text
assert "45678" not in json.dumps(body, ensure_ascii=False), "不放按运行数据渲染出来的标题、不放数"
assert body["links"] == [{"label": "去待办箱", "url": "https://agentlab.example.com/apps?tab=inbox"}]
assert await digest.digest_due(local(9, 15)) == [], "同一天只发一次"
digest.forget() # 重启:进程里记的没了
assert await digest.digest_due(local(10, 0)) == [], "重启了也不重发:认领在 settings 表里"
await service.drain()
assert len(received) == 1
async with SessionLocal() as session:
rows = (await session.execute(select(NotifyDelivery).where(NotifyDelivery.event == "task.digest"))).scalars()
assert [(r.subject_kind, r.subject_id, r.status) for r in rows] == [("digest", ana.id, "ok")]
assert await digest.digest_due(local(9, 1, day=17)) == [ana.id], "第二天照常"
async def test_digest_follows_the_configured_time_and_skips_people_with_nothing_pending(received):
cy = await auth_env.make_user("cy", "user")
await personal(cy, ["task.digest"])
async with SessionLocal() as session:
row = await session.get(Setting, "notify")
row.value = {**row.value, "digest_at": "17:30"}
await session.commit()
assert await digest.digest_due(local(9, 0)) == []
assert await digest.digest_due(local(17, 30)) == [cy.id]
await service.drain()
assert received == [], "没有待办不发「0 条」"
async def test_nothing_is_sent_when_login_is_off(received):
"""关登录时「派给我的」认不出是谁(人人等同管理员),访问密钥也不生效:汇总、到期提醒都不发,也不认领今天。"""
ana = await auth_env.make_user("ana", "user")
await personal(ana, ["task.digest"])
await tasks_for("ana", 2)
svc = await auth_env.make_service("svc-off")
key, _ = await auth_env.make_key(svc, days=3)
await _age(key, 30)
from app.auth import remember_settings
remember_settings({})
assert await digest.digest_due(local(9, 0)) == []
assert await digest.keys_due(utcnow()) == []
async with SessionLocal() as session:
assert await session.get(Setting, digest.DIGEST_CLAIM + local(9, 0).astimezone().date().isoformat()) is None
async def test_claim_rows_are_not_listed_as_settings(received):
await digest.digest_due(local(9, 0))
async with auth_env.client() as c:
root = await auth_env.login("root")
body = (await root.get("/api/settings")).json()
assert not [k for k in body if k.startswith(digest.STATE_PREFIX)]
# --------------------------------------------------------------------------
# 访问密钥快到期
# --------------------------------------------------------------------------
async def _expiring_audits(key_id: str) -> list[int]:
async with SessionLocal() as session:
rows = (await session.execute(select(AuditEvent).where(AuditEvent.action == "access_key.expiring",
AuditEvent.subject_id == key_id))).scalars()
return sorted(r.data["days"] for r in rows)
async def _age(key, days: float) -> None:
from app.db.models_identity import AccessKey
async with SessionLocal() as session:
row = await session.get(AccessKey, key.id)
row.created_at = utcnow() - timedelta(days=days)
await session.commit()
async def test_personal_key_reminds_its_owner_at_7_and_1_days(received):
await auth_env.personal_keys(True)
dee = await auth_env.make_user("dee", "user")
mine = await personal(dee, ["key.expiring"])
async with SessionLocal() as session:
session.add(NotifyChannel(name="全局", scope="global", format="generic", url=encrypt(URL),
events=["key.expiring"]))
await session.commit()
key, secret = await auth_env.make_key(dee, days=30, name="周报脚本")
await _age(key, 60)
now = utcnow()
assert await digest.keys_due(now) == [], "离到期还有 30 天"
assert await digest.keys_due(now + timedelta(days=23, hours=1)) == [key.id]
await service.drain()
[body] = received
assert body["event"] == "key.expiring" and "周报脚本" in body["title"] and "7 天后" in body["title"]
assert secret not in json.dumps(body) and key.prefix in body["text"]
assert body["links"] == [{"label": "去轮换", "url": "https://agentlab.example.com/settings/keys"}]
async with SessionLocal() as session:
to = (await session.execute(select(NotifyDelivery.channel_id))).scalars().all()
assert to == [mine.id], "个人密钥只推本人,不推全局渠道"
assert await digest.keys_due(now + timedelta(days=24)) == [], "这一档推过了"
digest.forget()
assert await digest.keys_due(now + timedelta(days=25)) == [], "重启后按审计认得出推过了"
assert await digest.keys_due(now + timedelta(days=29, hours=1)) == [key.id], "到期前 1 天再推一次"
assert await digest.keys_due(now + timedelta(days=29, hours=12)) == []
assert await _expiring_audits(key.id) == [1, 7]
await service.drain()
assert len(received) == 2 and "1 天内" in received[1]["title"]
async def test_service_account_keys_remind_global_channels(received):
svc = await auth_env.make_service("svc-portal")
async with SessionLocal() as session:
glob = NotifyChannel(name="运维群", scope="global", format="generic", url=encrypt(URL), events=["key.expiring"])
session.add(glob)
await session.commit()
key, _ = await auth_env.make_key(svc, days=5, name="门户拉数")
await _age(key, 30)
assert await digest.keys_due(utcnow()) == [key.id]
await service.drain()
[body] = received
assert "服务账号 svc-portal" in body["text"]
async with SessionLocal() as session:
assert (await session.execute(select(NotifyDelivery.channel_id))).scalars().all() == [glob.id]
async def test_keys_that_should_not_remind(received):
from app.db.models_identity import AccessKey
svc = await auth_env.make_service("svc-bi")
async with SessionLocal() as session:
session.add(NotifyChannel(name="运维群", scope="global", format="generic", url=encrypt(URL),
events=["key.expiring"]))
await session.commit()
short, _ = await auth_env.make_key(svc, days=3, name="临时") # 建的时候就只有 3 天:不推 7 天那一档
old, _ = await auth_env.make_key(svc, days=0.5, name="轮换下来的")
await _age(old, 90)
new, _ = await auth_env.make_key(svc, days=90, name="轮换上去的")
revoked, _ = await auth_env.make_key(svc, days=2, name="撤销了的")
await _age(revoked, 30)
async with SessionLocal() as session:
(await session.get(AccessKey, new.id)).rotated_from = old.id
(await session.get(AccessKey, revoked.id)).revoked_at = utcnow()
await session.commit()
assert await digest.keys_due(utcnow()) == []
assert await digest.keys_due(utcnow() + timedelta(days=2, hours=1)) == [short.id], "1 天那一档照推"
await service.drain()
assert len(received) == 1
+7 -4
View File
@@ -32,8 +32,10 @@ URL = "https://hooks.example.com/agentlab/notify-events-5d1c"
#: 本轮登记的六个(总方案 X6)
THIS_ROUND = ("source.changed", "ledger.recall", "eval.regressed", "eval.failed", "task.digest", "key.expiring")
#: 其中已经推送的(第 3 波:定时评测的通知只推全局渠道,notify_eval),其余还没有
PUSHED_THIS_ROUND = {"eval.regressed": {"global"}, "eval.failed": {"global"}}
#: 其中已经实现了推送、打开了订阅的(推给哪几条路):定时评测的两个只推全局渠道(第 3 波 W3-c,notify_eval);每天的待办汇总
#: 只推个人渠道;访问密钥快到期,个人密钥推本人、服务账号的推全局渠道(第 3 波 W3-d,notify/digest.py)。其余还没有
PUSHED_THIS_ROUND = {"eval.regressed": {"global"}, "eval.failed": {"global"},
"task.digest": {"user"}, "key.expiring": {"global", "user"}}
@pytest.fixture
@@ -57,8 +59,9 @@ def test_this_rounds_events_are_registered_once_and_not_pushed_yet():
assert name in service.EVENTS, f"{name} 没有登记"
assert service.EVENTS[name].label, name
if name in PUSHED_THIS_ROUND:
continue
assert service.EVENTS[name].scopes == (), f"{name} 还没有推送:不该有人订阅得到"
assert set(service.EVENTS[name].scopes) == PUSHED_THIS_ROUND[name], name
else:
assert service.EVENTS[name].scopes == (), f"{name} 还没有推送:不该有人订阅得到"
assert len({e.label for e in service.EVENTS.values()}) == len(service.EVENTS), "两个事件叫成了同一句"
assert service.EVENT_LABEL == {k: v.label for k, v in service.EVENTS.items()}
+470
View File
@@ -0,0 +1,470 @@
"""邮件:通知渠道的第五种格式(notify/mail.py,docs/design/next-round/04-对外集成与触达.md B2)。
全程用假的 SMTP 工厂(mail._smtp_factory),不连任何邮件服务器。守的是:
- 个人邮件渠道只发到拥有者在名录里的账号邮箱;账号没有邮箱时记一条失败的投递、写明原因;个人渠道不收地址;
- 全局、应用的邮件渠道填固定的收件地址(最多 20 个,格式不对的拒收);邮件服务没启用时不收新的邮件渠道;
- 4xx(临时错误)和连不上按 1/2/4 秒重试,5xx 不重试;报错里绝不出现口令;
- 邮件里只有标题、状态、链接,纯文本 + 转义过的 HTML,带 List-Unsubscribe;投递记录成功写「已交给邮件服务器」(不说已送达);
- 设置的 mail 一组:口令加密存、读的时候只说配没配;启用时要配全;发件人名称不许换行;只有管理员能改;
- 发测试邮件只发到管理员自己的账号邮箱:关登录、账号没有邮箱时 422 说清怎么办;
- 管理员开了「默认发邮件给待办处理人」:第一次派到待办的人自动有一个个人邮件渠道(默认订阅三项),本人删了不再建回来;
开关缺省关时不建。
人名、地址、主机都是虚构的。
"""
from __future__ import annotations
import json
import smtplib
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete, select
from app.core.crypto import encrypt
from app.db.base import SessionLocal
from app.db.models import NotifyChannel, NotifyDelivery, Setting, User
from app.notify import mail, service
from app.notify.core import Message
PASSWORD = "Sm7p-Pa55word-Zq"
class FakeSMTP:
"""假的 SMTP 连接:记下登录、发出的信;box.failures 里排着的异常按顺序在 send_message 上抛。"""
def __init__(self, box: SimpleNamespace) -> None:
self.box = box
def starttls(self, context=None) -> None:
self.box.tls += 1
def login(self, username: str, password: str) -> None:
self.box.logins.append((username, password))
if self.box.auth_error:
# 有的服务器会把收到的内容回显在错误里:报错里也不能出现口令
raise smtplib.SMTPAuthenticationError(535, f"5.7.8 bad credentials {password}".encode())
def send_message(self, em, from_addr=None, to_addrs=None):
if self.box.failures:
raise self.box.failures.pop(0)
self.box.sent.append(SimpleNamespace(from_addr=from_addr, to=list(to_addrs or []), em=em))
return {}
def quit(self) -> None:
self.box.quits += 1
def close(self) -> None:
pass
@pytest.fixture
def smtp(monkeypatch):
box = SimpleNamespace(sent=[], logins=[], failures=[], connects=[], refuse=0, tls=0, quits=0, auth_error=False)
def factory(host, port, security, timeout):
box.connects.append((host, port, security, timeout))
if box.refuse:
box.refuse -= 1
raise ConnectionRefusedError("refused")
return FakeSMTP(box)
monkeypatch.setattr(mail, "_smtp_factory", factory)
monkeypatch.setattr(mail, "RETRY_DELAYS", (0, 0, 0))
return box
@pytest.fixture(autouse=True)
async def clean():
"""前后各清一遍:同一个进程里别的测试留下的渠道、投递记录不算进来。"""
await _clean()
yield
await service.drain()
await _clean()
async def _clean() -> None:
async with SessionLocal() as session:
await session.execute(delete(NotifyDelivery))
await session.execute(delete(NotifyChannel))
await session.execute(delete(Setting).where(Setting.key.in_([mail.GROUP, "notify", service.MAIL_AUTO_KEY])))
await session.execute(delete(User).where(User.username.like("mail-%")))
await session.commit()
from app.core import public_url
public_url.remember(None) # 发信时按库里的对外访问地址对齐了缓存:删了那一行,缓存也清掉
async def configure(**over) -> None:
value = mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 587, "security": "starttls",
"username": "agentlab", "password": PASSWORD, "from_addr": "agentlab@example.com",
"from_name": "AgentLab", **over})
async with SessionLocal() as session:
row = await session.get(Setting, mail.GROUP)
if row is None:
session.add(Setting(key=mail.GROUP, value=value))
else:
row.value = value
notify = await session.get(Setting, "notify")
if notify is None:
session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example.com"}))
else:
# 别的测试留下的 notify 一组:对外访问地址换成这里要的(退订头按它拼)
notify.value = {**(notify.value or {}), "public_base_url": "https://agentlab.example.com"}
await session.commit()
async def person(name: str, email: str | None) -> User:
async with SessionLocal() as session:
user = User(username=f"mail-{name}", display_name=name, role="user", email=email)
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def channel(scope: str, owner_id: str | None = None, to: str = mail.PERSONAL_PLACEHOLDER,
events: list[str] | None = None) -> NotifyChannel:
async with SessionLocal() as session:
row = NotifyChannel(name=f"邮件-{scope}", scope=scope, owner_id=owner_id, format="email", url=encrypt(to),
events=events or ["task.opened"])
session.add(row)
await session.commit()
await session.refresh(row)
return row
MESSAGE = {"event": "task.opened", "status": "pending", "title": "新的待办:审批 <b>付款</b>",
"lines": [("状态", "待处理"), ("所在", "应用「报销 & 付款」")],
"links": [("去处理", "https://agentlab.example.com/apps/inbox/t1")]}
async def _deliveries() -> list[NotifyDelivery]:
async with SessionLocal() as session:
return list((await session.execute(select(NotifyDelivery))).scalars())
# --------------------------------------------------------------------------
# 发到谁
# --------------------------------------------------------------------------
async def test_personal_mail_goes_to_the_account_email_only(smtp):
await configure()
ana = await person("ana", "ana@example.com")
row = await channel("user", ana.id)
sent = await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened",
run_id=None, task_id="t1")
assert sent == 1
[mail_out] = smtp.sent
assert mail_out.to == ["ana@example.com"] and mail_out.from_addr == "agentlab@example.com"
assert smtp.logins == [("agentlab", PASSWORD)] and smtp.tls == 1
em = mail_out.em
assert em["Subject"] == "【AgentLab】新的待办:审批 <b>付款</b>"
assert em["List-Unsubscribe"] == "<https://agentlab.example.com/settings/notify>"
assert em["Auto-Submitted"] == "auto-generated"
text = em.get_body(("plain",)).get_content()
html = em.get_body(("html",)).get_content()
assert "状态:待处理" in text and "https://agentlab.example.com/apps/inbox/t1" in text
assert "&lt;b&gt;付款&lt;/b&gt;" in html and "<b>付款</b>" not in html, "HTML 里的内容一律转义"
assert "报销 &amp; 付款" in html
[delivery] = await _deliveries()
assert (delivery.status, delivery.error, delivery.http_status) == ("ok", None, None)
async def test_personal_mail_without_account_email_is_a_failed_delivery_with_the_reason(smtp):
await configure()
bo = await person("bo", None)
row = await channel("user", bo.id)
await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None,
task_id="t1")
assert smtp.sent == [] and smtp.connects == [], "没有邮箱就不连服务器"
[delivery] = await _deliveries()
assert delivery.status == "failed" and delivery.error == mail.NO_EMAIL
assert "设置 → 身份" in delivery.error
async def test_global_mail_goes_to_the_fixed_addresses(smtp):
await configure()
row = await channel("global", to="ops@example.com, lead@example.com")
await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None,
task_id="t1")
assert smtp.sent[0].to == ["ops@example.com", "lead@example.com"]
async def test_disabled_mail_service_records_why(smtp):
await configure(enabled=False)
row = await channel("global", to="ops@example.com")
await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None,
task_id="t1")
[delivery] = await _deliveries()
assert delivery.status == "failed" and delivery.error == mail.NOT_ENABLED and smtp.connects == []
# --------------------------------------------------------------------------
# 重试与报错
# --------------------------------------------------------------------------
def _cfg() -> mail.MailConfig:
return mail.config_of(mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 587,
"security": "starttls", "username": "agentlab", "password": PASSWORD,
"from_addr": "agentlab@example.com"}))
async def test_temporary_errors_and_refused_connections_are_retried(smtp):
smtp.failures = [smtplib.SMTPDataError(451, b"4.3.0 try again later")]
smtp.refuse = 1
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
assert out.ok is True and out.attempts == 3, "连不上一次、451 一次,第三次交给了邮件服务器"
assert len(smtp.sent) == 1
async def test_permanent_errors_are_not_retried(smtp):
smtp.failures = [smtplib.SMTPDataError(550, b"5.7.1 message rejected")]
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
assert out.ok is False and out.attempts == 1 and out.retryable is False
assert "550" in out.error and "smtp.example.com" in out.error
async def test_retries_stop_after_three(smtp):
smtp.refuse = 10
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
assert out.ok is False and out.attempts == 4 and len(smtp.connects) == 4
assert out.error == "无法连接邮件服务器 smtp.example.com:587"
async def test_errors_never_contain_the_password(smtp, caplog):
smtp.auth_error = True
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
assert out.ok is False and out.attempts == 1, "认证失败(5xx)不重试"
assert PASSWORD not in (out.error or "") and "535" in out.error
assert PASSWORD not in caplog.text
smtp.auth_error = False
smtp.failures = [smtplib.SMTPSenderRefused(553, f"sender {PASSWORD} refused".encode(), "agentlab@example.com")]
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
assert PASSWORD not in (out.error or "")
async def test_partially_refused_recipients_are_said_plainly(smtp, monkeypatch):
monkeypatch.setattr(FakeSMTP, "send_message", lambda self, em, from_addr=None, to_addrs=None: {"x@example.com": (550, b"no")})
out = await mail.send(_cfg(), ["ana@example.com", "x@example.com"], Message(title="t"), label="测试")
assert out.ok is True and out.error == "已交给邮件服务器,其中 1 个收件地址被拒收"
def test_line_breaks_in_titles_never_reach_the_headers():
em = mail.build(_cfg(), ["ana@example.com"], Message(title="新的待办:审批\r\nBcc: x@example.com"), unsubscribe=None)
assert em["Subject"] == "【AgentLab】新的待办:审批 Bcc: x@example.com" and em["Bcc"] is None
def test_ssl_security_uses_no_starttls(smtp):
cfg = mail.config_of(mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 465,
"security": "ssl", "from_addr": "agentlab@example.com"}))
out = mail._send_once(cfg, ["ana@example.com"], mail.build(cfg, ["ana@example.com"], Message(title="t"),
unsubscribe=None))
assert out.ok and smtp.tls == 0 and smtp.logins == [], "没填用户名不登录"
assert smtp.connects[0][:3] == ("smtp.example.com", 465, "ssl") and smtp.connects[0][3] == mail.TIMEOUT_S
# --------------------------------------------------------------------------
# 渠道接口
# --------------------------------------------------------------------------
@pytest.fixture
async def client():
from app.main import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def test_mail_channels_need_the_mail_service_and_valid_addresses(client, smtp):
body = {"name": "运维邮件", "scope": "global", "format": "email", "url": "ops@example.com", "events": ["task.opened"]}
refused = await client.post("/api/notify/channels", json=body)
assert refused.status_code == 422 and refused.json()["detail"] == mail.NOT_ENABLED
await configure()
too_many = ", ".join(f"p{i}@example.com" for i in range(21))
r = await client.post("/api/notify/channels", json={**body, "url": too_many})
assert r.status_code == 422 and "最多 20 个" in r.json()["detail"]
r = await client.post("/api/notify/channels", json={**body, "url": "ops@example.com\nnot-an-address"})
assert r.status_code == 422 and "not-an-address" in r.json()["detail"]
made = await client.post("/api/notify/channels", json={**body, "url": "ops@example.com;lead@example.com"})
assert made.status_code == 201, made.text
row = made.json()
assert row["format"] == "email" and row["mail_to"] == ["ops@example.com", "lead@example.com"]
assert row["url_host"] == "" and row["signed"] is False
listed = (await client.get("/api/notify/channels")).json()
assert listed["mail"] == {"enabled": True, "account_email": None}, "关登录时没有账号邮箱"
tested = (await client.post(f"/api/notify/channels/{row['id']}/test")).json()
assert tested["ok"] is True and tested["message"].startswith("已交给邮件服务器")
assert "是否送达以邮件服务器为准" in tested["message"]
deliveries = (await client.get(f"/api/notify/deliveries?channel_id={row['id']}")).json()["items"]
assert [(d["event"], d["status"], d["format"]) for d in deliveries] == [("test", "ok", "email")]
# 只改订阅、不传地址:照旧
kept = await client.patch(f"/api/notify/channels/{row['id']}", json={"events": ["task.opened", "action.unknown"]})
assert kept.status_code == 200 and kept.json()["mail_to"] == ["ops@example.com", "lead@example.com"]
# 换成 Webhook 要填 Webhook 地址
back = await client.patch(f"/api/notify/channels/{row['id']}", json={"format": "generic"})
assert back.status_code == 422 and "Webhook 地址" in back.json()["detail"]
async def test_personal_mail_channel_takes_no_address(smtp):
from tests.fakes import auth_env
await configure()
await auth_env.reset()
try:
await auth_env.turn_on_local()
await auth_env.make_user("mail-uma", "user", email="uma@example.com")
uma = await auth_env.login("mail-uma")
r = await uma.post("/api/notify/channels", json={"name": "我的邮件", "scope": "user", "format": "email",
"url": "someone@example.com", "events": ["task.opened"]})
assert r.status_code == 422 and "账号邮箱" in r.json()["detail"], "个人渠道不能填任意地址(不当中继)"
r = await uma.post("/api/notify/channels", json={"name": "我的邮件", "scope": "user", "format": "email",
"events": ["task.opened", "task.digest"]})
assert r.status_code == 201, r.text
assert r.json()["mail_to"] == ["uma@example.com"]
listed = (await uma.get("/api/notify/channels")).json()
assert listed["mail"] == {"enabled": True, "account_email": "uma@example.com"}
tested = (await uma.post(f"/api/notify/channels/{r.json()['id']}/test")).json()
assert tested["ok"] is True and smtp.sent[-1].to == ["uma@example.com"]
finally:
await auth_env.reset()
async def test_users_listing_channels_do_not_get_global_recipients(smtp):
"""使用者列渠道照样看得到全局、应用渠道(应用的通知里要挑一个,只用名字和 id),但邮件渠道的收件地址只给能改它的人
(搭建者以上):只给条数,不给地址。Webhook 只给主机名,邮件地址不能比它多露(复核 L2)。"""
from tests.fakes import auth_env
await configure()
await auth_env.reset()
try:
await auth_env.turn_on_local()
await auth_env.make_user("mail-uma", "user", email="uma@example.com")
await auth_env.make_user("mail-bo", "builder")
everyone = "finance-lead@example.com, ops@example.com"
made = await channel("global", to=everyone)
await channel("app", to="team@example.com")
uma = await auth_env.login("mail-uma")
mine = await uma.post("/api/notify/channels", json={"name": "我的邮件", "scope": "user", "format": "email",
"events": ["task.opened"]})
assert mine.status_code == 201, mine.text
listed = (await uma.get("/api/notify/channels")).json()["items"]
by = {row["scope"]: row for row in listed}
assert set(by) == {"global", "app", "user"}, "全局、应用渠道照样列(应用的通知里要挑)"
assert "finance-lead" not in json.dumps(listed) and "team@" not in json.dumps(listed), "收件地址不给使用者"
assert "mail_to" not in by["global"] and by["global"]["mail_count"] == 2
assert "mail_to" not in by["app"] and by["app"]["mail_count"] == 1
assert by["user"]["mail_to"] == ["uma@example.com"], "自己的个人渠道照常写发到哪"
bo = await auth_env.login("mail-bo")
shown = {row["id"]: row for row in (await bo.get("/api/notify/channels")).json()["items"]}
assert shown[made.id]["mail_to"] == ["finance-lead@example.com", "ops@example.com"], "能改的人看得到,改地址要整份重填"
finally:
await auth_env.reset()
# --------------------------------------------------------------------------
# 设置
# --------------------------------------------------------------------------
async def test_mail_settings_keep_the_password_encrypted_and_hidden(client):
put = await client.put("/api/settings", json={"values": {"mail": {
"enabled": True, "host": "smtp.example.com", "port": 465, "security": "ssl", "username": "agentlab",
"password": PASSWORD, "from_addr": "agentlab@example.com", "from_name": "流程助手"}}})
assert put.status_code == 200, put.text
shown = put.json()["mail"]
assert shown["password"] is None and shown["password_set"] is True and PASSWORD not in json.dumps(put.json())
async with SessionLocal() as session:
stored = (await session.get(Setting, "mail")).value
assert stored["password"] != PASSWORD and PASSWORD not in json.dumps(stored)
assert mail.config_of(stored).password == PASSWORD
# 整组回写(口令是 null、带着 password_set):口令照旧
again = await client.put("/api/settings", json={"values": {"mail": {**shown, "from_name": "AgentLab"}}})
assert again.status_code == 200 and again.json()["mail"]["password_set"] is True
async with SessionLocal() as session:
assert mail.config_of((await session.get(Setting, "mail")).value).password == PASSWORD
# 空串表示去掉
cleared = await client.put("/api/settings", json={"values": {"mail": {"password": ""}}})
assert cleared.json()["mail"]["password_set"] is False
assert cleared.json()["mail"]["host"] == "smtp.example.com", "只给一项的不冲掉别的"
@pytest.mark.parametrize("value, fragment", [
({"enabled": True}, "SMTP 服务器"),
({"enabled": True, "host": "smtp.example.com"}, "发件地址"),
({"host": "smtp://smtp.example.com"}, "主机名"),
({"port": 70000}, "端口"),
({"security": "tls"}, "加密方式"),
({"from_addr": "not-an-address"}, "发件地址"),
({"from_name": "AgentLab\r\nBcc: x@example.com"}, "换行"),
({"relay": True}, "不支持「relay」"),
])
async def test_mail_settings_are_validated(client, value, fragment):
put = await client.put("/api/settings", json={"values": {"mail": value}})
assert put.status_code == 422, put.text
assert fragment in put.json()["detail"]
async def test_mail_settings_are_admin_only_and_test_mail_goes_to_the_admin(smtp):
from tests.fakes import auth_env
await configure()
await auth_env.reset()
try:
async with auth_env.client() as off:
r = await off.post("/api/settings/mail/test")
assert r.status_code == 422 and r.json()["code"] == "mail_test_no_address", "关登录时没有账号邮箱"
root = await auth_env.turn_on_local()
r = await root.post("/api/settings/mail/test")
assert r.status_code == 422 and "没有邮箱地址" in r.json()["detail"]
async with SessionLocal() as session:
admin = (await session.execute(select(User).where(User.username == "root"))).scalar_one()
admin.email = "root@example.com"
await session.commit()
r = await root.post("/api/settings/mail/test")
assert r.status_code == 200 and r.json()["ok"] is True, r.text
assert "已交给邮件服务器" in r.json()["message"] and smtp.sent[-1].to == ["root@example.com"]
await auth_env.make_user("mail-bea", "builder")
bea = await auth_env.login("mail-bea")
assert (await bea.get("/api/settings")).json()["mail"]["password"] is None, "搭建者读得到,看不到口令"
assert (await bea.put("/api/settings", json={"values": {"mail": {"enabled": False}}})).status_code == 403
assert (await bea.post("/api/settings/mail/test")).status_code == 403
finally:
await auth_env.reset()
# --------------------------------------------------------------------------
# 默认发邮件给待办处理人
# --------------------------------------------------------------------------
async def test_assignees_get_a_personal_mail_channel_only_when_the_admin_turns_it_on(smtp):
from tests.fakes import auth_env
await auth_env.reset()
try:
await auth_env.turn_on_local()
cy = await person("cy", "cy@example.com")
await configure()
assert await service.ensure_assignee_mail([cy.username]) == 0, "缺省关:不建"
await configure(default_for_assignees=True)
assert await service.ensure_assignee_mail([cy.username]) == 1
async with SessionLocal() as session:
[row] = (await session.execute(select(NotifyChannel).where(NotifyChannel.owner_id == cy.id))).scalars()
assert (row.scope, row.format, row.enabled) == ("user", "email", True)
assert row.events == ["task.opened", "action.unknown", "review.done"]
await session.delete(row)
await session.commit()
assert await service.ensure_assignee_mail([cy.username]) == 0, "本人删了就是不要:不再建回来"
finally:
await auth_env.reset()
async def test_assignee_mail_is_not_created_when_login_is_off(smtp):
dee = await person("dee", "dee@example.com")
await configure(default_for_assignees=True)
assert await service.ensure_assignee_mail([dee.username]) == 0, "关登录没有认证过的账号,个人渠道无从挂起"
+4 -1
View File
@@ -109,13 +109,16 @@ async def test_env_locks_the_setting(monkeypatch):
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 一组照旧是库里存的那一份"
assert body[NOTIFY_GROUP]["public_base_url"] is 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
# 同一组里别的项(待办汇总的时间)照常能改:环境变量只锁对外访问地址这一项
r = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": "08:15"}}})
assert r.status_code == 200 and r.json()[NOTIFY_GROUP]["digest_at"] == "08:15"
# 只读的 public_url 一组写不进库
r = await c.put("/api/settings", json={"values": {"public_url": {"value": SET_URL, "env_locked": False}}})
assert r.status_code == 200
+8 -7
View File
@@ -33,16 +33,17 @@ async def clean():
async def test_daily_limit_is_a_registered_setting_with_default_200():
async with auth_env.client() as c:
body = (await c.get("/api/settings")).json()
assert body[APPS_GROUP] == {"on_demand_daily_limit": 200, "user_personal_apps": False}
assert body[APPS_GROUP] == {"on_demand_daily_limit": 200, "user_personal_apps": False, "share_links": False}
put = await c.put("/api/settings", json={"values": {APPS_GROUP: {"on_demand_daily_limit": 30}}})
assert put.status_code == 200, put.text
assert put.json()[APPS_GROUP] == {"on_demand_daily_limit": 30, "user_personal_apps": False}
assert put.json()[APPS_GROUP] == {"on_demand_daily_limit": 30, "user_personal_apps": False, "share_links": False}
assert (await c.get("/api/settings")).json()[APPS_GROUP] == {"on_demand_daily_limit": 30,
"user_personal_apps": False}
"user_personal_apps": False, "share_links": False}
async with SessionLocal() as session:
assert await apps_settings(session) == {"on_demand_daily_limit": 30, "user_personal_apps": False}
assert apps_values({"on_demand_daily_limit": "坏的", "user_personal_apps": "开"}) == {
"on_demand_daily_limit": 200, "user_personal_apps": False}, "存坏了的按默认"
assert await apps_settings(session) == {"on_demand_daily_limit": 30, "user_personal_apps": False,
"share_links": False}
assert apps_values({"on_demand_daily_limit": "坏的", "user_personal_apps": "开", "share_links": 1}) == {
"on_demand_daily_limit": 200, "user_personal_apps": False, "share_links": False}, "存坏了的按默认"
@pytest.mark.parametrize("value, fragment", [
@@ -81,7 +82,7 @@ async def test_auth_settings_are_not_read_or_written_here():
await auth_env.make_user("bob", "builder")
bob = await auth_env.login("bob")
assert (await bob.get("/api/settings")).json()[APPS_GROUP] == {"on_demand_daily_limit": 200,
"user_personal_apps": False}
"user_personal_apps": False, "share_links": False}
assert (await bob.put("/api/settings", json={"values": {APPS_GROUP: {"on_demand_daily_limit": 1}}})
).status_code == 403, "设置页的应用一组只有管理员能改"
assert (await bob.put("/api/settings", json={"values": {APPS_GROUP: {"user_personal_apps": True}}})
+31 -8
View File
@@ -1,4 +1,5 @@
"""通知设置(api/settings.py 的 notify 一组):定时任务的 Webhook 里拼运行链接用的对外访问地址。
"""通知设置(api/settings.py 的 notify 一组):定时任务的 Webhook 里拼运行链接用的对外访问地址,和每天的待办汇总几点发
(digest_at,缺省 09:00,04 细案 B3)。
- 默认没填(null),通知里就不附运行链接
- 只收 http(s) 开头的完整地址;末尾的 / 去掉、首尾空白去掉,空串当没填;带 ? 或 # 的拒收
@@ -31,9 +32,9 @@ def client() -> AsyncClient:
async def test_default_is_no_link():
async with client() as c:
body = (await c.get("/api/settings")).json()
assert body["notify"] == {"public_base_url": None}
assert body["notify"] == {"public_base_url": None, "digest_at": "09:00"}
async with SessionLocal() as session:
assert await notify_settings(session) == {"public_base_url": None}
assert await notify_settings(session) == {"public_base_url": None, "digest_at": "09:00"}
@pytest.mark.parametrize("given, stored", [
@@ -47,10 +48,10 @@ async def test_saved_and_normalized(given, stored):
async with client() as c:
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": given}}})
assert put.status_code == 200, put.text
assert put.json()["notify"] == {"public_base_url": stored}
assert (await c.get("/api/settings")).json()["notify"] == {"public_base_url": stored}
assert put.json()["notify"] == {"public_base_url": stored, "digest_at": "09:00"}
assert (await c.get("/api/settings")).json()["notify"] == {"public_base_url": stored, "digest_at": "09:00"}
async with SessionLocal() as session:
assert await notify_settings(session) == {"public_base_url": stored}
assert await notify_settings(session) == {"public_base_url": stored, "digest_at": "09:00"}
@pytest.mark.parametrize("value, fragment", [
@@ -73,5 +74,27 @@ async def test_invalid_values_are_refused_in_chinese(value, fragment):
def test_broken_stored_value_reads_as_unset():
assert notify_values({"public_base_url": "不是地址"}) == {"public_base_url": None}
assert notify_values("坏掉的") == {"public_base_url": None}
assert notify_values({"public_base_url": "不是地址", "digest_at": "25:00"}) == {"public_base_url": None,
"digest_at": "09:00"}
assert notify_values("坏掉的") == {"public_base_url": None, "digest_at": "09:00"}
@pytest.mark.parametrize("given, ok", [("08:30", True), ("23:59", True), ("00:00", True), ("24:00", False),
("9:00", False), ("09:60", False), (900, False), ("", False)])
async def test_digest_time_is_hh_mm(given, ok):
async with client() as c:
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": given}}})
if ok:
assert put.status_code == 200, put.text
assert put.json()["notify"]["digest_at"] == given
else:
assert put.status_code == 422
assert "待办汇总的发送时间" in put.json()["detail"]
async def test_changing_digest_time_keeps_the_public_address():
"""只给汇总时间的一组:对外访问地址不被冲成没填(设置页的通知分别保存这两项)。"""
async with client() as c:
await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"public_base_url": "https://agentlab.example.com"}}})
put = await c.put("/api/settings", json={"values": {NOTIFY_GROUP: {"digest_at": "17:45"}}})
assert put.json()["notify"] == {"public_base_url": "https://agentlab.example.com", "digest_at": "17:45"}
+416
View File
@@ -0,0 +1,416 @@
"""分享链接(api/shares.py + apps/resolve.py 的 share 旁路,docs/design/next-round/04-对外集成与触达.md C2)。
守的是:
- 管理员开关缺省关,关着建不了、已有的打不开;只有应用的 manage 能建;个人应用、没发布的、这一页没有定时出数的视图都建不了;
- 到期必填,最长 90 天;
- 读的接口不用登录,只认请求头 X-AgentLab-Share:地址里带 key 不认;库里只有 key 的哈希和开头一截;
- 只解析定时出数(最近封存)的视图:按需视图写明原因、从不发起运行(声明了 auto 的也不);流程组件、报告换成写明原因的说明;
- fixed 模式钉在建分享时的那几次运行上(趋势也停在那一期),latest 跟着最新的封存走;
- 下载报告只认这条分享解析出来的运行,内部就是报告导出(遮罩值写「已遮罩」);
- 撤销、到期回 410 并写明;建、撤销写审计,打开只计数;
- 访问密钥一律调不了分享的接口;
- /s 上的安全头:嵌入白名单用 AGENTLAB_EMBED_ORIGINS、Referrer-Policy: no-referrer。
应用、工作流、人名都是虚构的。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from datetime import timedelta
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete, func, select
from app.core.config import BACKEND_DIR
from app.db.base import SessionLocal, utcnow
from app.db.models import AuditEvent, Run, Setting
from app.db.models_apps import ViewShare
from tests.fakes import auth_env
from tests.test_access_key_use import KPI, formal, published, wait, workflow
from tests.test_access_key_use import world # noqa: F401 - 开登录、建管理员、收尾清掉应用
HEADER = "X-AgentLab-Share"
@pytest.fixture(autouse=True)
async def switch(world): # noqa: F811
await set_switch(True)
yield
async with SessionLocal() as s:
await s.execute(delete(ViewShare))
await s.execute(delete(Setting).where(Setting.key == "apps"))
await s.commit()
async def set_switch(on: bool) -> None:
async with SessionLocal() as s:
row = await s.get(Setting, "apps")
if row is None:
s.add(Setting(key="apps", value={"share_links": on}))
else:
row.value = {**row.value, "share_links": on}
await s.commit()
def anonymous(**headers: str) -> AsyncClient:
"""不带会话 cookie、不带 CSRF 头的客户端:拿到链接的人就是这样打开的。"""
from app.main import app
return AsyncClient(transport=ASGITransport(app=app), base_url="http://test", headers=headers)
def board(wf: str, h: str, *, body: list | None = None, views: dict | None = None) -> dict:
return {"format": "agentlab.app/1", "name": "周报看板",
"pins": [{"alias": "main", "workflow_id": wf, "version": 1, "graph_hash": h, "use": "view"}],
"views": views or {"latest": {"source": {"kind": "latest_sealed", "pin": "main", "from": "formal"}}},
"pages": [{"id": "home", "title": "首页", "body": body or [
{"id": "gmv", "type": "metric", "props": {"label": "销售额"},
"bind": {"view": "latest", "metric": "card.gmv"}},
{"id": "trend", "type": "series", "bind": {"view": "latest", "series": "card.gmv"}}]}]}
def soon(days: float = 30) -> str:
return (utcnow() + timedelta(days=days)).isoformat()
async def share(app_id: str, **body) -> dict:
admin = await auth_env.login("root")
r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon(), **body})
assert r.status_code == 201, r.text
return r.json()
# --------------------------------------------------------------------------
# 建:开关、权限、到期
# --------------------------------------------------------------------------
async def test_switch_and_manage_are_both_required(world): # noqa: F811
wf, h = await workflow(KPI)
await auth_env.make_user("pia", "user")
app_id = await published(world, board(wf, h), [("user", "pia", "observe")])
admin = await auth_env.login("root")
await set_switch(False)
r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon()})
assert r.status_code == 403 and r.json()["code"] == "share_links_off" and "允许建分享链接" in r.json()["detail"]
listed = (await admin.get(f"/api/apps/{app_id}/shares")).json()
assert listed["enabled"] is False and listed["items"] == [] and listed["max_days"] == 90
await set_switch(True)
pia = await auth_env.login("pia")
r = await pia.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon()})
assert r.status_code == 403 and r.json()["code"] == "app_forbidden", "查看全部实例也不够,要管理权限"
made = await share(app_id)
assert made["path"] == f"/s#{made['key']}" and made["state"] == "active" and made["mode"] == "latest"
async with SessionLocal() as s:
row = await s.get(ViewShare, made["id"])
assert row.key_hash != made["key"] and made["key"] not in json.dumps(
{c.name: str(getattr(row, c.name)) for c in ViewShare.__table__.columns}), "库里没有 key 的明文"
assert row.prefix == made["key"][:6] and made["prefix"] == row.prefix
listed = (await admin.get(f"/api/apps/{app_id}/shares")).json()
assert [i["id"] for i in listed["items"]] == [made["id"]] and "key" not in listed["items"][0]
assert listed["items"][0]["page_title"] == "首页"
@pytest.mark.parametrize("expires, fragment", [(None, "必须设到期时间"), (-1, "现在之后"), (91, "最长 90 天")])
async def test_expiry_is_required_and_at_most_90_days(world, expires, fragment): # noqa: F811
wf, h = await workflow(KPI)
app_id = await published(world, board(wf, h), [])
admin = await auth_env.login("root")
body = {"page_id": "home", **({"expires_at": soon(expires)} if expires is not None else {})}
r = await admin.post(f"/api/apps/{app_id}/shares", json=body)
assert r.status_code == 422 and r.json()["code"] == "share_expires_invalid" and fragment in r.json()["detail"]
ok = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon(90)})
assert ok.status_code == 201
async def test_pages_without_sealed_views_and_personal_apps_cannot_be_shared(world): # noqa: F811
wf, h = await workflow(KPI)
on_demand = board(wf, h, views={"latest": {"source": {"kind": "on_demand", "pin": "main"}}},
body=[{"id": "gmv", "type": "metric", "bind": {"view": "latest", "metric": "card.gmv"}}])
app_id = await published(world, on_demand, [])
admin = await auth_env.login("root")
r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "home", "expires_at": soon()})
assert r.status_code == 409 and r.json()["code"] == "share_no_sealed_view"
r = await admin.post(f"/api/apps/{app_id}/shares", json={"page_id": "nope", "expires_at": soon()})
assert r.status_code == 404
draft_only = (await admin.post("/api/apps", json={"name": "草稿", "spec": board(wf, h)})).json()["id"]
world["apps"].append(draft_only)
r = await admin.post(f"/api/apps/{draft_only}/shares", json={"page_id": "home", "expires_at": soon()})
assert r.status_code == 409 and r.json()["code"] == "app_unpublished"
from app.db.models import App
async with SessionLocal() as s:
(await s.get(App, draft_only)).scope = "personal"
await s.commit()
r = await admin.post(f"/api/apps/{draft_only}/shares", json={"page_id": "home", "expires_at": soon()})
assert r.json()["code"] in ("personal.not_shareable", "app_not_found"), r.text
# --------------------------------------------------------------------------
# 打开
# --------------------------------------------------------------------------
async def test_the_page_is_public_but_only_trusts_the_header(world): # noqa: F811
wf, h = await workflow(KPI)
app_id = await published(world, board(wf, h), [])
run = await formal(wf)
made = await share(app_id)
async with anonymous() as c:
missing = await c.get("/api/shares/page", params={"key": made["key"]})
assert missing.status_code == 400 and missing.json()["code"] == "share_key_missing", "地址里带的 key 不认"
wrong = await c.get("/api/shares/page", headers={HEADER: made["key"][:-2] + "zz"})
assert wrong.status_code == 404 and wrong.json()["code"] == "share_not_found"
r = await c.get("/api/shares/page", headers={HEADER: made["key"]})
assert r.status_code == 200, r.text
body = r.json()
assert body["format"] == "agentlab.share/1"
assert body["share"]["created_by"] == "root" and body["share"]["mode"] == "latest"
view = body["view"]
assert view["format"] == "agentlab.view/1" and view["sources"]["latest"]["run_id"] == run.id
gmv = view["tree"][0]["data"]
assert gmv["state"] == "ok" and "4,321.5" in gmv["text"], "看到的就是使用者看到的那份"
assert view["sources"]["latest"]["stamp"]["sealed"] is True, "印章照常算"
assert body["downloads"] == [{"view": "latest", "run_id": run.id, "reports": [],
"as_of_text": view["sources"]["latest"]["stamp"]["as_of_text"]}]
async def test_only_sealed_views_and_flow_components_become_placeholders(world): # noqa: F811
wf, h = await workflow(KPI)
spec = board(wf, h, views={
"latest": {"source": {"kind": "latest_sealed", "pin": "main", "from": "formal"}},
"ask": {"source": {"kind": "on_demand", "pin": "main", "auto": True}}}, body=[
{"id": "gmv", "type": "metric", "bind": {"view": "latest", "metric": "card.gmv"}},
{"id": "asked", "type": "metric", "bind": {"view": "ask", "metric": "card.gmv"}},
{"id": "todo", "type": "task_list"},
{"id": "mine", "type": "instance_list"}])
app_id = await published(world, spec, [])
await formal(wf)
made = await share(app_id)
async with SessionLocal() as s:
before = await s.scalar(select(func.count(Run.id)))
async with anonymous(**{HEADER: made["key"]}) as c:
view = (await c.get("/api/shares/page")).json()["view"]
async with SessionLocal() as s:
assert await s.scalar(select(func.count(Run.id))) == before, "打开分享页从不发起运行(声明了 auto 的按需视图也不)"
by = {n["id"]: n for n in view["tree"]}
assert by["gmv"]["data"]["state"] == "ok"
assert by["asked"]["data"]["state"] == "empty" and "只显示定时出数的视图" in by["asked"]["data"]["message"]
for cid, kind in (("todo", "task_list"), ("mine", "instance_list")):
assert by[cid]["type"] == "notice" and by[cid]["share_placeholder"] == kind and by[cid]["props"]["text"]
assert "data" not in by[cid], "不取实例、待办"
async def test_fixed_mode_stays_on_the_runs_it_was_created_with(world): # noqa: F811
wf, h = await workflow(KPI)
app_id = await published(world, board(wf, h), [])
first = await formal(wf, "W1")
fixed = await share(app_id, mode="fixed")
latest = await share(app_id)
second = await formal(wf, "W2")
async with anonymous(**{HEADER: fixed["key"]}) as c:
pinned = (await c.get("/api/shares/page")).json()
other = await c.get("/api/shares/report.html", params={"run": second.id})
assert other.status_code == 404 and other.json()["code"] == "share_run_not_shared"
async with anonymous(**{HEADER: latest["key"]}) as c:
moving = (await c.get("/api/shares/page")).json()
assert pinned["view"]["sources"]["latest"]["run_id"] == first.id
points = pinned["view"]["tree"][1]["data"]["series"][-1]["points"]
assert [p["run_id"] for p in points] == [first.id], "趋势也停在建分享的那一期"
assert moving["view"]["sources"]["latest"]["run_id"] == second.id
async def test_runs_issued_from_questions_are_never_the_latest(world): # noqa: F811
"""问数据的答案按口径正式出具的运行(ledger_issue)参数是从某人的提问里绑出来的:应用页、趋势、分享链接(latest 和建
fixed 时取的那次)、视图数据接口的「最近一次封存」都不取它,下载报告也不认它(复核 H1)。"""
from app.engine.launch import launch_run
svc = await auth_env.make_service()
wf, h = await workflow(KPI)
app_id = await published(world, board(wf, h), [("user", "svc-portal", "observe")])
regular = await formal(wf, "W1")
async with SessionLocal() as s:
launched = await launch_run(s, workflow_id=wf, run_class="formal", version=1, input_payload={"week": "W9"},
actor="编辑", origin_kind="ledger_issue", origin={"source_run": "提问那次"},
expect_hash=h)
issued = await wait(launched.id)
assert issued.status == "succeeded" and issued.manifest_hash, "出具的运行成功并封存了,是更新的那一次"
admin = await auth_env.login("root")
page = (await admin.get(f"/api/apps/{app_id}/pages/home")).json()
assert page["sources"]["latest"]["run_id"] == regular.id, "看板不换成某人提问的那组参数"
points = page["tree"][1]["data"]["series"][-1]["points"]
assert [p["run_id"] for p in points] == [regular.id], "趋势里也没有它"
_, secret = await auth_env.make_key(svc, scopes=("apps.read",))
data = (await auth_env.keyed(secret).get(f"/api/apps/{app_id}/views/latest/data")).json()
assert data["state"] == "ok" and data["run_id"] == regular.id, "视图数据接口同一个取法"
fixed = await share(app_id, mode="fixed")
latest = await share(app_id)
for made in (fixed, latest):
async with anonymous(**{HEADER: made["key"]}) as c:
body = (await c.get("/api/shares/page")).json()
assert body["view"]["sources"]["latest"]["run_id"] == regular.id, made["mode"]
refused = await c.get("/api/shares/report.html", params={"run": issued.id})
assert refused.status_code == 404 and refused.json()["code"] == "share_run_not_shared", made["mode"]
def test_every_run_origin_is_classified_for_latest_sealed():
"""每种运行来源都要说清算不算「最近一次封存」:以后加了来源、没在 apps/resolve 归类的,这里先失败。"""
from app.apps.resolve import NOT_SEALED_ORIGINS, SEALED_ORIGINS
from app.engine.origin import ORIGIN_KINDS
assert not set(SEALED_ORIGINS) & set(NOT_SEALED_ORIGINS)
assert set(SEALED_ORIGINS) | set(NOT_SEALED_ORIGINS) == set(ORIGIN_KINDS)
assert "ledger_issue" in NOT_SEALED_ORIGINS and "app_backfill" in NOT_SEALED_ORIGINS
async def test_report_download_only_for_the_shares_runs_and_masked(world, monkeypatch, tmp_path): # noqa: F811
import sqlite3
from app.data.engine import engines
from app.engine.launch import launch_run
from tests import test_report_masked_metrics as vipmod
path = tmp_path / "vip.db"
db = sqlite3.connect(path)
db.executescript("CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT, phone TEXT);")
db.executemany("INSERT INTO customers VALUES (?, ?, ?)", vipmod.CUSTOMERS)
db.commit()
db.close()
source_id = await vipmod.set_source(path, {"mask_columns": ["phone"]})
await engines.invalidate(source_id)
world["sources"].append("vip")
vipmod.script(monkeypatch, vipmod.CLEAN)
wf, h = await workflow(vipmod.graph(on_violation="flag"))
spec = board(wf, h, body=[{"id": "n", "type": "metric", "bind": {"view": "latest", "metric": "card.n_cust"}},
{"id": "doc", "type": "report", "bind": {"view": "latest", "report": "write"}}])
app_id = await published(world, spec, [])
async with SessionLocal() as s:
launched = await launch_run(s, workflow_id=wf, run_class="formal", version=1, input_payload={},
actor="编辑", origin_kind="launcher")
run = await wait(launched.id)
assert run.status == "succeeded", run.error
made = await share(app_id)
kpi_wf, _ = await workflow(KPI)
stranger = await formal(kpi_wf)
async with anonymous(**{HEADER: made["key"]}) as c:
body = (await c.get("/api/shares/page")).json()
by = {n["id"]: n for n in body["view"]["tree"]}
assert by["doc"]["type"] == "notice" and "下载报告" in by["doc"]["props"]["text"], "报告正文不在线打开"
[download] = body["downloads"]
assert download["run_id"] == run.id and download["reports"] == ["write"]
report = await c.get("/api/shares/report.html", params={"run": run.id})
assert report.status_code == 200, report.text
assert "attachment" in report.headers["content-disposition"]
assert vipmod.leaks(report.text) == [], "报告里没有遮罩列的原值"
refused = await c.get("/api/shares/report.html", params={"run": stranger.id})
assert refused.status_code == 404 and refused.json()["code"] == "share_run_not_shared"
no_header = await anonymous().get("/api/shares/report.html", params={"run": run.id})
assert no_header.status_code == 400
async with SessionLocal() as s:
exported = (await s.execute(select(AuditEvent).where(AuditEvent.action == "report.exported",
AuditEvent.subject_id == run.id))).scalars().all()
assert [(e.actor, e.verified) for e in exported] == [(None, False)], "导出记审计,导出人不冒充谁"
async def test_revoked_and_expired_links_say_so_with_410(world): # noqa: F811
wf, h = await workflow(KPI)
app_id = await published(world, board(wf, h), [])
await formal(wf)
gone = await share(app_id)
old = await share(app_id)
admin = await auth_env.login("root")
r = await admin.delete(f"/api/apps/{app_id}/shares/{gone['id']}")
assert r.status_code == 200 and r.json()["state"] == "revoked" and r.json()["revoked_by"] == "root"
async with SessionLocal() as s:
(await s.get(ViewShare, old["id"])).expires_at = utcnow() - timedelta(minutes=5)
await s.commit()
async with anonymous() as c:
r = await c.get("/api/shares/page", headers={HEADER: gone["key"]})
assert r.status_code == 410 and r.json()["code"] == "share_revoked" and "链接已撤销" in r.json()["detail"]
r = await c.get("/api/shares/page", headers={HEADER: old["key"]})
assert r.status_code == 410 and r.json()["code"] == "share_expired" and "链接已到期" in r.json()["detail"]
live = await share(app_id)
await set_switch(False)
async with anonymous(**{HEADER: live["key"]}) as c:
r = await c.get("/api/shares/page")
assert r.status_code == 403 and r.json()["code"] == "share_links_off", "管理员关掉开关,已有的一并打不开"
assert (await admin.delete(f"/api/apps/{app_id}/shares/{live['id']}")).status_code == 200, "关着也能撤销"
async def test_create_and_revoke_are_audited_and_opening_only_counts(world): # noqa: F811
wf, h = await workflow(KPI)
app_id = await published(world, board(wf, h), [])
await formal(wf)
made = await share(app_id, embed_allowed=True)
async with anonymous(**{HEADER: made["key"]}) as c:
for _ in range(3):
assert (await c.get("/api/shares/page")).status_code == 200
admin = await auth_env.login("root")
await admin.delete(f"/api/apps/{app_id}/shares/{made['id']}")
await admin.delete(f"/api/apps/{app_id}/shares/{made['id']}")
async with SessionLocal() as s:
row = await s.get(ViewShare, made["id"])
actions = (await s.execute(select(AuditEvent.action, AuditEvent.actor).where(
AuditEvent.subject_id == made["id"]).order_by(AuditEvent.at))).all()
assert row.open_count == 3 and row.last_opened_at is not None and row.embed_allowed is True
assert actions == [("app.share_create", "root"), ("app.share_revoke", "root")], "打开不记审计;撤销两次只记一次"
async def test_access_keys_cannot_reach_share_routes(world): # noqa: F811
wf, h = await workflow(KPI)
app_id = await published(world, board(wf, h), [])
made = await share(app_id)
svc = await auth_env.make_service()
_, secret = await auth_env.make_key(svc)
c = auth_env.keyed(secret, **{HEADER: made["key"]})
for method, path in (("GET", "/api/shares/page"), ("GET", f"/api/apps/{app_id}/shares"),
("POST", f"/api/apps/{app_id}/shares")):
r = await c.request(method, path, json={"page_id": "home", "expires_at": soon()} if method == "POST" else None)
assert r.status_code == 403 and r.json()["code"] == "key_route_forbidden", (method, path)
# --------------------------------------------------------------------------
# /s 的安全头
# --------------------------------------------------------------------------
_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 ("/s", "/apps/x", "/api/shares/page"):
r = await c.get(p)
out[p] = {"status": r.status_code, **{k: r.headers.get(k) for k in (
"content-security-policy", "x-frame-options", "referrer-policy")}}
print(json.dumps(out))
asyncio.run(main())
"""
def test_share_page_uses_the_embed_whitelist_and_no_referrer(tmp_path: Path) -> None:
dist = tmp_path / "dist"
(dist / "assets").mkdir(parents=True)
(dist / "index.html").write_text("<!doctype html><html><body><div id=root></div></body></html>")
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_EMBED_ORIGINS": "https://bi.example.org",
"AGENTLAB_FRAME_ANCESTORS": ""}
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])
assert out["/s"]["status"] == 200, "分享页由页面托管给出(不进外壳由前端定)"
assert out["/s"]["content-security-policy"] == "frame-ancestors 'self' https://bi.example.org"
assert out["/s"]["x-frame-options"] is None and out["/s"]["referrer-policy"] == "no-referrer"
assert out["/apps/x"]["content-security-policy"] == "frame-ancestors 'self'", "别的页面不跟着放开嵌入"
assert out["/apps/x"]["x-frame-options"] == "SAMEORIGIN"
assert out["/api/shares/page"]["status"] == 400, "关登录时也只认请求头"
+1 -1
View File
@@ -4,7 +4,7 @@
- 页面、/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 波才加,头的规则先守住);
- 分享页 /s 用 AGENTLAB_EMBED_ORIGINS、Referrer-Policy: no-referrer(真实页面托管下的头另见 test_shares.py);
- 严格的 script-src 只发 Content-Security-Policy-Report-Only,只在页面(index.html)上发,哈希就是构建产物里内联
脚本的 sha256;接口返回的 HTML(导出的报告自带 meta CSP)不带它;
- 接口自己设过的同名头不覆盖、不重复;WebSocket 不经过它;
+8 -2
View File
@@ -38,10 +38,16 @@ BIND_ADDR=0.0.0.0
# X-Frame-Options: SAMEORIGIN),别的站点嵌进去会被浏览器拦下,防点击劫持。HSTS 不在这里加,交给终结 https 的反向代理。
# 同站的门户(和 AgentLab 在同一个可注册域名下,比如 portal.example.internal 和 agentlab.example.internal)要嵌整站时,
# 把门户的地址写在下面,逗号分隔,只写 协议://主机[:端口],主机可以写 *.example.internal。填了之后只发
# frame-ancestors、不再发 X-Frame-Options。登录用的 cookie 只随同站的请求带上,跨站的门户嵌整站登录不了。
# frame-ancestors、不再发 X-Frame-Options。同站时登录用的会话 cookie(SameSite=Lax)会随 iframe 里的请求带上,看的人在
# 门户里照常登录、单点登录照常;嵌进去的就是整站,权限和直接打开一样。
# 跨站的门户(不在同一个可注册域名下)嵌整站不支持:浏览器不把会话 cookie 带进跨站的 iframe,登录不了;为此把 cookie
# 改成 SameSite=None 会削弱整站防跨站请求伪造的那一层,Safari、Firefox 默认还是会拦。跨站要嵌,请用下面的分享链接。
# 写错了(带路径、没写协议、只写 *)服务启动时报错。
# AGENTLAB_FRAME_ANCESTORS=https://portal.example.internal
# 分享页(/s)允许被哪些站点嵌,写法同上。分享页只认这一项、不认上面那一项。分享链接还没有上线,现在填了不起作用
# 分享页(/s,分享链接打开的只读页面)允许被哪些站点嵌,写法同上。分享页只认这一项、不认上面那一项;不填时只有 AgentLab
# 自己的页面能嵌。分享链接要管理员在「设置 → 偏好设置」的「应用」一节里打开「允许建分享链接」,再由管理应用的人在
# 应用编辑的「岗位与授权」里建(必设到期、最长 90 天,可撤销);建的时候没勾「允许嵌入」的那一条,在别的页面的 iframe 里
# 不显示内容。分享页另带 Referrer-Policy: no-referrer
# AGENTLAB_EMBED_ORIGINS=https://bi.example.internal
# ---- 数据 ----
+11
View File
@@ -32,6 +32,7 @@ import { NewAppStart } from './pages/blueprints/NewAppStart'
import { BlueprintPage } from './pages/blueprints/BlueprintPage'
import { personalAppsOn } from './pages/blueprints/shared'
import { LoginPage } from './pages/LoginPage'
import { SharePage } from './pages/apps/SharePage'
import { Logo } from './components/Logo'
import {
CommandPalette, PAGES, ShortcutHelp, flushThemePref, openShortcutHelp, toggleCommandPalette, toggleNotify,
@@ -56,7 +57,17 @@ import { APP_TEXT, AUTH_MODE_LABEL, AUTH_TEXT, ROLE_LABEL, RUN_COMPARE_TEXT, TAS
/** 启动时认人(/auth/me)最多等多久再取目录 */
const AUTH_WAIT_MS = 2500
/**
* 分享页(/s#<分享 key>,04 细案 C2)不在外壳里,比登录页还早一步分开:拿到链接的人没有登录这回事,外壳的认人、取目录、
* 心跳、读主题一样都不做(开了登录时它们会 401,再被请求层送去登录页)。整页加载时定一次:分享页上没有通往外壳的链接
*/
const SHARE_PAGE = typeof window !== 'undefined' && window.location.pathname === '/s'
export default function App() {
return SHARE_PAGE ? <SharePage /> : <Shell />
}
function Shell() {
const loaded = useCatalog((s) => s.loaded)
const backend = useCatalog((s) => s.backend)
const everOk = useCatalog((s) => s.lastOkAt != null)
+12 -2
View File
@@ -6,10 +6,11 @@
*/
import type {
AppBindings, AppDetail, AppGrant, AppHookRow, AppList, HookReceipts, AppSpec, AppSummary, AppVersionRow, BackfillPlan, BackfillResult,
ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel,
ComponentCatalog, Disposition, InstanceRow, LintIssue, PublishResult, ViewModel, ViewShareCreated, ViewShareList,
ViewShareRow,
} from '../typesApp'
import type { ConversationDetail, EvidenceSegmentDetail } from '../types'
import { del, get, patch, post, put, qs, type RequestOptions } from './http'
import { del, get, patch, post, put, qs, request, type RequestOptions } from './http'
const at = (id: string) => `/apps/${encodeURIComponent(id)}`
const hookAt = (id: string, name: string) => `${at(id)}/hooks/${encodeURIComponent(name)}`
@@ -74,6 +75,15 @@ export const apps = {
deleteHook: (id: string, name: string) => del(hookAt(id, name)),
/** 钩子最近收到的请求(manage):状态、原因、发起的实例或解决的待办,加近 1 小时验签失败几次(04 细案 A6) */
hookReceipts: (id: string, name: string) => get<HookReceipts>(`${hookAt(id, name)}/receipts`),
/**
* 分享链接(manage,服务端 api/shares.py):列、建、撤销。建要管理员开了「允许建分享链接」,到期必填(最长 max_days 天);
* key 只在建的这一次交回
*/
shares: (id: string) => get<ViewShareList>(`${at(id)}/shares`),
createShare: (id: string, body: { page_id: string; mode: 'latest' | 'fixed'; expires_at: string; embed_allowed: boolean }) =>
post<ViewShareCreated>(`${at(id)}/shares`, body),
revokeShare: (id: string, shareId: string) =>
request<ViewShareRow>(`${at(id)}/shares/${encodeURIComponent(shareId)}`, { method: 'DELETE' }),
/**
* 应用编辑器里助手的对话(D-e,manage):取这个应用的那段对话,没有就建(kind=app、归属这个应用)。带着历史轮次;
* 老服务端只回对话本身时由调用方再按 id 取一次
+5 -3
View File
@@ -5,7 +5,7 @@
import type { CatalogReviewAction, CatalogSource, CatalogStatus } from '../types'
import type {
CapabilityEffect, CapabilityReviewOutcome, CapabilityReviewRow, CapabilityRow, EndpointRow, NotifyChannelRow,
NotifyEventRow,
NotifyEventRow, NotifyMailState,
} from '../typesApp'
import { del, get, patch, post, put, qs, type RequestOptions } from './http'
@@ -122,8 +122,10 @@ export const endpoints = {
}
export const notify = {
channels: () => get<{ items: NotifyChannelRow[] }>('/notify/channels'),
createChannel: (body: { name: string; scope: string; owner_id?: string | null; format: string; url: string;
/** mail:邮件服务启没启用、本人的账号邮箱(老服务端不给) */
channels: () => get<{ items: NotifyChannelRow[]; mail?: NotifyMailState }>('/notify/channels'),
/** 邮件渠道:个人的不带 url(只发到账号邮箱),全局、应用的 url 是收件地址(逗号或换行分隔,最多 20 个) */
createChannel: (body: { name: string; scope: string; owner_id?: string | null; format: string; url?: string;
secret?: string | null; events: string[] }) => post<NotifyChannelRow>('/notify/channels', body),
updateChannel: (id: string, body: Record<string, any>) => patch<NotifyChannelRow>(`/notify/channels/${encodeURIComponent(id)}`, body),
removeChannel: (id: string) => del(`/notify/channels/${encodeURIComponent(id)}`),
+2
View File
@@ -689,6 +689,8 @@ export const api = {
/** 今天(本地日期)结论句裁判花了多少:设置页放在每日上限旁边。只读,计数只有裁判自己记 */
judgeSpend: () => get<{ date?: string; usd?: number; calls?: number; unpriced_calls?: number
daily_max_usd?: number | null }>('/settings/judge/spend'),
/** 按存着的邮件服务设置发一封测试邮件,发到管理员自己的账号邮箱(管理员;关登录、没有邮箱时 422) */
mailTest: () => post<{ ok: boolean; message: string }>('/settings/mail/test'),
},
// ---- 工具 ----
+2 -1
View File
@@ -104,7 +104,8 @@ export function actorHeader(): Record<string, string> {
function toLogin(path?: string) {
if (typeof window === 'undefined' || path === '/auth/login' || path === '/auth/me') return
const { pathname, search } = window.location
if (pathname === '/login') return
// 分享页(/s)上没有登录这回事:不送去登录页
if (pathname === '/login' || pathname === '/s') return
const back = `${pathname}${search}`
window.location.assign(back && back !== '/' ? `/login?next=${encodeURIComponent(back)}` : '/login')
}
+42
View File
@@ -0,0 +1,42 @@
/**
* 分享页(/s)的两个接口:GET /api/shares/page、GET /api/shares/report.html(服务端 api/shares.py,04 细案 C2)。
*
* 分享 key 在链接的 # 后面(/s#<key>),打开页面时不发给服务器;这里读出来放进请求头 X-AgentLab-Share,服务端只认请求头。
* 不经 http.request:那条路会带署名头、把 401 当成「会话没了」整页跳去登录页——分享页上没有登录这回事,也不该在外壳的连接
* 状态里报来报去。报错照样翻成 ApiError({detail, code}),页面按 code 写「链接已撤销 / 已到期」。
*
* 只从 ./http、./bundle 导入(同 reportExport.ts,避免和 client.ts 循环导入)。
*/
import type { SharePayload } from '../typesApp'
import { ApiError, BASE, NETWORK_MESSAGE, codeOf, describeDetail } from './http'
import { dispositionName } from './bundle'
export const SHARE_HEADER = 'X-AgentLab-Share'
async function call(path: string, key: string): Promise<Response> {
let res: Response
try {
res = await fetch(BASE + path, { headers: { [SHARE_HEADER]: key }, credentials: 'omit', referrerPolicy: 'no-referrer' })
} catch (e: any) {
throw new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` })
}
if (res.ok) return res
const text = await res.text().catch(() => '')
let body: any = null
try { body = JSON.parse(text) } catch { /* 不是 JSON(代理回的页面) */ }
const detail = body?.detail
throw new ApiError(res.status, detail ? describeDetail(detail, path) : `请求失败(HTTP ${res.status})`,
{ detail, code: codeOf(body), body, raw: `${res.status} ${text.slice(0, 500)}` })
}
export const shares = {
/** 分享页的视图模型、页脚、能下载报告的运行 */
page: async (key: string): Promise<SharePayload> => (await call('/shares/page', key)).json(),
/** 下载报告(附件):只认这条分享解析出来的运行;文件名取服务端给的,没给是 null */
report: async (key: string, runId: string, reportNode?: string): Promise<{ blob: Blob; name: string | null }> => {
const q = new URLSearchParams({ run: runId })
if (reportNode) q.set('report', reportNode)
const res = await call(`/shares/report.html?${q.toString()}`, key)
return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) }
},
}
+10 -4
View File
@@ -11,6 +11,9 @@
* 隐藏(normal-user-apps N6):作者隐藏的组件,发布版解析时服务端就跳过;按草稿解析时照样下发、带 hidden。这里只看这个
* 标记:应用页上直接改的编辑状态(editing)画成半透明、标「已隐藏」,别处(编辑器的预览)照使用者看到的样子不画。
* 编辑状态另给草稿里隐藏了的组件 id(editing.hidden):服务端没带标记的也认得出来。
*
* 分享页(pages/apps/SharePage,04 细案 C2)也用它:传 preview(不发起、不刷新)和 onEvidence——点数字不打开在线出处(那要
* 按运行读证据的身份,分享页上没有),交给分享页说明「出处在下载的报告里」。
*/
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
@@ -35,6 +38,8 @@ export interface ViewRendererProps {
onReload?: () => void
/** 应用页上直接改的编辑状态:隐藏的组件画成半透明(不传时隐藏的组件不画) */
editing?: ViewEditMarks
/** 点数字看出处时不开证据面板、交给调用方(分享页:出处在下载的报告里) */
onEvidence?: (evidence: ViewEvidence, label: string, text?: string) => void
}
/** 编辑状态的标记:草稿里隐藏了的组件 id */
@@ -47,11 +52,12 @@ const EditMarksCtx = createContext<ViewEditMarks | null>(null)
/** 服务端按草稿解析时给隐藏的组件带的标记(ViewNode.hidden,发布版里没有这样的组件),加上这一次刚隐藏的 */
const isHidden = (node: ViewNode, marks: ViewEditMarks | null) => node.hidden === true || !!marks?.hidden.has(node.id)
export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing }: ViewRendererProps) {
export function ViewRenderer({ model, appId, runId, preview = false, onNavigate, onReload, editing, onEvidence }: ViewRendererProps) {
const [evidence, setEvidence] = useState<EvidenceTarget | null>(null)
const openEvidence = useCallback((ev: ViewEvidence, label: string, text?: string) => {
setEvidence({ evidence: ev, label, text })
}, [])
if (onEvidence) onEvidence(ev, label, text)
else setEvidence({ evidence: ev, label, text })
}, [onEvidence])
// 视图模型换了(刷新、重新取)就是新的一代:画坏了的组件跟着重试一次
const gen = useMemo(() => String(++generation), [model])
const render = useCallback((nodes: ViewNode[] | undefined): ReactNode =>
@@ -81,7 +87,7 @@ export function ViewRenderer({ model, appId, runId, preview = false, onNavigate,
)}
<EditMarksCtx.Provider value={editing ?? null}>{render(model.tree)}</EditMarksCtx.Provider>
</div>
<EvidenceDrawer target={evidence} onClose={() => setEvidence(null)} />
{!onEvidence && <EvidenceDrawer target={evidence} onClose={() => setEvidence(null)} />}
</ViewCtx.Provider>
)
}
+5 -1
View File
@@ -5672,7 +5672,8 @@ export const NOTIFY_TEXT = {
urlHint: '地址加密保存,之后只显示主机名',
secret: '签名密钥(可选)',
format: '格式',
formats: { generic: '通用 JSON', feishu: '飞书群机器人', wecom: '企业微信群机器人', dingtalk: '钉钉群机器人' } as Record<string, string>,
// email:邮件(第五种格式,服务端 notify/mail.py):要先在「邮件服务」一节配好并启用
formats: { generic: '通用 JSON', feishu: '飞书群机器人', wecom: '企业微信群机器人', dingtalk: '钉钉群机器人', email: '邮件' } as Record<string, string>,
scope: '范围',
scopes: { global: '全局', app: '应用', user: '个人' } as Record<string, string>,
events: '订阅的事件',
@@ -6146,6 +6147,9 @@ export const APP_SETTING_TEXT = {
onDemandLimitInvalid: '请填写 0 到 100,000 之间的整数',
personalApps: '使用者可以建个人应用',
personalAppsHint: '开启后,使用者可以从说需求、场景模板或问数据的回答建一个只有自己看得见的应用:只读、结果遮罩,每人每天的刷新次数按上面的每日上限算;想给别人用要提交给搭建者审核发布。搭建者及以上不受这一项限制',
// 分享链接(服务端 apps.share_links,04 细案 C2):默认关
shareLinks: '允许建分享链接',
shareLinksHint: '开启后,管理应用的人可以在应用编辑的「岗位与授权」里给一页建分享链接:拿到链接的人不用登录就能看(只读、只显示定时出数的视图、数据和使用者看到的一样遮过),必须设到期、最长 90 天、随时可以撤销。关掉时已有的分享链接一并打不开',
}
/**
+119
View File
@@ -802,3 +802,122 @@ export const HOOK_RECEIPT_TEXT = {
? `近 1 小时验签失败 ${n} 次:验签失败的请求不留记录,只计数`
: '近 1 小时没有验签失败的请求'),
}
/**
* 邮件(通知渠道的第五种格式,服务端 notify/mail.py,04 细案 B2):设置 → 通知里的「邮件服务」「每天的待办汇总」两节、
* 渠道里的邮件格式。照实说:成功只写「已交给邮件服务器」,退信不追踪,不说已送达
*/
export const MAIL_TEXT = {
section: '邮件服务',
hint: '通知渠道选「邮件」时用这台邮件服务器发信。口令加密保存,之后只显示「已设置」',
readOnly: '只有管理员能修改邮件服务',
enabled: '启用邮件',
enabledHint: '关着时通知渠道里没有「邮件」这一格式;已有的邮件渠道发不出去,投递记录里写明原因',
host: 'SMTP 服务器',
hostHint: '主机名或 IP 地址,例如 smtp.example.com,不带协议和端口',
port: '端口',
security: '加密方式',
securityLabel: { starttls: 'STARTTLS(常用端口 587)', ssl: 'SSL/TLS(常用端口 465)', none: '不加密(只在内网中继时用)' } as Record<string, string>,
username: '用户名',
usernameHint: '服务器不要求登录时留空',
password: '口令',
passwordSet: '已设置;留空表示不修改',
passwordUnset: '未设置',
passwordClear: '清除已保存的口令',
fromAddr: '发件地址',
fromName: '发件人名称',
defaultAssignees: '默认发邮件给待办处理人',
defaultAssigneesHint: '打开后,第一次有待办派给某人时,自动给他建一个个人邮件渠道(订阅待办打开、动作结果未知、审核有结果),发到他的账号邮箱;本人之后可以改订阅或关掉、删掉。缺省关:刚配好邮件服务时不会突然往外发邮件',
save: '保存邮件服务',
saved: '已保存邮件服务设置',
invalidPort: '请填写 1 到 65535 之间的整数',
leaveBody: '邮件服务有没保存的改动,离开将丢失这些改动。',
test: '发测试邮件',
testHint: '按已保存的设置发一封到你自己的账号邮箱',
testDirty: '先保存再发测试邮件',
handoff: '已交给邮件服务器',
handoffHint: '「已交给邮件服务器」不等于已经进了收件箱:退信不追踪,是否送达以邮件服务器为准',
// 每天的待办汇总(服务端 notify/digest.py)
digest: '每天的待办汇总',
digestHint: '每天这个时间(按服务端所在时区)给订阅了「每天的待办汇总」的个人渠道发一条:还有几条待办、最早的时限、最多 5 条和去待办箱的链接。没有待办的人那天不发',
digestAt: '发送时间',
digestSave: '保存发送时间',
digestSaved: (at: string) => `每天 ${at} 发待办汇总`,
digestReadOnly: '只有管理员能修改发送时间',
// 渠道里的邮件格式
personalTo: (email: string | null) => (email ? `发到账号邮箱 ${email}` : '你的账号没有邮箱地址,发不出去:请联系管理员在「设置 → 身份」里填写'),
personalToHint: '个人邮件渠道只发到你的账号邮箱,不能填别的地址',
recipients: '收件地址',
recipientsHint: '多个地址用逗号或换行分隔,最多 20 个;人多的请填邮件组的地址',
to: (list: string[]) => (list.length
? `发到 ${list.slice(0, 2).join('、')}${list.length > 2 ? ` 等 ${list.length} 个地址` : ''}` : ''),
off: '邮件服务没有启用,暂时不能选邮件:请管理员在下面的「邮件服务」里配置并启用',
}
/**
* 分享链接(服务端 api/shares.py,04 细案 C2):应用编辑 → 岗位与授权里的管理。叫「分享链接」,不叫快照的那种说法:
* 「快照」专指查询快照
*/
export const SHARE_TEXT = {
section: '分享链接',
hint: '拿到链接的人不用登录就能看这一页:只读,只显示定时出数的视图,看到的数据和使用者一样遮过;出处给「下载报告」。必须设到期,随时可以撤销',
off: '管理员还没有打开分享链接:在「设置 → 偏好设置」的「应用」一节里打开「允许建分享链接」后才能建',
offWithRows: '管理员已关闭分享链接:下面这些暂时打不开,仍然可以撤销',
personal: '个人应用只给你自己用,不能分享',
unpublished: '发布之后才能建分享链接',
create: '新建分享链接',
empty: '还没有分享链接',
page: '分享哪一页',
pageHint: '只显示这一页上定时出数的视图;入口、表单、实例、待办和报告正文在分享页上换成说明',
mode: '数据',
modeLatest: '始终最新:跟着最近一次封存走',
modeFixed: '固定在现在这一期:之后出的数不换上来',
modeShort: { latest: '始终最新', fixed: '固定这一期' } as Record<string, string>,
expires: '有效期',
expiresDays: (n: number) => `${n} 天`,
expiresHint: (max: number) => `必须设到期,最长 ${max} 天`,
embed: '允许嵌入到别的站点',
embedHint: '勾上之后这条链接能放进门户的 iframe(只限部署配置里允许嵌入分享页的站点);不勾的在别的页面里不显示内容',
embedNoOrigins: '部署没有配置允许嵌入分享页的站点(AGENTLAB_EMBED_ORIGINS),勾了也只能在 AgentLab 自己的页面里嵌',
submit: '建分享链接',
created: '分享链接已建好',
once: '链接只显示这一次,请现在复制下来;之后只看得到开头几位',
dismiss: '我已复制',
state: { active: '有效', revoked: '已撤销', expired: '已到期' } as Record<string, string>,
embedded: '可嵌入',
opened: (n: number) => (n ? `打开过 ${n} 次` : '还没打开过'),
by: (who: string) => `由 ${who} 建`,
until: (at: string) => `${at} 到期`,
revoke: '撤销',
revokeTitle: (prefix: string) => `撤销分享链接 ${prefix}…?`,
revokeBody: '撤销之后,拿着这条链接的人打开会看到「链接已撤销」,不能恢复;要再分享请建一条新的。',
revoked: '已撤销分享链接',
}
/** 分享页(/s,不在外壳里):拿到分享链接的人看到的页面 */
export const SHARE_PAGE_TEXT = {
loading: '正在打开分享的页面…',
readOnly: '只读',
footer: (who: string, at: string, until: string) => `只读 · 由 ${who} 于 ${at} 分享 · ${until} 到期`,
someone: '管理应用的人',
fixed: '固定在分享时的那一期',
latest: '跟着最近一次封存更新',
stamps: '数据的来历',
stampsHint: '每个视图取的是哪一次运行、截至什么时候、按哪个口径、出具档位',
download: '下载报告',
downloadHint: '下载的报告里每个数都点得开出处(口径、来源、计算方式),离线也能看',
downloading: '正在下载…',
downloaded: '已下载报告',
noReports: '这一页的数据没有可下载的报告',
evidenceTitle: '出处在下载的报告里',
evidence: '分享页不打开在线出处:点下面的「下载报告」,下载的报告里每个数都点得开出处,离线也能看',
close: '知道了',
noKey: '链接不完整:缺少 # 后面的那一段,请向分享的人确认链接',
errorTitle: {
share_revoked: '链接已撤销', share_expired: '链接已到期', share_links_off: '分享链接已关闭',
share_gone: '分享的页面打不开了', share_not_found: '链接无效', share_key_missing: '链接不完整',
} as Record<string, string>,
failed: '分享的页面打不开',
framed: '这条分享链接没有允许嵌入到别的页面,请直接打开链接查看',
openDirect: '在新窗口打开',
}
+18 -2
View File
@@ -682,6 +682,8 @@ function PrefsTab() {
// 应用一组(D-j):按需视图每个应用每天最多发起几次;使用者能不能建个人应用(N7,默认关)
appsDaily: string
appsPersonal: boolean
// 允许建分享链接(服务端 apps.share_links,04 细案 C2,默认关)
appsShare: boolean
} | null>(null)
const [spend, setSpend] = useState<{ usd?: number; calls?: number; unpriced_calls?: number } | null>(null)
// 核查一组(双盲复算、反方审稿、审批质询)的草稿另放一份,和上面的共用保存条;verifySaves 是存过几次,今日核查花费跟着再取
@@ -735,6 +737,7 @@ function PrefsTab() {
// 服务端没给这一项时按默认的 200 显示(同服务端的缺省值)
appsDaily: String(values?.apps?.on_demand_daily_limit ?? APPS_DAILY_DEFAULT),
appsPersonal: values?.apps?.user_personal_apps === true,
appsShare: values?.apps?.share_links === true,
}
const cur = draft ?? saved
const stepCap = Number(values?.limits?.max_agent_steps) || 100
@@ -834,7 +837,8 @@ function PrefsTab() {
const catalogDraft = draftChanged ? { provider: cur.draftProvider || null, model: cur.draftModel.trim() || null } : null
// 应用一组同样整组写:别的键(以后加的)照原样带回去
const apps = appsChanged
? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal }
? { ...(values.apps ?? {}), on_demand_daily_limit: Number(cur.appsDaily.trim()), user_personal_apps: cur.appsPersonal,
share_links: cur.appsShare }
: null
if (runChanged || judge || verify || catalogDraft || apps) {
const run = {
@@ -1201,6 +1205,17 @@ function PrefsTab() {
</span>
</span>
</label>
<label className="mt-3 flex items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5" checked={cur.appsShare} data-apps-share-links=""
aria-describedby="pref-apps-share-hint"
onChange={(e) => edit({ appsShare: e.target.checked })} />
<span>
{APP_SETTING_TEXT.shareLinks}
<span id="pref-apps-share-hint" className="mt-0.5 block text-2xs leading-relaxed text-faint">
{APP_SETTING_TEXT.shareLinksHint}
</span>
</span>
</label>
</section>
</fieldset>
</>
@@ -1228,6 +1243,7 @@ function PrefsTab() {
jClick: '每次点击的金额上限', jDaily: '每日金额上限',
draftProvider: DRAFT_MODEL_SETTING_TEXT.provider, draftModel: DRAFT_MODEL_SETTING_TEXT.model,
appsDaily: APP_SETTING_TEXT.onDemandLimit, appsPersonal: APP_SETTING_TEXT.personalApps,
appsShare: APP_SETTING_TEXT.shareLinks,
})[k]).concat(verifyLabels(verifyKeys)).join('、')}
</span>
</span>
@@ -1261,7 +1277,7 @@ function PrefsTab() {
}
/** 应用一组在草稿里的几项:保存时整组写 apps */
const APPS_DRAFT = new Set(['appsDaily', 'appsPersonal'])
const APPS_DRAFT = new Set(['appsDaily', 'appsPersonal', 'appsShare'])
/** 按需视图每日上限的缺省值和最大值(同服务端 settings.APPS_DEFAULTS、APPS_DAILY_LIMIT_MAX) */
const APPS_DAILY_DEFAULT = 200
+178
View File
@@ -0,0 +1,178 @@
/**
* 分享页(/s#<分享 key>,不在外壳里,同登录页的写法;04 细案 C2):拿到分享链接的人不用登录就能看应用的一页。
*
* - key 在 # 后面:打开页面时不发给服务器、不进访问日志和 Referer,这里读出来放进请求头(api/shares.ts);
* - 用同一个 ViewRenderer 渲染服务端按分享解析好的视图模型(不另写渲染器):preview 打开,所有操作禁用;入口、表单、实例、
* 待办、报告正文服务端已经换成写明原因的说明块;
* - 印章常显:页面上没放印章组件的视图,也在「数据的来历」里逐个列出它的印章(截至、口径、出具档位、来源);
* - 出处给「下载报告」:点数字不开在线证据,说明出处在下载的报告里(报告文件自己离线点得开);
* - 页脚写「只读 · 由 X 于 … 分享 · … 到期」;撤销、到期、关了开关、链接不完整,照服务端的话写明,不给空白页;
* - 这条分享没允许嵌入、页面又在别的页面的 iframe 里:不显示内容,给「在新窗口打开」(服务端在 /s 上只能按全站的嵌入白名单
* 发头,打开页面那一刻它还不知道是哪一条分享)。
*
* 不认人、不取目录、不连心跳(App 在 /s 上整个跳过外壳的启动),也就不会因为没登录被送去登录页。
*/
import { useEffect, useMemo, useState } from 'react'
import { Download, ExternalLink, Lock } from 'lucide-react'
import { ApiError } from '../../api/http'
import { shares } from '../../api/shares'
import { ViewRenderer } from '../../apps/ViewRenderer'
import { Stamp } from '../../apps/Stamp'
import { Logo } from '../../components/Logo'
import { Modal, Notice, Spinner, toast } from '../../components/ui'
import { formatDateTime } from '../../lib/format'
import { SHARE_PAGE_TEXT as T } from '../../lib/termsApp'
import type { SharePayload } from '../../typesApp'
/** 链接里 # 后面的那一段就是分享 key */
export const shareKeyOf = (hash: string): string => decodeURIComponent(hash.replace(/^#/, '').trim())
const framed = (): boolean => {
try {
return window.self !== window.top
} catch {
// 跨源的父页面读 top 会抛:那就是被别的站点嵌着
return true
}
}
export function SharePage() {
const key = useMemo(() => shareKeyOf(window.location.hash), [])
const [data, setData] = useState<SharePayload | null>(null)
const [error, setError] = useState<{ code?: string; message: string } | null>(key ? null : { code: 'share_key_missing', message: T.noKey })
const [note, setNote] = useState(false)
useEffect(() => {
if (!key) return
shares.page(key).then(setData, (e) => setError({
code: e instanceof ApiError ? e.code : undefined,
message: e instanceof Error ? e.message : T.failed,
}))
}, [key])
useEffect(() => {
if (data) document.title = `${data.view.app.name} · ${data.view.page.title || data.view.page.id}`
}, [data])
const blocked = !!data && !data.share.embed_allowed && framed()
const state = error ? 'error' : !data ? 'loading' : blocked ? 'framed' : 'ok'
return (
<div className="h-full overflow-auto bg-bg" data-share-page={state}>
<div className="mx-auto max-w-5xl space-y-4 p-4 sm:p-6">
<header className="flex flex-wrap items-center gap-3">
<Logo size={26} className="text-accent" title="AgentLab" />
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold text-fg">
{data ? data.view.app.name : 'AgentLab'}
{data?.view.page.title && <span className="font-normal text-dim"> · {data.view.page.title}</span>}
</h1>
</div>
{data && (
<span className="chip flex items-center gap-1" data-share-readonly="">
<Lock size={10} aria-hidden /> {T.readOnly} · {data.share.mode === 'fixed' ? T.fixed : T.latest}
</span>
)}
</header>
{error ? (
<div className="rounded-lg border bg-panel p-4" data-share-error={error.code ?? ''}>
<h2 className="text-sm font-semibold">{(error.code && T.errorTitle[error.code]) || T.failed}</h2>
<p className="mt-1 text-xs leading-relaxed text-dim" role="alert">{error.message}</p>
</div>
) : !data ? (
<div className="flex items-center gap-2 text-xs text-dim" role="status"><Spinner size={12} /> {T.loading}</div>
) : blocked ? (
<div className="rounded-lg border bg-panel p-4" data-share-framed="">
<p className="text-xs leading-relaxed text-fg">{T.framed}</p>
<a className="btn btn-sm mt-2" href={window.location.href} target="_blank" rel="noopener noreferrer">
<ExternalLink size={11} aria-hidden /> {T.openDirect}
</a>
</div>
) : (
<>
<ViewRenderer model={data.view} appId={data.view.app.id} preview onEvidence={() => setNote(true)} />
<Stamps data={data} />
<footer className="space-y-2 border-t pt-3" data-share-footer="">
<Downloads shareKey={key} data={data} />
<p className="text-2xs text-faint" data-share-footer-text="">
{T.footer(data.share.created_by || T.someone, formatDateTime(data.share.created_at),
formatDateTime(data.share.expires_at))}
</p>
</footer>
</>
)}
</div>
{data && (
<Modal open={note} onClose={() => setNote(false)} title={T.evidenceTitle} width={460}
footer={<button type="button" className="btn" onClick={() => setNote(false)}>{T.close}</button>}>
<div className="space-y-3" data-share-evidence-note="">
<p className="text-xs leading-relaxed text-fg">{T.evidence}</p>
<Downloads shareKey={key} data={data} />
</div>
</Modal>
)}
</div>
)
}
/** 印章常显:每个视图来源的印章(取到了运行的),页面上有没有放印章组件都列 */
function Stamps({ data }: { data: SharePayload }) {
const rows = Object.entries(data.view.sources).filter(([, s]) => s.stamp)
if (!rows.length) return null
return (
<section className="space-y-2" data-share-stamps={rows.length}>
<div>
<h2 className="text-xs font-semibold">{T.stamps}</h2>
<p className="text-2xs text-faint">{T.stampsHint}</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{rows.map(([vid, s]) => <Stamp key={vid} stamp={s.stamp!} />)}
</div>
</section>
)
}
/** 下载报告:只给这条分享解析出来、带报告的那几次运行(服务端只认这几次) */
function Downloads({ shareKey, data }: { shareKey: string; data: SharePayload }) {
const [busy, setBusy] = useState<string | null>(null)
const items = data.downloads.flatMap((d) => d.reports.map((node) => ({ ...d, node })))
if (!items.length) return <p className="text-2xs text-faint" data-share-no-reports="">{T.noReports}</p>
const download = async (runId: string, node: string) => {
const id = `${runId}:${node}`
setBusy(id)
try {
const { blob, name } = await shares.report(shareKey, runId, node)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name ?? `报告-${runId.slice(0, 6)}.html`
document.body.appendChild(a)
a.click()
a.remove()
setTimeout(() => URL.revokeObjectURL(url), 1000)
toast.ok(T.downloaded)
} catch (e) {
toast.error(e)
} finally {
setBusy(null)
}
}
return (
<div className="space-y-1">
<div className="flex flex-wrap gap-2">
{items.map((d) => {
const id = `${d.run_id}:${d.node}`
return (
<button key={id} type="button" className="btn btn-sm" disabled={busy != null} data-share-download={d.run_id}
onClick={() => void download(d.run_id, d.node)}>
{busy === id ? <Spinner size={11} /> : <Download size={11} aria-hidden />}
{busy === id ? T.downloading : T.download}
{items.length > 1 && d.as_of_text && <span className="text-2xs text-faint">({d.as_of_text})</span>}
</button>
)
})}
</div>
<Notice tone="info" attr={{ 'data-share-download-hint': '' }}>{T.downloadHint}</Notice>
</div>
)
}
+169 -3
View File
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useState } from 'react'
import { KeyRound, Plus } from 'lucide-react'
import { KeyRound, Link2, Plus } from 'lucide-react'
import { api } from '../../../api/client'
import type { AppHookRow, HookReceipts } from '../../../typesApp'
import type { AppHookRow, HookReceipts, ViewShareCreated, ViewShareList, ViewShareRow } from '../../../typesApp'
import { RecentRequests, type RecentRequestRow } from '../../../components/RecentRequests'
import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT } from '../../../lib/termsApp'
import { formatDateTime } from '../../../lib/format'
import { HOOK_RECEIPT_TEXT, RECENT_REQUESTS_TEXT, SHARE_TEXT as S } from '../../../lib/termsApp'
import {
CopyButton, DeleteButton, EmptyState, ErrorState, Field, SectionBar, Skeleton, Spinner, confirmDialog, toast,
} from '../../../components/ui'
@@ -20,6 +21,8 @@ import { useEditor } from './context'
* 岗位:图里人工节点写的岗位名(assignee 的岗位)在这里绑定到人或组;绑定不进 spec,存在草稿的 bindings 里,发布时
* 进应用版本——spec 可以导出,绑定是这一个部署自己的。人员变动只改组成员,不发新版本。
* 授权:use / observe / manage,即时生效、写审计,不需要发新版本(A1)。关登录时所有人都是管理员,授权开登录后才生效
* 分享链接(04 细案 C2):管理员开了「允许建分享链接」后,管理应用的人给发布版的一页建只读、不用登录的链接(必设到期、
* 最长 90 天、可撤销);链接只在建好时显示一次。关着时不给新建,已有的照列、能撤销
*/
const ROLES: GrantRole[] = ['use', 'observe', 'manage']
@@ -73,6 +76,169 @@ export function AccessSection() {
</div>
</section>
<Grants />
<Shares />
</div>
)
}
/** 有效期的几个选项(天),按服务端的上限截 */
const SHARE_DAYS = [1, 7, 30, 90]
function Shares() {
const { appId, detail } = useEditor()
const personal = detail.scope === 'personal'
const pages = detail.published?.pages ?? []
const [list, setList] = useState<ViewShareList | null>(null)
const [error, setError] = useState<unknown>(null)
const [form, setForm] = useState<{ page: string; mode: 'latest' | 'fixed'; days: number; embed: boolean } | null>(null)
const [busy, setBusy] = useState(false)
const [made, setMade] = useState<ViewShareCreated | null>(null)
const load = useCallback(() => {
if (personal) return
api.apps.shares(appId).then((v) => { setList(v); setError(null) }, setError)
}, [appId, personal])
useEffect(load, [load])
if (personal) {
return (
<section data-app-shares="personal">
<SectionBar title={S.section} hint={S.personal} />
</section>
)
}
const create = async () => {
if (!form || busy) return
setBusy(true)
try {
const out = await api.apps.createShare(appId, {
page_id: form.page, mode: form.mode, embed_allowed: form.embed,
expires_at: new Date(Date.now() + form.days * 86_400_000).toISOString(),
})
setMade(out)
setForm(null)
load()
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
const revoke = async (row: ViewShareRow) => {
if (!(await confirmDialog({ title: S.revokeTitle(row.prefix), body: S.revokeBody, danger: true, confirmLabel: S.revoke }))) return
try {
await api.apps.revokeShare(appId, row.id)
toast.ok(S.revoked)
load()
} catch (e) {
toast.error(e)
}
}
const maxDays = list?.max_days ?? 90
const canCreate = !!list?.enabled && pages.length > 0 && detail.published_version != null
const hint = !list ? S.hint : !list.enabled ? (list.items.length ? S.offWithRows : S.off)
: detail.published_version == null ? S.unpublished : S.hint
return (
<section data-app-shares={list ? (list.enabled ? 'on' : 'off') : ''}>
<SectionBar title={S.section} hint={hint}>
{canCreate && !form && (
<button type="button" className="btn btn-sm" data-app-share-create=""
onClick={() => { setMade(null); setForm({ page: pages[0].id, mode: 'latest', days: Math.min(30, maxDays), embed: false }) }}>
<Plus size={11} aria-hidden /> {S.create}
</button>
)}
</SectionBar>
{made && <ShareOnce made={made} onDismiss={() => setMade(null)} />}
{form && (
<div className="mb-3 space-y-3 rounded-lg border bg-panel px-3 py-3" data-app-share-form="">
<Field label={S.page} hint={S.pageHint}>
{(p) => (
<select {...p} className="field max-w-72" value={form.page} data-app-share-page=""
onChange={(e) => setForm({ ...form, page: e.target.value })}>
{pages.map((pg) => <option key={pg.id} value={pg.id}>{pg.title || pg.id}</option>)}
</select>
)}
</Field>
<fieldset>
<legend className="label">{S.mode}</legend>
{(['latest', 'fixed'] as const).map((m) => (
<label key={m} className="flex items-center gap-1.5 text-xs">
<input type="radio" name="share-mode" checked={form.mode === m} data-app-share-mode={m}
onChange={() => setForm({ ...form, mode: m })} />
{m === 'latest' ? S.modeLatest : S.modeFixed}
</label>
))}
</fieldset>
<Field label={S.expires} hint={S.expiresHint(maxDays)} required>
{(p) => (
<select {...p} className="field w-32" value={form.days} data-app-share-days=""
onChange={(e) => setForm({ ...form, days: Number(e.target.value) })}>
{SHARE_DAYS.filter((d) => d <= maxDays).map((d) => <option key={d} value={d}>{S.expiresDays(d)}</option>)}
</select>
)}
</Field>
<label className="flex items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5" checked={form.embed} data-app-share-embed=""
onChange={(e) => setForm({ ...form, embed: e.target.checked })} />
<span>
{S.embed}
<span className="mt-0.5 block text-2xs leading-relaxed text-faint">{list?.embed_origins ? S.embedHint : S.embedNoOrigins}</span>
</span>
</label>
<div className="flex justify-end gap-2">
<button type="button" className="btn btn-sm" onClick={() => setForm(null)}>取消</button>
<button type="button" className="btn btn-sm btn-primary" disabled={busy} onClick={() => void create()} data-app-share-submit="">
{busy ? <Spinner size={11} /> : <Link2 size={11} aria-hidden />} {S.submit}
</button>
</div>
</div>
)}
{error ? <ErrorState error={error} onRetry={load} /> : !list ? <Skeleton rows={2} height={36} /> : !list.items.length ? (
<p className="text-2xs text-faint" data-app-shares-empty="">{S.empty}</p>
) : (
<div className="space-y-1.5">
{list.items.map((row) => (
<div key={row.id} className="flex flex-wrap items-center gap-2 rounded-lg border bg-panel px-3 py-2 text-xs"
data-app-share={row.prefix} data-app-share-state={row.state}>
<span className="mono">{row.prefix}…</span>
<span className="font-medium">{row.page_title}</span>
<span className="chip">{S.modeShort[row.mode] ?? row.mode}</span>
<span className="chip" style={row.state === 'active' ? { color: 'var(--ok)' } : { color: 'var(--st-waiting)' }}>
{S.state[row.state] ?? row.state}
</span>
{row.embed_allowed && <span className="chip">{S.embedded}</span>}
<span className="min-w-0 flex-1 truncate text-2xs text-faint">
{[row.expires_at ? S.until(formatDateTime(row.expires_at)) : null, S.opened(row.open_count),
row.created_by_name || row.created_by ? S.by(row.created_by_name || row.created_by || '') : null]
.filter(Boolean).join(' · ')}
</span>
{row.state === 'active' && (
<button type="button" className="btn btn-xs" onClick={() => void revoke(row)} data-app-share-revoke={row.prefix}>
{S.revoke}
</button>
)}
</div>
))}
</div>
)}
</section>
)
}
/** 刚建好的分享链接:只显示这一次(库里只有哈希),附复制按钮;关掉就没有了 */
function ShareOnce({ made, onDismiss }: { made: ViewShareCreated; onDismiss: () => void }) {
const link = made.url ?? `${window.location.origin}${made.path}`
return (
<div className="mb-3 space-y-2 rounded-lg border px-3 py-2.5" style={{ borderColor: 'var(--st-waiting)' }} role="status"
data-app-share-once="">
<div className="flex items-center gap-1.5 text-xs font-semibold"><Link2 size={12} aria-hidden /> {S.created}</div>
<p className="text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }}>{S.once}</p>
<div className="flex items-center gap-2">
<code className="mono min-w-0 flex-1 break-all rounded border bg-bg px-2 py-1 text-2xs" data-app-share-link="">{link}</code>
<CopyButton text={link} />
</div>
<div className="flex justify-end">
<button type="button" className="btn btn-sm" onClick={onDismiss} data-app-share-dismiss="">{S.dismiss}</button>
</div>
</div>
)
}
+285 -31
View File
@@ -1,37 +1,55 @@
import { useCallback, useEffect, useState } from 'react'
import { Bell, Plus, Send } from 'lucide-react'
import { Bell, Check, Mail, Plus, Send } from 'lucide-react'
import { api } from '../../api/client'
import {
DeleteButton, EmptyState, ErrorState, Field, Modal, SectionBar, Skeleton, Spinner, confirmDialog, toast,
} from '../../components/ui'
import { formatDateTime } from '../../lib/format'
import { useLeaveGuard } from '../../lib/leave'
import { NOTIFY_TEXT as T, SETTINGS_ROLE_TEXT } from '../../lib/terms'
import { NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp'
import { MAIL_TEXT as M, NOTIFY_PERSONAL_EVENT_LABEL } from '../../lib/termsApp'
import { useAtLeast } from '../../store/auth'
import type { NotifyChannelRow, NotifyEventRow } from '../../typesApp'
import type { NotifyChannelRow, NotifyEventRow, NotifyMailState } from '../../typesApp'
/*
* 设置 → 通知(A4 第 7 条):待办、动作的事件推送到哪些地址。格式只有 Webhook 的四种(Q6),发送核心和定时任务的
* 通知共用;消息里只放标题、状态和链接,不放报告里的数字。地址和签名密钥加密保存,之后只显示主机名。
* 设置 → 通知(A4 第 7 条):待办、动作的事件推送到哪些地址。格式是 Webhook 的四种加上邮件(04 细案 B2,改写了 Q6):
* Webhook 的发送核心和定时任务的通知共用;邮件走管理员在「邮件服务」一节配的 SMTP。消息里只放标题、状态和链接,不放报告
* 里的数字。地址和签名密钥加密保存,之后只显示主机名。
* 全局渠道只给管理员建;个人渠道每个人都能建(§3.12)。开了登录的使用者只看、只建自己的个人渠道(scope=user),
* 投递记录只给搭建者以上
* 投递记录只给搭建者以上。
*
* 邮件:邮件服务没启用时不列「邮件」这一格式(建了也发不出去);个人邮件渠道不填地址,只发到本人的账号邮箱(列表里写
* 「发到账号邮箱 …」,没有邮箱照实写发不出去);全局、应用的填固定收件地址。投递成功写「已交给邮件服务器」,不写已送达:
* 退信不追踪。「邮件服务」「每天的待办汇总」两节给搭建者以上看(设置接口搭建者只读),只有管理员能改。
*/
const FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const
const WEBHOOK_FORMATS = ['generic', 'feishu', 'wecom', 'dingtalk'] as const
const EVENTS = Object.keys(T.eventLabel)
/** 事件在这个范围的渠道上怎么叫:个人渠道的实例结束只推自己发起的,叫「我发起的实例完成 / 失败」 */
const eventLabel = (event: string, scope: string): string =>
(scope === 'user' ? NOTIFY_PERSONAL_EVENT_LABEL[event] : undefined) ?? T.eventLabel[event] ?? event
/** 一个渠道发到哪:Webhook 只有主机名;邮件写收件人(个人渠道是账号邮箱) */
function targetOf(row: NotifyChannelRow): string {
if (row.format !== 'email') return row.url_host
if (row.scope === 'user') return M.personalTo(row.mail_to?.[0] ?? null)
return M.to(row.mail_to ?? [])
}
export function NotifyTab() {
const builder = useAtLeast('builder')
const [rows, setRows] = useState<NotifyChannelRow[] | null>(null)
const [mail, setMail] = useState<NotifyMailState>({ enabled: false, account_email: null })
const [error, setError] = useState<unknown>(null)
const [editing, setEditing] = useState<NotifyChannelRow | 'new' | null>(null)
const [testing, setTesting] = useState<string | null>(null)
const load = useCallback(() => {
api.notify.channels()
.then(({ items }) => { setRows(builder ? items : items.filter((c) => c.scope === 'user')); setError(null) })
.then(({ items, mail: state }) => {
setRows(builder ? items : items.filter((c) => c.scope === 'user'))
setMail(state ?? { enabled: false, account_email: null })
setError(null)
})
.catch(setError)
}, [builder])
useEffect(load, [load])
@@ -40,7 +58,8 @@ export function NotifyTab() {
setTesting(row.id)
try {
const res = await api.notify.testChannel(row.id)
if (res.ok) toast.ok(T.testOk)
// 邮件渠道成功时服务端说的是「已交给邮件服务器……」:照原话写,不写「已发送」
if (res.ok) toast.ok(row.format === 'email' ? res.message : T.testOk)
else toast.warn(res.message)
} catch (e) {
toast.error(e)
@@ -79,7 +98,8 @@ export function NotifyTab() {
) : (
<div className="space-y-2">
{rows.map((row) => (
<div key={row.id} className="flex items-center gap-2 rounded-lg border bg-panel px-3 py-2" data-notify-channel={row.name}>
<div key={row.id} className="flex items-center gap-2 rounded-lg border bg-panel px-3 py-2" data-notify-channel={row.name}
data-notify-format={row.format}>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-xs">
<span className="font-medium">{row.name}</span>
@@ -89,7 +109,10 @@ export function NotifyTab() {
{!row.enabled && <span className="chip text-faint">已停用</span>}
</div>
<div className="truncate text-2xs text-faint">
<span className="mono">{row.url_host}</span>
<span className={row.format === 'email' ? undefined : 'mono'} data-notify-target=""
style={row.format === 'email' && row.scope === 'user' && !row.mail_to?.length ? { color: 'var(--warn)' } : undefined}>
{targetOf(row)}
</span>
{row.events.length > 0 && ` · ${row.events.map((e) => eventLabel(e, row.scope)).join('、')}`}
</div>
</div>
@@ -108,7 +131,9 @@ export function NotifyTab() {
)}
</section>
{builder && <Deliveries />}
{editing && <ChannelEditor row={editing === 'new' ? null : editing} onClose={() => setEditing(null)}
{builder && <MailSection onSaved={load} />}
{builder && <DigestSection />}
{editing && <ChannelEditor row={editing === 'new' ? null : editing} mail={mail} onClose={() => setEditing(null)}
onSaved={(name) => { setEditing(null); toast.ok(T.saved(name)); load() }} />}
</div>
)
@@ -123,11 +148,13 @@ function Deliveries() {
{!rows ? <Skeleton rows={2} height={28} /> : !rows.length ? <p className="text-2xs text-faint">{T.deliveriesEmpty}</p> : (
<div className="divide-y rounded-lg border text-2xs">
{rows.map((d) => (
<div key={d.id} className="flex items-center gap-2 px-3 py-1.5">
<div key={d.id} className="flex items-center gap-2 px-3 py-1.5" data-delivery-format={d.format ?? ''}>
<span className="w-28 shrink-0 text-dim">{T.deliveryEventLabel[d.event] ?? T.eventLabel[d.event] ?? d.event}</span>
<span className="min-w-0 flex-1 truncate text-faint">{d.error ?? (d.http_status ? `HTTP ${d.http_status}` : '')}</span>
<span className="shrink-0" style={{ color: d.status === 'ok' ? 'var(--ok)' : d.status === 'failed' ? 'var(--st-failed)' : undefined }}>
{d.status === 'ok' ? '已送达' : d.status === 'failed' ? '失败' : d.status}
{/* 邮件只知道交给了邮件服务器,进没进收件箱不知道(退信不追踪):不写「已送达」 */}
<span className="shrink-0" title={d.format === 'email' && d.status === 'ok' ? M.handoffHint : undefined}
style={{ color: d.status === 'ok' ? 'var(--ok)' : d.status === 'failed' ? 'var(--st-failed)' : undefined }}>
{d.status === 'ok' ? (d.format === 'email' ? M.handoff : '已送达') : d.status === 'failed' ? '失败' : d.status}
</span>
<time className="tnum shrink-0 text-faint">{formatDateTime(d.created_at ?? d.at)}</time>
</div>
@@ -138,14 +165,16 @@ function Deliveries() {
)
}
function ChannelEditor({ row, onClose, onSaved }: {
row: NotifyChannelRow | null; onClose: () => void; onSaved: (name: string) => void
function ChannelEditor({ row, mail, onClose, onSaved }: {
row: NotifyChannelRow | null; mail: NotifyMailState; onClose: () => void; onSaved: (name: string) => void
}) {
const admin = useAtLeast('admin')
const builder = useAtLeast('builder')
const [form, setForm] = useState({
name: row?.name ?? '', scope: row?.scope ?? (admin ? 'global' : 'user'), format: row?.format ?? 'generic',
url: '', secret: '', events: row?.events ?? ['task.opened', 'action.unknown'],
// 全局、应用的邮件渠道:收件地址整份重填(服务端照原样给,编辑时带出来)
recipients: row?.format === 'email' && row.scope !== 'user' ? (row.mail_to ?? []).join('\n') : '',
})
const [busy, setBusy] = useState(false)
// 列哪些事件由服务端说(notify/service.EVENTS 的 scopes):这种范围的渠道收得到的才列,登记了还没推送的不列(勾了收不到)。
@@ -155,18 +184,28 @@ function ChannelEditor({ row, onClose, onSaved }: {
useEffect(() => { api.notify.events().then(setRegistry).catch(() => setRegistryFailed(true)) }, [])
const offered = EVENTS.filter((ev) => form.events.includes(ev) || row?.events.includes(ev)
|| registry?.some((r) => r.event === ev && r.scopes.includes(form.scope)))
const email = form.format === 'email'
// 邮件服务没启用:不列「邮件」(建了也发不出去);原来就是邮件渠道的照列,能改名、改订阅、换回 Webhook
const formats = [...WEBHOOK_FORMATS, ...(mail.enabled || row?.format === 'email' ? ['email' as const] : [])]
const personalMail = email && form.scope === 'user'
const recipientsChanged = form.recipients.trim() !== (row?.format === 'email' ? (row.mail_to ?? []).join('\n') : '')
const missingTarget = email ? !personalMail && !form.recipients.trim()
: !row || row.format === 'email' ? !form.url.trim() : false
const save = async () => {
setBusy(true)
try {
const target = email
? (personalMail ? {} : recipientsChanged || row?.format !== 'email' ? { url: form.recipients.trim() } : {})
: form.url.trim() ? { url: form.url.trim() } : {}
if (row) {
await api.notify.updateChannel(row.id, {
name: form.name.trim(), scope: form.scope, format: form.format, events: form.events,
// 地址、密钥只回主机名:不填就是保持原值
...(form.url.trim() ? { url: form.url.trim() } : {}), ...(form.secret ? { secret: form.secret } : {}),
...target, ...(!email && form.secret ? { secret: form.secret } : {}),
})
} else {
await api.notify.createChannel({ name: form.name.trim(), scope: form.scope, format: form.format, url: form.url.trim(),
secret: form.secret || null, events: form.events })
await api.notify.createChannel({ name: form.name.trim(), scope: form.scope, format: form.format, ...target,
secret: !email && form.secret ? form.secret : null, events: form.events })
}
onSaved(form.name.trim())
} catch (e) {
@@ -179,7 +218,7 @@ function ChannelEditor({ row, onClose, onSaved }: {
<Modal open onClose={onClose} title={row ? `${T.title}:${row.name}` : T.create} width={560} dirty
footer={<>
<button className="btn" onClick={onClose}>取消</button>
<button className="btn btn-primary" disabled={busy || !form.name.trim() || (!row && !form.url.trim())}
<button className="btn btn-primary" disabled={busy || !form.name.trim() || missingTarget}
onClick={() => void save()} data-notify-save="">
{busy ? <Spinner size={11} /> : null} 保存
</button>
@@ -191,9 +230,9 @@ function ChannelEditor({ row, onClose, onSaved }: {
</Field>
<Field label={T.format}>
{(p) => (
<select {...p} className="field" value={form.format}
<select {...p} className="field" value={form.format} data-notify-format-select=""
onChange={(e) => setForm({ ...form, format: e.target.value as typeof form.format })}>
{FORMATS.map((f) => <option key={f} value={f}>{T.formats[f]}</option>)}
{formats.map((f) => <option key={f} value={f}>{T.formats[f]}</option>)}
</select>
)}
</Field>
@@ -207,14 +246,32 @@ function ChannelEditor({ row, onClose, onSaved }: {
)}
</Field>
</div>
<Field label={T.url} hint={row ? `${T.urlHint};当前:${row.url_host}` : T.urlHint} required={!row}>
{(p) => <input {...p} className="field mono text-xs" value={form.url} placeholder="https://"
onChange={(e) => setForm({ ...form, url: e.target.value })} />}
</Field>
<Field label={T.secret}>
{(p) => <input {...p} className="field mono text-xs" type="password" autoComplete="off" value={form.secret}
onChange={(e) => setForm({ ...form, secret: e.target.value })} />}
</Field>
{!mail.enabled && !email && <p className="text-2xs text-faint" data-notify-mail-off="">{M.off}</p>}
{personalMail ? (
<div className="rounded-md border bg-elev px-2.5 py-2 text-xs" data-notify-mail-to="account">
<div className="flex items-center gap-1.5" style={mail.account_email ? undefined : { color: 'var(--warn)' }}>
<Mail size={12} aria-hidden /> {M.personalTo(mail.account_email)}
</div>
<p className="mt-0.5 text-2xs text-faint">{M.personalToHint}</p>
</div>
) : email ? (
<Field label={M.recipients} hint={M.recipientsHint} required>
{(p) => <textarea {...p} className="field mono min-h-[72px] text-xs" value={form.recipients} data-notify-recipients=""
placeholder="ops@example.com" onChange={(e) => setForm({ ...form, recipients: e.target.value })} />}
</Field>
) : (
<>
<Field label={T.url} hint={row && row.format !== 'email' ? `${T.urlHint};当前:${row.url_host}` : T.urlHint}
required={!row || row.format === 'email'}>
{(p) => <input {...p} className="field mono text-xs" value={form.url} placeholder="https://"
onChange={(e) => setForm({ ...form, url: e.target.value })} />}
</Field>
<Field label={T.secret}>
{(p) => <input {...p} className="field mono text-xs" type="password" autoComplete="off" value={form.secret}
onChange={(e) => setForm({ ...form, secret: e.target.value })} />}
</Field>
</>
)}
<fieldset>
<legend className="label">{T.events}</legend>
<div className="grid grid-cols-3 gap-1.5" data-notify-events={registry ? form.scope : ''}>
@@ -233,3 +290,200 @@ function ChannelEditor({ row, onClose, onSaved }: {
</Modal>
)
}
// -------------------------------------------------------------------------
// 邮件服务(设置的 mail 一组)、每天的待办汇总(notify 一组的 digest_at)
// -------------------------------------------------------------------------
interface MailSettings {
enabled: boolean; host: string | null; port: number; security: string; username: string | null
password: null; password_set: boolean; from_addr: string | null; from_name: string; default_for_assignees: boolean
}
interface MailDraft {
enabled: boolean; host: string; port: string; security: string; username: string; password: string
clearPassword: boolean; from_addr: string; from_name: string; default_for_assignees: boolean
}
const draftOf = (m: MailSettings): MailDraft => ({
enabled: m.enabled, host: m.host ?? '', port: String(m.port ?? 587), security: m.security ?? 'starttls',
username: m.username ?? '', password: '', clearPassword: false, from_addr: m.from_addr ?? '',
from_name: m.from_name ?? 'AgentLab', default_for_assignees: m.default_for_assignees,
})
/**
* 「邮件服务」一节:管理员改,搭建者只看。口令不回,只说设没设;留空表示不修改,勾「清除」才去掉。保存一整组(服务端
* 补齐没给的);测试邮件按已保存的设置发到管理员自己的账号邮箱,有没保存的改动时先保存
*/
function MailSection({ onSaved }: { onSaved: () => void }) {
const admin = useAtLeast('admin')
const [saved, setSaved] = useState<MailSettings | null>(null)
const [loadError, setLoadError] = useState<unknown>(null)
const [draft, setDraft] = useState<MailDraft | null>(null)
const [busy, setBusy] = useState(false)
const [testing, setTesting] = useState(false)
const load = () => api.settings.get().then((s) => { setSaved(s.mail ?? null); setLoadError(null) }, setLoadError)
useEffect(() => { void load() }, [])
const base = saved ? draftOf(saved) : null
const cur = draft ?? base
const dirty = !!cur && !!base && JSON.stringify(cur) !== JSON.stringify(base)
useLeaveGuard(dirty, () => confirmDialog({
title: M.section, body: M.leaveBody, confirmLabel: '放弃修改', cancelLabel: '返回保存', danger: true,
}))
const portBad = !!cur && !(/^\d+$/.test(cur.port.trim()) && Number(cur.port) >= 1 && Number(cur.port) <= 65535)
const edit = (patch: Partial<MailDraft>) => cur && setDraft({ ...cur, ...patch })
const save = async () => {
if (!cur || portBad || busy) return
setBusy(true)
try {
const out = await api.settings.put({ mail: {
enabled: cur.enabled, host: cur.host.trim() || null, port: Number(cur.port), security: cur.security,
username: cur.username.trim() || null, from_addr: cur.from_addr.trim() || null, from_name: cur.from_name.trim() || null,
default_for_assignees: cur.default_for_assignees,
...(cur.clearPassword ? { password: '' } : cur.password ? { password: cur.password } : {}),
} })
setSaved(out.mail)
setDraft(null)
toast.ok(M.saved)
onSaved()
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
const test = async () => {
setTesting(true)
try {
const res = await api.settings.mailTest()
if (res.ok) toast.ok(res.message)
else toast.warn(res.message)
} catch (e) {
toast.error(e)
} finally {
setTesting(false)
}
}
return (
<section data-mail-section={admin ? 'edit' : 'readonly'}>
<SectionBar title={M.section} hint={admin ? M.hint : `${M.hint}。${M.readOnly}`} />
{!cur ? (loadError ? <ErrorState error={loadError} onRetry={() => void load()} /> : <Skeleton rows={3} height={14} />) : (
<fieldset disabled={!admin} className="space-y-3">
<label className="flex items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5" checked={cur.enabled} data-mail-field="enabled"
onChange={(e) => edit({ enabled: e.target.checked })} />
<span>{M.enabled}<span className="mt-0.5 block text-2xs text-faint">{M.enabledHint}</span></span>
</label>
<div className="grid grid-cols-3 gap-3">
<Field label={M.host} hint={M.hostHint} className="col-span-2">
{(p) => <input {...p} className="field mono text-xs" value={cur.host} placeholder="smtp.example.com"
data-mail-field="host" onChange={(e) => edit({ host: e.target.value })} />}
</Field>
<Field label={M.port} error={portBad ? M.invalidPort : undefined}>
{(p) => <input {...p} className="field tnum text-xs" inputMode="numeric" value={cur.port} data-mail-field="port"
onChange={(e) => edit({ port: e.target.value })} />}
</Field>
</div>
<Field label={M.security}>
{(p) => (
<select {...p} className="field max-w-72 text-xs" value={cur.security} data-mail-field="security"
onChange={(e) => edit({ security: e.target.value })}>
{Object.entries(M.securityLabel).map(([k, label]) => <option key={k} value={k}>{label}</option>)}
</select>
)}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={M.username} hint={M.usernameHint}>
{(p) => <input {...p} className="field mono text-xs" value={cur.username} autoComplete="off" data-mail-field="username"
onChange={(e) => edit({ username: e.target.value })} />}
</Field>
<Field label={M.password} hint={saved?.password_set ? M.passwordSet : M.passwordUnset}>
{(p) => <input {...p} className="field mono text-xs" type="password" autoComplete="new-password" value={cur.password}
data-mail-field="password" disabled={cur.clearPassword}
onChange={(e) => edit({ password: e.target.value })} />}
</Field>
</div>
{saved?.password_set && (
<label className="flex items-center gap-1.5 text-2xs text-dim">
<input type="checkbox" checked={cur.clearPassword} data-mail-field="clear-password"
onChange={(e) => edit({ clearPassword: e.target.checked, password: '' })} /> {M.passwordClear}
</label>
)}
<div className="grid grid-cols-2 gap-3">
<Field label={M.fromAddr}>
{(p) => <input {...p} className="field mono text-xs" value={cur.from_addr} placeholder="agentlab@example.com"
data-mail-field="from_addr" onChange={(e) => edit({ from_addr: e.target.value })} />}
</Field>
<Field label={M.fromName}>
{(p) => <input {...p} className="field text-xs" value={cur.from_name} data-mail-field="from_name"
onChange={(e) => edit({ from_name: e.target.value })} />}
</Field>
</div>
<label className="flex items-start gap-2 text-xs">
<input type="checkbox" className="mt-0.5" checked={cur.default_for_assignees} data-mail-field="default_for_assignees"
onChange={(e) => edit({ default_for_assignees: e.target.checked })} />
<span>{M.defaultAssignees}<span className="mt-0.5 block text-2xs leading-relaxed text-faint">{M.defaultAssigneesHint}</span></span>
</label>
{admin && (
<div className="flex flex-wrap items-center gap-2">
<button type="button" className="btn btn-sm btn-primary" disabled={!dirty || portBad || busy} onClick={() => void save()}
data-mail-save="">
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {M.save}
</button>
<button type="button" className="btn btn-sm" disabled={dirty || testing} onClick={() => void test()}
title={dirty ? M.testDirty : M.testHint} data-mail-test="">
{testing ? <Spinner size={11} /> : <Send size={11} aria-hidden />} {M.test}
</button>
<span className="text-2xs text-faint">{dirty ? M.testDirty : M.testHint}</span>
</div>
)}
<p className="text-2xs leading-relaxed text-faint" data-mail-handoff="">{M.handoffHint}</p>
</fieldset>
)}
</section>
)
}
/** 「每天的待办汇总」:几点发(服务端时区)。只改 notify 一组的这一项,服务端补上对外访问地址 */
function DigestSection() {
const admin = useAtLeast('admin')
const [saved, setSaved] = useState<string | null>(null)
const [draft, setDraft] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
useEffect(() => { api.settings.get().then((s) => setSaved(s.notify?.digest_at ?? '09:00'), () => setSaved(null)) }, [])
if (saved == null) return null
const cur = draft ?? saved
const valid = /^([01]\d|2[0-3]):[0-5]\d$/.test(cur)
const save = async () => {
if (!valid || busy) return
setBusy(true)
try {
const out = await api.settings.put({ notify: { digest_at: cur } })
setSaved(out.notify?.digest_at ?? cur)
setDraft(null)
toast.ok(M.digestSaved(out.notify?.digest_at ?? cur))
} catch (e) {
toast.error(e)
} finally {
setBusy(false)
}
}
return (
<section data-digest-section={admin ? 'edit' : 'readonly'}>
<SectionBar title={M.digest} hint={admin ? M.digestHint : `${M.digestHint}。${M.digestReadOnly}`} />
<div className="flex flex-wrap items-end gap-2">
<Field label={M.digestAt}>
{(p) => <input {...p} type="time" className="field tnum w-32 text-xs" value={cur} disabled={!admin} data-digest-at=""
onChange={(e) => setDraft(e.target.value)} />}
</Field>
{admin && (
<button type="button" className="btn btn-sm" disabled={cur === saved || !valid || busy} onClick={() => void save()}
data-digest-save="">
{busy ? <Spinner size={11} /> : <Check size={12} aria-hidden />} {M.digestSave}
</button>
)}
</div>
</section>
)
}
+74 -2
View File
@@ -456,6 +456,11 @@ export interface ViewNode {
* 使用面不下发隐藏的组件
*/
hidden?: boolean
/**
* 分享链接里(resolve_page 的 share 旁路)被换成说明块的组件原来是什么(entry_button、task_list、report……):type 已经是
* notice,props.text 写着为什么不显示。别处没有这一项
*/
share_placeholder?: string
/**
* 服务端解析好的数据(backend/app/apps/resolve.py 的 _data),形状随组件而定;绑定了视图的都带 state 和可选的 message:
* - stamp:{view, state, stamp};metric:{state, text, name, unit, evidence}
@@ -720,12 +725,79 @@ export interface NotifyChannelRow {
name: string
scope: 'global' | 'app' | 'user'
owner_id: string | null
format: 'generic' | 'feishu' | 'wecom' | 'dingtalk'
/** 只回主机名 */
/** email:邮件(第五种格式,服务端 notify/mail.py,04 细案 B2) */
format: 'generic' | 'feishu' | 'wecom' | 'dingtalk' | 'email'
/** 只回主机名;邮件渠道是空串 */
url_host: string
signed: boolean
events: string[]
enabled: boolean
/**
* 邮件渠道发给谁:个人渠道是拥有者的账号邮箱(没有邮箱是空的,发不出去),全局、应用的是填的那几个收件地址。
* Webhook 渠道没有这一项。全局、应用的邮件渠道只给改得了它的人(开了登录要搭建者以上),别人拿到的是 mail_count
*/
mail_to?: string[]
/** 全局、应用的邮件渠道给改不了它的人列出时:只有收件地址的个数,没有地址(设置页的通知只给使用者列个人渠道,用不上) */
mail_count?: number
}
/**
* GET /api/notify/channels 另给的 mail:邮件服务(设置的 mail 一组)启没启用、本人的账号邮箱(关登录时为 null)。
* 设置页据此列不列「邮件」这一格式、个人邮件渠道写「发到账号邮箱 …」。老服务端不给时按没启用
*/
export interface NotifyMailState {
enabled: boolean
account_email: string | null
}
/**
* 分享链接(服务端 api/shares.py,04 细案 C2):管理应用的人在应用编辑里建、撤销。key 只在建的时候交回一次
* (ViewShareCreated),之后只给开头一截(prefix)认是哪一条
*/
export interface ViewShareRow {
id: string
prefix: string
app_id: string
page_id: string
page_title: string
/** latest:始终是最近一次封存;fixed:固定在建分享那一刻每个视图取的运行 */
mode: 'latest' | 'fixed'
app_version: number | null
embed_allowed: boolean
created_by: string | null
created_by_name: string | null
created_at: string | null
expires_at: string | null
revoked_at: string | null
revoked_by: string | null
open_count: number
last_opened_at: string | null
state: 'active' | 'revoked' | 'expired'
}
export interface ViewShareCreated extends ViewShareRow {
key: string
/** /s#<key> */
path: string
/** 拼上对外访问地址的完整链接;没设对外访问地址时为 null,界面按当前页面的地址拼 */
url: string | null
}
export interface ViewShareList {
/** 管理员开没开「允许建分享链接」:关着时不给新建,已有的照列、能撤销 */
enabled: boolean
/** 部署配没配允许嵌入分享页的站点(AGENTLAB_EMBED_ORIGINS) */
embed_origins: boolean
max_days: number
items: ViewShareRow[]
}
/** 分享页(GET /api/shares/page,agentlab.share/1):页脚要的、按分享解析的视图模型、能下载报告的那几次运行 */
export interface SharePayload {
format: 'agentlab.share/1'
share: { mode: 'latest' | 'fixed'; created_by: string | null; created_at: string; expires_at: string; embed_allowed: boolean }
view: ViewModel
downloads: { view: string; run_id: string; reports: string[]; as_of_text?: string | null }[]
}
/**
+2 -1
View File
@@ -99,6 +99,7 @@ const ORDER = [
'app-editor', // 应用编辑:固定版本、派生、页面树和属性面板、检查定位、发布时选处置、助手流
'capabilities', // 能力登记、端点、密钥掩码;设置页的身份、通知、审计
'access-keys', // 设置页的访问密钥:服务账号、密钥只显示一次、撤销、轮换、最近的请求、接口说明
'share', // 分享链接:/s 分享页不进外壳、印章常显、页脚、下载报告、撤销后的说明、嵌入;应用编辑里的分享管理
// 普通用户建应用(docs/design/normal-user-apps.md §4.8):全是伪造接口、不连真数据
'blueprint', // 新建应用的起点、蓝图卡片、反问、试运行、发布摘要
'personal', // 个人应用:建、刷新、提交审核
@@ -115,7 +116,7 @@ const ESTIMATE = {
// 占位,换成真检查时按实测改
schedules: 60, compare: 45, verify: 71, inquiry: 60, bundle: 16, whatif: 33, 'report-export': 30,
// 应用平台:还没实测,按同类页面估(全是伪造接口、不连真数据)
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30,
apps: 60, inbox: 45, 'app-editor': 45, capabilities: 30, 'access-keys': 30, share: 30,
// 普通用户建应用:还没实测,按同类页面估
blueprint: 90, personal: 45, records: 30, files: 30,
}
+127 -12
View File
@@ -6,6 +6,9 @@
// 管理员能开;测试连接按环境发,照服务端的话写(2xx 才是成功,能连上但 404 是提醒);
// - 关登录(默认)时所有人等同管理员:治理、退役、身份设置都能改;
// - 设置页的身份、通知、审计三个页签在,通知渠道的地址只显示主机名;
// - 邮件(04 细案 B2):「邮件服务」一节管理员改、搭建者只看,口令只说设没设、留空不改,有没保存的改动时不能发测试邮件;
// 每天的待办汇总的发送时间;邮件服务没启用时不列「邮件」格式;个人邮件渠道不填地址、写「发到账号邮箱 …」,没有邮箱照实
// 写发不出去;投递记录里邮件成功写「已交给邮件服务器」,不写已送达;
// - 能力字段的待审阅(D-i)复用数据目录的审阅队列:成批确认逐项带能力的治理版本;
// - 自定义工具可以绑定登记的端点(只填路径);MCP 的认证头按掩码编辑;本地子进程只有管理员能建、能改(A6);
// - 单点登录映射管理员:用户或组至少一项;开了登录的使用者在设置页只剩个人的主题和通知,换主题不写服务端(D-s)。
@@ -86,7 +89,11 @@ const MCP_ROWS = [
headers: { Authorization: 'Bearer ****9a2c' }, endpoint_id: null, status: 'ok', last_error: null, tools_cache: [] },
]
async function open(path, { conflict = false, me = OFF } = {}) {
const NOTIFY_ITEMS = [
{ id: 'ch1', name: '财务群', scope: 'global', owner_id: null, format: 'feishu', url_host: 'open.feishu.cn', signed: true,
events: ['task.opened'], enabled: true }]
async function open(path, { conflict = false, me = OFF, settings = null, notify = null, deliveries = [] } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
opened.add(ctx)
const page = await ctx.newPage()
@@ -95,7 +102,8 @@ async function open(path, { conflict = false, me = OFF } = {}) {
page.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
const state = { caps: JSON.parse(JSON.stringify(CAPS)), endpoints: JSON.parse(JSON.stringify(ENDPOINTS)), governs: [], endpointWrites: [],
tests: [], syncs: 0, settings: [], reviews: [], batches: [], tools: [], mcp: [], prefs: [] }
tests: [], syncs: 0, settings: [], reviews: [], batches: [], tools: [], mcp: [], prefs: [], channelWrites: [], mailTests: 0,
values: settings ? JSON.parse(JSON.stringify(settings)) : { ui: { theme: 'dark' } } }
await page.route(/\/api\//, (route) => {
const req = route.request()
@@ -133,8 +141,25 @@ async function open(path, { conflict = false, me = OFF } = {}) {
return json(route, { id: 'm9', ...req.postDataJSON() })
})
await page.route(/\/api\/settings$/, (route) => {
if (route.request().method() !== 'GET') { state.prefs.push(route.request().postDataJSON()); return json(route, {}) }
return me.mode !== 'off' && me.role === 'user' ? json(route, { detail: '没有权限' }, 403) : json(route, { ui: { theme: 'dark' } })
if (route.request().method() !== 'GET') {
const body = route.request().postDataJSON()
state.prefs.push(body)
// 同服务端:给了的组并进去(mail 的口令不回、只说设没设),交回整份
for (const [k, v] of Object.entries(body?.values ?? {})) {
state.values[k] = { ...(state.values[k] ?? {}), ...v }
if (k === 'mail') {
const pw = v.password
state.values.mail = { ...state.values.mail, password: null,
password_set: typeof pw === 'string' ? pw !== '' : !!state.values.mail.password_set }
}
}
return json(route, state.values)
}
return me.mode !== 'off' && me.role === 'user' ? json(route, { detail: '没有权限' }, 403) : json(route, state.values)
})
await page.route(/\/api\/settings\/mail\/test$/, (route) => {
state.mailTests += 1
return json(route, { ok: true, message: '已交给邮件服务器(发到 root@example.com),是否送达以邮件服务器为准,请到收件箱确认' })
})
await page.route(/\/api\/capabilities\/sync$/, (route) => { state.syncs += 1; return json(route, { changed: 2 }) })
await page.route(/\/api\/capabilities\/[^/]+\/governance$/, (route) => {
@@ -177,10 +202,16 @@ async function open(path, { conflict = false, me = OFF } = {}) {
await page.route(/\/api\/audit(\?.*)?$/, (route) => json(route, { items: [
{ id: 'a1', at: '2026-10-10T02:00:00Z', actor: '张工', verified: false, action: 'capability.govern', subject_kind: 'capability',
subject_id: 'erp_pay', data: { version: 2 } }] }))
await page.route(/\/api\/notify\/channels$/, (route) => json(route, { items: [
{ id: 'ch1', name: '财务群', scope: 'global', owner_id: null, format: 'feishu', url_host: 'open.feishu.cn', signed: true,
events: ['task.opened'], enabled: true }] }))
await page.route(/\/api\/notify\/deliveries(\?.*)?$/, (route) => json(route, { items: [] }))
await page.route(/\/api\/notify\/channels$/, (route) => {
const req = route.request()
if (req.method() === 'POST') {
const body = req.postDataJSON()
state.channelWrites.push(body)
return json(route, { id: 'ch-new', owner_id: null, url_host: '', signed: false, enabled: true, ...body }, 201)
}
return json(route, { items: notify?.items ?? NOTIFY_ITEMS, ...(notify?.mail ? { mail: notify.mail } : {}) })
})
await page.route(/\/api\/notify\/deliveries(\?.*)?$/, (route) => json(route, { items: deliveries }))
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(300)
@@ -311,23 +342,107 @@ await section('设置 → 通知:可订阅的事件照服务端登记列(总
return waitFor(page, async () => await dlg.locator(`[data-notify-events="${value}"]`).count() === 1)
}
const every = ['task.opened', 'run.finished', 'run.failed', 'action.unknown']
// 第 3 波起:定时评测的两个事件只推全局渠道(评测是搭建者们共用的一把尺,不归哪个人;服务端 notify/service.notify_eval)
// 第 3 波起:定时评测的两个事件只推全局渠道(评测是搭建者们共用的一把尺,不归哪个人;服务端 notify/service.notify_eval);
// 访问密钥快到期推给全局渠道(服务账号的密钥)和个人渠道(个人密钥),每天的待办汇总只推个人渠道
check('全局渠道:推给全局渠道的几项', await waitFor(page, async () => await dlg.locator('[data-notify-events="global"]').count() === 1)
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'eval.regressed', 'eval.failed']),
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'eval.regressed', 'eval.failed', 'key.expiring']),
JSON.stringify(await listed()))
const text = await dlg.innerText()
// 只推定时发起的评测:叫法照实带「定时」,勾上的人不会以为手动发起的那次也推
check('评测的两个事件照实说是定时的', ['定时评测有退步', '定时评测没有评完'].every((t) => text.includes(t)), text)
check('从来推不到的不列', !['待办升级', '待办超时', '待办处理完', '动作失败', '动作已生效'].some((t) => text.includes(t)), text)
check('登记了还没推送的不列', !['每天的待办汇总', '访问密钥快到期', '数据源有变化要处理', '口径升版,答案的数变了']
check('登记了还没推送的不列', !['数据源有变化要处理', '口径升版,答案的数变了']
.some((t) => text.includes(t)), text)
check('只推个人渠道的「每天的待办汇总」全局渠道不列', !text.includes('每天的待办汇总'), text)
check('应用的渠道:不列只发给提交人的「我提交审核的应用有结果」', await scope('app')
&& JSON.stringify(await listed()) === JSON.stringify(every), JSON.stringify(await listed()))
check('个人渠道:五项都列', await scope('user') && JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done']),
check('个人渠道:连同待办汇总、密钥快到期都列', await scope('user')
&& JSON.stringify(await listed()) === JSON.stringify([...every, 'review.done', 'task.digest', 'key.expiring']),
JSON.stringify(await listed()))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
})
const MAIL = { enabled: true, host: 'smtp.example.com', port: 587, security: 'starttls', username: 'agentlab', password: null,
password_set: true, from_addr: 'agentlab@example.com', from_name: 'AgentLab', default_for_assignees: false }
await section('设置 → 通知:邮件服务一节、每天的待办汇总', async () => {
const { page, state, errors } = await open('/settings/notify', { settings: { mail: MAIL, notify: { public_base_url: null, digest_at: '09:00' } },
notify: { items: NOTIFY_ITEMS, mail: { enabled: true, account_email: null } } })
const box = page.locator('[data-mail-section="edit"]')
check('管理员看得到、改得了邮件服务', await waitFor(page, async () => await box.count() === 1)
&& await box.locator('[data-mail-field="host"]').inputValue() === 'smtp.example.com')
check('口令只说设没设,输入框是空的', await box.locator('[data-mail-field="password"]').inputValue() === ''
&& (await box.innerText()).includes('已设置;留空表示不修改'))
check('没改动时能发测试邮件', !(await box.locator('[data-mail-test]').isDisabled()))
await box.locator('[data-mail-field="host"]').fill('mail.example.org')
check('有没保存的改动时先保存再发', await box.locator('[data-mail-test]').isDisabled() && (await box.innerText()).includes('先保存再发测试邮件'))
await box.locator('[data-mail-save]').click()
check('保存交的是 mail 一组,口令没填就不带', await waitFor(page, async () => state.prefs.length === 1)
&& state.prefs[0].values.mail.host === 'mail.example.org' && !('password' in state.prefs[0].values.mail),
JSON.stringify(state.prefs[0]))
await box.locator('[data-mail-test]').click()
check('测试邮件照服务端的话写「已交给邮件服务器」', await waitFor(page, async () => state.mailTests === 1)
&& await waitFor(page, async () => (await page.locator('body').innerText()).includes('已交给邮件服务器(发到')))
check('写明退信不追踪', (await box.locator('[data-mail-handoff]').innerText()).includes('退信不追踪'))
const digest = page.locator('[data-digest-section="edit"]')
check('待办汇总的发送时间', await digest.locator('[data-digest-at]').inputValue() === '09:00')
await digest.locator('[data-digest-at]').fill('17:30')
await digest.locator('[data-digest-save]').click()
check('只交汇总时间这一项', await waitFor(page, async () => state.prefs.length === 2)
&& JSON.stringify(state.prefs[1]) === JSON.stringify({ values: { notify: { digest_at: '17:30' } } }), JSON.stringify(state.prefs[1]))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
const builder = { mode: 'local', username: 'bea', display_name: '搭建者', role: 'builder', groups: [], verified: true }
const ro = await open('/settings/notify', { me: builder, settings: { mail: MAIL, notify: { public_base_url: null, digest_at: '09:00' } } })
const roBox = ro.page.locator('[data-mail-section="readonly"]')
check('搭建者只看:输入框都不能改、没有保存和测试', await waitFor(ro.page, async () => await roBox.count() === 1)
&& await roBox.locator('[data-mail-field="host"]').isDisabled() && await roBox.locator('[data-mail-save]').count() === 0
&& await roBox.locator('[data-mail-test]').count() === 0 && (await roBox.innerText()).includes('只有管理员能修改邮件服务'))
})
await section('设置 → 通知:邮件格式、个人邮件渠道发到账号邮箱、投递记录', async () => {
const off = await open('/settings/notify', { notify: { items: NOTIFY_ITEMS, mail: { enabled: false, account_email: null } } })
await off.page.getByRole('button', { name: '添加渠道' }).first().click()
const offDlg = off.page.locator('[role="dialog"]')
const options = await offDlg.locator('[data-notify-format-select] option').allInnerTexts()
check('邮件服务没启用:不列「邮件」,写明为什么', !options.includes('邮件')
&& (await offDlg.locator('[data-notify-mail-off]').innerText()).includes('邮件服务没有启用'), options.join('、'))
const me = { mode: 'local', username: 'uma', display_name: '小乌', role: 'user', groups: [], verified: true, user_id: 'u-uma' }
const items = [
{ id: 'm1', name: '我的邮件', scope: 'user', owner_id: 'u-uma', format: 'email', url_host: '', signed: false,
events: ['task.opened'], enabled: true, mail_to: ['uma@example.com'] },
{ id: 'm2', name: '旧邮件', scope: 'user', owner_id: 'u-uma', format: 'email', url_host: '', signed: false,
events: ['task.opened'], enabled: true, mail_to: [] },
]
const deliveries = [
{ id: 'd1', channel_id: 'm1', event: 'task.opened', status: 'ok', http_status: null, error: null, format: 'email', created_at: '2026-10-15T02:00:00Z' },
]
const { page, state, errors } = await open('/settings/notify', { me, deliveries,
notify: { items, mail: { enabled: true, account_email: 'uma@example.com' } } })
const row = page.locator('[data-notify-channel="我的邮件"]')
check('个人邮件渠道写「发到账号邮箱 …」', await waitFor(page, async () => await row.count() === 1)
&& (await row.locator('[data-notify-target]').innerText()).includes('发到账号邮箱 uma@example.com'))
check('账号没有邮箱的照实写发不出去', (await page.locator('[data-notify-channel="旧邮件"] [data-notify-target]').innerText())
.includes('没有邮箱地址'))
await page.getByRole('button', { name: '添加渠道' }).first().click()
const dlg = page.locator('[role="dialog"]')
await dlg.locator('[data-notify-format-select]').selectOption('email')
check('个人邮件渠道不填地址,写发到账号邮箱', await dlg.locator('[data-notify-mail-to="account"]').count() === 1
&& (await dlg.locator('[data-notify-mail-to="account"]').innerText()).includes('发到账号邮箱 uma@example.com')
&& await dlg.locator('input[placeholder="https://"]').count() === 0)
await dlg.getByLabel('名称').fill('待办邮件')
await dlg.locator('[data-notify-save]').click()
check('建的时候不带地址', await waitFor(page, async () => state.channelWrites.length === 1)
&& state.channelWrites[0].format === 'email' && state.channelWrites[0].scope === 'user' && !('url' in state.channelWrites[0]),
JSON.stringify(state.channelWrites[0]))
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
const admin = await open('/settings/notify', { deliveries, notify: { items: NOTIFY_ITEMS, mail: { enabled: true, account_email: null } } })
const d = admin.page.locator('[data-delivery-format="email"]')
check('投递记录里邮件成功写「已交给邮件服务器」,不写已送达', await waitFor(admin.page, async () => await d.count() === 1)
&& (await d.innerText()).includes('已交给邮件服务器') && !(await d.innerText()).includes('已送达'))
})
await section('能力:待审阅复用数据目录的审阅队列,成批确认逐项带能力的治理版本', async () => {
const { page, state, errors } = await open('/tools/capabilities?view=review')
check('左栏换成待审阅视图', await page.locator('[data-capability-views="review"]').count() === 1)
+5 -1
View File
@@ -6,7 +6,7 @@
// 守的是 2026-09 文案整改定下的写法(术语表和语体规范见 frontend/src/lib/terms.ts 开头):
// - 「跑」作动词(工作流写「运行」,节点、工具写「执行」)、后端(写「服务端」)、多半、照样、眼下、压根、
// 取不到、没取回来、读不懂、连不上、写坏了(写「无法 / 未能 / 失败」)、
// 令牌 / tok / tokens(写「token」)、钉住(写「固定版本」「引用版本」)、智能体(写「Agent」)、
// 令牌 / tok / tokens(写「token」)、快照链接(写「分享链接」)、钉住(写「固定版本」「引用版本」)、智能体(写「Agent」)、
// 供应商(写「模型接入」)、界面上的 Copilot(写「助手」);
// - 句尾语气词(吧、呢、哦……);
// - 句子里裸写的配置键名和枚举值(report_from、require_citation、cells: true……),要写界面上的中文标签;
@@ -67,6 +67,8 @@ const RULES = [
test: (t) => t.match(/.{0,6}(?:多半|照样|眼下|压根|取不到|没取回来|读不懂|连不上|写坏了).{0,6}/)?.[0],
},
{ id: '令牌', what: '「令牌」:写「token」', test: (t) => t.match(/.{0,6}令牌.{0,6}/)?.[0] },
// 总方案 X10:不记名的只读链接叫「分享链接」,「快照」专指查询快照
{ id: '快照链接', what: '「快照链接」:写「分享链接」(「快照」专指查询快照)', test: (t) => t.match(/.{0,6}快照链接.{0,6}/)?.[0] },
{
id: 'tok', what: '「tok」「tokens」:写「token」(如「2.8k token」)',
test: (t, prose) => (prose
@@ -462,6 +464,8 @@ console.log('\n=== 扫描器自检(拿构造的源码喂一遍)===')
&& !strings.some((s) => s.text === '跑') && !seen('./跑.ts') && !strings.some((s) => s.text === '后端'))
check('合规的文案不误报', !lint('保存并继续运行', true).length && !lint('56.0k token', false).length
&& !lint('已达上限(3 句),这句未裁判', true).length)
check('「快照链接」报出来,「分享链接」「查询快照」不误报', lint('复制快照链接', true).some((h) => h.rule === '快照链接')
&& !lint('复制分享链接', true).length && !lint('打开查询快照', true).length)
const py = [
'from fastapi import HTTPException',
+321
View File
@@ -0,0 +1,321 @@
// 分享链接的检查(docs/design/next-round/04-对外集成与触达.md C2):/s 分享页和应用编辑里的分享管理。
//
// 守的是:
// - /s 不进外壳:没有导航,不认人、不取目录、不连心跳——整页只发一个请求 GET /api/shares/page;
// - 分享 key 从链接的 # 后面读,放进请求头 X-AgentLab-Share,地址里不带;
// - 用同一个视图渲染器画:指标照常显示;待办、发起按钮这些服务端换成的说明块照画,没有待办卡、没有发起按钮;
// - 印章常显:「数据的来历」里逐个列出视图的印章(页面上没放印章组件也列);
// - 页脚写「只读 · 由 X 于 … 分享 · … 到期」;
// - 点数字不开在线证据,说明出处在下载的报告里;下载报告带请求头、只取这条分享解析出来的运行;
// - 撤销、到期、链接不完整:照服务端的话写明(「链接已撤销」……),不给空白页;
// - 这条分享没允许嵌入、页面又在别的页面的 iframe 里:不显示内容,给「在新窗口打开」;允许嵌入的照常显示;
// - 应用编辑 → 岗位与授权:管理员没开开关时不给新建、写明去哪开;开了之后建分享(页面、数据、有效期、嵌入),链接只显示
// 这一次;列表写状态、打开次数;撤销先确认。
//
// 不写库:/api/shares/*、/api/apps/* 全部伪造。其余写请求一律 409 并记账,检查结束时必须是空的。
//
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273:
// AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-share.mjs
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const ONLY = (process.env.SHARE_ONLY ?? '').split(',').filter(Boolean)
let failed = 0
const check = (name, cond, detail = '') => {
const d = String(detail ?? '').replace(/\s+/g, ' ').slice(0, 160)
console.log(` ${cond ? '✓' : '✗'} ${name}${d ? ` — ${d}` : ''}`)
if (!cond) failed++
}
const opened = new Set()
async function section(name, fn) {
if (ONLY.length && !ONLY.some((k) => name.includes(k))) return
console.log(`=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
} finally {
for (const c of opened) await c.close().catch(() => {})
opened.clear()
}
}
const waitFor = async (page, fn, ms = 5000) => {
for (let t = 0; t < ms; t += 100) {
if (await fn().catch(() => false)) return true
await page.waitForTimeout(100)
}
return false
}
const clone = (v) => JSON.parse(JSON.stringify(v))
const writes = []
// 只拦接口:按路径的开头认(/src/api/client.ts 这类前端模块的地址里也有「/api/」,不能按正则拦)
const API = (url) => url.pathname.startsWith('/api/')
// ---------------------------------------------------------------- 夹具(通用示例名)
const KEY = 'Sh4reKeyExampleOnly_0123456789abcdefGHIJ'
const STAMP = {
as_of: '2026-10-15T01:00:00Z', as_of_text: '截至 2026-10-15 09:00;统计期 week 2026-W41', pin: '周报 v3', graph_hash: 'ab12cd34',
calibers: [{ name: '周报口径', version: 'v3' }], tier: 'formal', tier_text: '完整出具', from: '定时任务「周一出数」第 6 次', sealed: true,
}
const VIEW = {
format: 'agentlab.view/1',
app: { id: 'app-share', name: '门店周报', version: 3, level: 'published', needs_upgrade: false },
page: { id: 'home', title: '本周概览' },
pages: [{ id: 'home', title: '本周概览' }],
sources: {
wk: { state: 'ok', kind: 'latest_sealed', run_id: 'run-share-1', stamp: STAMP },
ask: { state: 'empty', kind: 'on_demand', run_id: null, stamp: null, message: '分享链接只显示定时出数的视图:这一块的数要打开应用才看得到' },
},
tree: [
{ id: 'gmv', type: 'metric', props: { label: '销售额' }, data: { state: 'ok', text: '18,218.54元', name: '销售额', unit: '元',
evidence: { run_id: 'run-share-1', kind: 'metric', node: 'card', metric: 'gmv', eid: 'e1' } } },
{ id: 'asked', type: 'metric', props: { label: '按条件查看' }, data: { state: 'empty',
message: '分享链接只显示定时出数的视图:这一块的数要打开应用才看得到' } },
{ id: 'todo', type: 'notice', props: { text: '分享链接不列待办:待办只给派给的人,请打开应用处理', tone: 'info' },
share_placeholder: 'task_list' },
{ id: 'go', type: 'notice', props: { text: '分享链接是只读的,不能在这里发起', tone: 'info' }, share_placeholder: 'entry_button' },
],
forms: {}, problems: [],
}
const PAYLOAD = {
format: 'agentlab.share/1',
share: { mode: 'latest', created_by: '编辑甲', created_at: '2026-10-14T02:00:00Z', expires_at: '2026-11-13T02:00:00Z', embed_allowed: false },
view: VIEW,
downloads: [{ view: 'wk', run_id: 'run-share-1', reports: ['write'], as_of_text: STAMP.as_of_text }],
}
async function openShare({ hash = `#${KEY}`, payload = PAYLOAD, status = 200, error = null } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, acceptDownloads: true })
opened.add(ctx)
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const state = { api: [], pages: [], reports: [] }
page.on('request', (req) => { if (new URL(req.url()).pathname.startsWith('/api/')) state.api.push(new URL(req.url()).pathname) })
const json = (route, body, code = 200) => route.fulfill({ status: code, contentType: 'application/json', body: JSON.stringify(body) })
await page.route(API, (route) => {
const req = route.request()
if (req.method() === 'GET') return json(route, { detail: '分享页不该请求这个接口' }, 418)
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
return json(route, { detail: '检查脚本不写库' }, 409)
})
await page.route(/\/api\/shares\/page(\?.*)?$/, (route) => {
const req = route.request()
state.pages.push({ url: req.url(), header: req.headers()['x-agentlab-share'] ?? null })
return error ? json(route, error, status) : json(route, payload)
})
await page.route(/\/api\/shares\/report\.html(\?.*)?$/, (route) => {
const req = route.request()
state.reports.push({ url: req.url(), header: req.headers()['x-agentlab-share'] ?? null })
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: '<!doctype html><title>报告</title>',
headers: { 'Content-Disposition': "attachment; filename*=UTF-8''%E5%91%A8%E6%8A%A5.html" } })
})
await page.goto(`${WEB}/s${hash}`, { waitUntil: 'networkidle' })
return { ctx, page, state, errors }
}
const browser = await chromium.launch({ executablePath: CHROME })
// ---------------------------------------------------------------- 分享页
await section('分享页:不进外壳,key 只放在请求头里', async () => {
const { page, state, errors } = await openShare()
check('页面画出来了', await waitFor(page, async () => await page.locator('[data-share-page="ok"]').count() === 1))
check('没有外壳的导航', await page.locator('nav').count() === 0 && await page.locator('[data-nav-item]').count() === 0)
check('只请求了分享页一个接口(不认人、不取目录、不连心跳)',
state.api.length >= 1 && state.api.every((p) => p === '/api/shares/page'), state.api.join('、'))
const [first] = state.pages
check('key 在请求头 X-AgentLab-Share 里', first?.header === KEY)
check('请求地址里不带 key', !!first && !first.url.includes(KEY))
check('页面上没有报错', errors.length === 0, errors.join(';'))
})
await section('分享页:视图照画,流程组件是说明,印章常显,页脚', async () => {
const { page } = await openShare()
await waitFor(page, async () => await page.locator('[data-share-page="ok"]').count() === 1)
check('定时出数的指标照常显示', (await page.locator('[data-node="gmv"]').innerText()).includes('18,218.54'))
check('按需视图写明只显示定时出数的', (await page.locator('[data-node="asked"]').innerText()).includes('只显示定时出数的视图'))
check('没有待办卡、没有发起按钮', await page.locator('[data-component="task_list"], [data-entry-button], [data-task-card]').count() === 0)
check('换成的说明块照画', (await page.locator('[data-node="todo"]').innerText()).includes('不列待办')
&& (await page.locator('[data-node="go"]').innerText()).includes('只读'))
check('只读的标记', (await page.locator('[data-share-readonly]').innerText()).includes('只读'))
check('印章常显:页面上没放印章组件也列出来', await page.locator('[data-share-stamps] [data-stamp]').count() === 1
&& (await page.locator('[data-share-stamps]').innerText()).includes('完整出具'))
const footer = await page.locator('[data-share-footer-text]').innerText()
check('页脚写谁分享、什么时候、什么时候到期', /^只读 · 由 编辑甲 于 .+ 分享 · .+ 到期$/.test(footer.trim()), footer)
check('没有刷新、提交这些操作', await page.locator('[data-view-refresh], [data-form-submit]').count() === 0)
})
await section('分享页:点数字说明出处在报告里,下载报告带请求头', async () => {
const { page, state } = await openShare()
await waitFor(page, async () => await page.locator('[data-share-page="ok"]').count() === 1)
await page.locator('[data-node="gmv"] [data-evidence="metric"]').click()
check('不开在线证据,说明出处在下载的报告里',
await waitFor(page, async () => await page.locator('[data-share-evidence-note]').count() === 1)
&& (await page.locator('[data-share-evidence-note]').innerText()).includes('下载报告'))
check('没有去取证据接口', !state.api.some((p) => p.includes('/evidence')))
await page.keyboard.press('Escape')
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 5000 }).catch(() => null),
page.locator('[data-share-footer] [data-share-download="run-share-1"]').click(),
])
check('下载了报告文件,用服务端给的文件名', !!download && download.suggestedFilename() === '周报.html', download?.suggestedFilename())
const [req] = state.reports
check('下载报告带请求头、点名这次分享的运行', req?.header === KEY && req.url.includes('run=run-share-1')
&& req.url.includes('report=write') && !req.url.includes(KEY))
})
await section('分享页:撤销、到期、链接不完整都写明', async () => {
for (const [code, title, detail] of [
['share_revoked', '链接已撤销', '链接已撤销(2026-10-15 10:00):请向分享的人要新的链接'],
['share_expired', '链接已到期', '链接已到期(2026-10-15 10:00):请向分享的人要新的链接'],
]) {
const { page } = await openShare({ status: 410, error: { detail, code } })
const box = page.locator(`[data-share-error="${code}"]`)
check(`${title}:标题和服务端的原话`, await waitFor(page, async () => await box.count() === 1)
&& (await box.innerText()).includes(title) && (await box.innerText()).includes('请向分享的人要新的链接'))
}
const { page, state } = await openShare({ hash: '' })
check('链接不完整(没有 # 后面那段):写明,不发请求',
await waitFor(page, async () => await page.locator('[data-share-error="share_key_missing"]').count() === 1)
&& state.pages.length === 0)
})
await section('分享页:没允许嵌入的不在别的页面里显示,允许的照常', async () => {
for (const allowed of [false, true]) {
const payload = clone(PAYLOAD)
payload.share.embed_allowed = allowed
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } })
opened.add(ctx)
const page = await ctx.newPage()
await page.route(/\/api\/shares\/page(\?.*)?$/, (route) => route.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify(payload) }))
await page.route((url) => API(url) && !url.pathname.startsWith('/api/shares/'), (route) => route.fulfill({ status: 418, body: '' }))
await page.setContent(`<!doctype html><iframe id="f" src="${WEB}/s#${KEY}" style="width:1000px;height:700px"></iframe>`)
const frame = await (async () => {
for (let i = 0; i < 50; i++) {
const f = page.frames().find((x) => x.url().includes('/s'))
if (f) return f
await page.waitForTimeout(100)
}
return null
})()
const want = allowed ? 'ok' : 'framed'
const ok = !!frame && await waitFor(page, async () => await frame.locator(`[data-share-page="${want}"]`).count() === 1)
check(allowed ? '允许嵌入:iframe 里照常显示' : '没允许嵌入:iframe 里不显示内容,给在新窗口打开', ok)
if (!allowed && frame) check('嵌着时没有画视图', await frame.locator('[data-view-renderer]').count() === 0)
}
})
// ---------------------------------------------------------------- 应用编辑里的分享管理
const SPEC = {
format: 'agentlab.app/1', name: '门店周报', description: '', icon: 'app',
pins: [{ alias: 'weekly', workflow_id: 'wf-share', version: 3, graph_hash: 'hash3', level: 'published', use: 'view' }],
slots: {}, entries: [], forms: {}, task_ui: {},
views: { wk: { source: { kind: 'latest_sealed', pin: 'weekly', from: 'formal' } } },
pages: [{ id: 'home', title: '本周概览', body: [] }], hooks: [], notify: [],
}
const shareRow = (over = {}) => ({
id: 'sh1', prefix: 'AbC123', app_id: 'app-share', page_id: 'home', page_title: '本周概览', mode: 'latest', app_version: 3,
embed_allowed: false, created_by: 'root', created_by_name: '管理员', created_at: '2026-10-14T02:00:00Z',
expires_at: '2026-11-13T02:00:00Z', revoked_at: null, revoked_by: null, open_count: 4, last_opened_at: '2026-10-15T02:00:00Z',
state: 'active', ...over,
})
async function openEditor({ enabled = true, items = [shareRow()] } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
opened.add(ctx)
const page = await ctx.newPage()
page.setDefaultTimeout(8000)
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
const json = (route, body, code = 200) => route.fulfill({ status: code, contentType: 'application/json', body: JSON.stringify(body) })
const state = { items: clone(items), creates: [], revokes: [] }
const app = { id: 'app-share', name: SPEC.name, description: '', icon: 'app', status: 'active', published_version: 3,
level: 'published', needs_upgrade: false, pending_tasks: 0, my_role: 'manage', updated_at: '2026-10-10T02:00:00Z',
draft: clone(SPEC), draft_bindings: { slots: {} }, created_by: 'root', scope: 'shared',
published: { version: 3, pages: [{ id: 'home', title: '本周概览' }], entries: [] } }
await page.route(/\/api\//, (route) => {
const req = route.request()
if (req.method() === 'GET') return route.continue()
writes.push(`${req.method()} ${new URL(req.url()).pathname}`)
return json(route, { detail: '检查脚本不写库' }, 409)
})
await page.route(/\/api\/apps\/catalog$/, (route) => json(route, { version: 1, components: [] }))
await page.route(/\/api\/apps\/app-share(\/.*)?(\?.*)?$/, (route) => {
const req = route.request()
const parts = new URL(req.url()).pathname.split('/')
const sub = parts[4]
if (!sub) return json(route, app)
if (sub === 'grants') return json(route, { items: [] })
if (sub === 'assistant-conversation') return json(route, { id: 'conv-share', kind: 'app', title: '', turns: [] })
if (sub === 'shares') {
if (req.method() === 'GET') return json(route, { enabled, embed_origins: false, max_days: 90, items: state.items })
if (req.method() === 'POST') {
const body = req.postDataJSON()
state.creates.push(body)
const row = shareRow({ id: 'sh2', prefix: 'NeW456', mode: body.mode, embed_allowed: body.embed_allowed, open_count: 0,
expires_at: body.expires_at })
state.items = [row, ...state.items]
return json(route, { ...row, key: 'NeW456-secret-share-key', path: '/s#NeW456-secret-share-key', url: null }, 201)
}
if (req.method() === 'DELETE') {
state.revokes.push(parts[5])
state.items = state.items.map((r) => (r.id === parts[5] ? { ...r, state: 'revoked', revoked_at: '2026-10-16T02:00:00Z' } : r))
return json(route, state.items.find((r) => r.id === parts[5]))
}
}
return route.fallback()
})
await page.goto(`${WEB}/apps/app-share/edit/access`, { waitUntil: 'networkidle' })
return { page, state, errors }
}
await section('应用编辑:管理员没开开关时不给新建,写明去哪开', async () => {
const { page } = await openEditor({ enabled: false, items: [] })
const box = page.locator('[data-app-shares="off"]')
check('写明管理员没开、去哪开', await waitFor(page, async () => await box.count() === 1)
&& (await box.innerText()).includes('允许建分享链接'))
check('没有新建的入口', await page.locator('[data-app-share-create]').count() === 0)
})
await section('应用编辑:建分享链接,链接只显示一次;列表与撤销', async () => {
const { page, state, errors } = await openEditor()
const row = page.locator('[data-app-share="AbC123"]')
check('已有的分享列出状态、打开次数、建的人', await waitFor(page, async () => await row.count() === 1)
&& (await row.innerText()).includes('有效') && (await row.innerText()).includes('打开过 4 次')
&& (await row.innerText()).includes('由 管理员 建'))
await page.locator('[data-app-share-create]').click()
check('表单:选页面、数据、有效期、嵌入', await page.locator('[data-app-share-form]').count() === 1
&& await page.locator('[data-app-share-days] option').count() === 4)
await page.locator('[data-app-share-mode="fixed"]').check()
await page.locator('[data-app-share-days]').selectOption('7')
await page.locator('[data-app-share-submit]').click()
check('建好后链接只显示这一次', await waitFor(page, async () => await page.locator('[data-app-share-once]').count() === 1)
&& (await page.locator('[data-app-share-link]').innerText()).endsWith('/s#NeW456-secret-share-key')
&& (await page.locator('[data-app-share-once]').innerText()).includes('只显示这一次'))
const [body] = state.creates
const days = body ? (new Date(body.expires_at).getTime() - Date.now()) / 86_400_000 : 0
check('交给服务端的:页面、固定这一期、7 天后到期、不允许嵌入', body?.page_id === 'home' && body.mode === 'fixed'
&& days > 6.9 && days < 7.1 && body.embed_allowed === false, JSON.stringify(body))
await page.locator('[data-app-share-dismiss]').click()
check('关掉之后链接就没有了', await page.locator('[data-app-share-link]').count() === 0
&& await page.locator('[data-app-share="NeW456"]').count() === 1)
await page.locator('[data-app-share-revoke="AbC123"]').click()
const dialog = page.getByRole('alertdialog').or(page.getByRole('dialog'))
check('撤销先确认,写明后果', await waitFor(page, async () => (await dialog.innerText()).includes('链接已撤销')))
await dialog.getByRole('button', { name: '撤销' }).click()
check('撤销了', await waitFor(page, async () => state.revokes.includes('sh1'))
&& await waitFor(page, async () => await page.locator('[data-app-share="AbC123"][data-app-share-state="revoked"]').count() === 1))
check('页面上没有报错', errors.length === 0, errors.join(';'))
})
check('检查没有写库(兜底拦下的写请求)', writes.length === 0, writes.slice(0, 4).join('、'))
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
await browser.close()
process.exit(failed ? 1 : 0)