合入期 1 可验证报告:已封存运行导出自包含的 HTML,断网也能在浏览器里核对事件、封存清单、工件哈希、引用链和每个数字;记录页封存凭证条加导出按钮

This commit is contained in:
ThearchyHelios
2026-10-05 07:53:23 +08:00
15 changed files with 4019 additions and 18 deletions
+66 -3
View File
@@ -1,10 +1,73 @@
"""可验证报告:把一次运行的报告和证据导出成可独立核对的 HTML 包。
"""可验证报告:GET /api/runs/{run_id}/bundle.html,把一次已封存运行的报告导出成一个自包含的 HTML 文件。
目前只登记了路由前缀,接口由可验证报告的工作包补上。路径挂在 /api/runs/{run_id}/… 下面;
注意 runs.py 里已经有 /{run_id}/verify(封存复验),别撞名。
拿到文件的人不连服务、不装任何东西,在浏览器里就能核对每个数字的出处、运行记录和工件有没有被改过
(页面怎么核见 bundle/verifier.js,数据怎么拼见 bundle/build.py)。
只给已封存、而且封存核对一致的运行:封存判定照搬证据接口(api/evidence._sealed),不另写一套。没封存的
运行事件还会变,导出去的东西马上就过时;封存核对不一致的,导出去的页面只会把一条断掉的链画成核对通过。
另外要求运行已经结束(成功、失败、取消):失败后接着跑的运行留着上一次的封存,跑完会重新封存、换掉清单哈希,
早年的运行停在审批时也封存过——这时导出去的文件,封存哈希和系统里之后记的对不上,页面上「去系统里对照」
那一步就作废了。
只读:不追加任何事件,不改运行记录。路径挂在 /api/runs/{run_id}/… 下面,runs.py 里已经有 /{run_id}/verify,
这里叫 bundle.html,不撞名。
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from fastapi import APIRouter
from fastapi.responses import Response
from sqlalchemy import select
from app.api.coded import CodedHTTPException
from app.api.evidence import RUN_NOT_FOUND, _mask_fingerprint, _sealed
from app.bundle.build import attachment_name, build_bundle, content_disposition, render_html
from app.core.artifact_store import read_bytes
from app.db.base import SessionLocal
from app.db.models import Run, RunEvent
router = APIRouter(prefix="/api/runs", tags=["bundle"])
BUNDLE_UNSEALED = "bundle_unsealed"
BUNDLE_SEAL_BROKEN = "bundle_seal_broken"
#: 运行结束的状态:封存是最后一次的,之后不会再换(除非失败的运行又被接着跑,那是另一次封存)
_FINISHED = frozenset({"succeeded", "failed", "cancelled"})
UNSEALED = "运行尚未结束封存,结束后才能导出"
#: 原样出现在记录页的错误提示里,导出按钮旁边就是「核对」。不拼封存核对的结论原句:那句自带冒号和「不一致」
SEAL_BROKEN = "封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。点「核对」可查看核对结果"
@router.get("/{run_id}/bundle.html")
async def export_bundle(run_id: str) -> Response:
"""可验证报告的 HTML 文件,作为附件下载(附件名「<运行名>-可验证报告.html」)。"""
sealed = await _sealed(run_id)
if not sealed.sealed or sealed.status not in _FINISHED:
raise CodedHTTPException(409, UNSEALED, BUNDLE_UNSEALED)
if not sealed.trusted:
raise CodedHTTPException(409, SEAL_BROKEN, BUNDLE_SEAL_BROKEN)
bound = sealed.seal["manifest_seq"]
async with SessionLocal() as session:
run = await session.get(Run, run_id)
if run is None: # 两次读之间被删掉了
raise CodedHTTPException(404, "运行记录不存在", RUN_NOT_FOUND)
info = {"id": run.id, "workflow_id": run.workflow_id, "workflow_name": run.workflow_name,
"version": run.version, "version_hash": run.version_hash, "run_class": run.run_class,
"status": run.status, "started_at": run.started_at, "finished_at": run.finished_at}
# 封存之后追加的事件(按需裁判、审批质询):_sealed 只留了封存范围内的,这几条另取,单列在页面上
later = [tuple(r) for r in await session.execute(
select(RunEvent.seq, RunEvent.type, RunEvent.node_id, RunEvent.data)
.where(RunEvent.run_id == run_id, RunEvent.seq > bound).order_by(RunEvent.seq))]
# 数据源现在设的遮罩:事后加的遮罩同样生效,和证据面板一个道理
masks = await _mask_fingerprint()
exported_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
# 读工件、算哈希、base64 是同步的活,快照大的时候不该占着事件循环
page = await asyncio.to_thread(lambda: render_html(build_bundle(
info, [*sealed.events, *later], manifest_seq=bound, manifest_hash=str(sealed.manifest_hash or ""),
legacy=bool(sealed.seal.get("legacy")), graph=sealed.graph, source_masks=masks,
read=lambda artifact: read_bytes(artifact, verify=False), exported_at=exported_at)))
return Response(page, media_type="text/html; charset=utf-8", headers={
"Content-Disposition": content_disposition(attachment_name(info), run_id=run_id),
# 导出的是这一刻的封存件,不该被哪一层缓存下来、换给下一个请求
"Cache-Control": "no-store",
})
+8
View File
@@ -0,0 +1,8 @@
"""可验证报告:把一次已封存运行的报告导出成一个自包含的 HTML 文件,拿到文件的人在浏览器里就能核对。
- build.py:从事件、工件拼出页面数据,填进模板(纯函数,不碰数据库)
- template.html:页面骨架和样式
- verifier.js:浏览器端的核对和渲染(原生 JS,无依赖)
接口在 api/bundle.py(GET /api/runs/{run_id}/bundle.html)。
"""
+465
View File
@@ -0,0 +1,465 @@
"""可验证报告的构建:把一次已封存运行的报告、证据和运行记录打成一个自包含的 HTML 文件。
拿到文件的人不连服务、不装任何东西,在浏览器里就能核对(verifier.js):
- 事件:封存范围内(seq ≤ manifest_seq)的每一条都带 {seq, type, node_id, data_sha256},data_sha256 是
sha256(canonical_json(data)),和 runner 封存时算 manifest_hash 用的是同一个算法,浏览器按同样的拼法重算
清单哈希、和封存哈希比。白名单里的事件另带服务端算好的规范字符串 canonical(逐字等于 canonical_json(data)),
浏览器先核它的哈希,再从里面读出报告文档、口径卡、查询快照的 id。其余事件只给哈希:工具返回、节点产出的
预览里可能有遮罩列的原值和提示词。
- 工件:每份报告文档(各报告节点最后一次自查记下的 doc_artifact)、它引用到的口径卡和查询快照,按原字节
base64 嵌入,浏览器重算 sha256 和 id 比;带遮罩的快照不嵌内容、只给 id。没被报告引用的工件不嵌。
- 封存之后追加的事件(按需裁判、审批质询这类批注)单列:类型、序号、内容摘要,不进清单。
它只证明「内部一致」:数字、文档、运行记录彼此对得上,而且没被改过。真伪要拿页面上的封存哈希和系统里这次
运行的封存核对(GET /api/runs/{id}/verify)对照,页面上醒目地写着这一点。
主体是纯函数:不碰数据库,工件经 read 取原字节,数据源现在设的遮罩由调用方查好传进来(source_masks)。
"""
from __future__ import annotations
import base64
import hashlib
import html
import json
import re
from collections.abc import Callable, Iterable, Mapping, Sequence
from datetime import datetime
from functools import lru_cache
from pathlib import Path
from typing import Any
from urllib.parse import quote
from app.core.artifact_store import canonical_json, content_hash
from app.core.events import EventType
from app.engine.evidence import iter_units, query_entry_fields
from app.engine.merge_query import MERGE_SOURCE
from app.engine.schema import NodeType
from app.engine.toolcalls import QUERY_PREFIX
SCHEMA = "agentlab.bundle/1"
#: 不论哪个节点都带规范字符串的事件:运行开始(实际生效的运行参数)、报告自查(报告文档的 id)、
#: 口径卡升版处置、出具判定(档位)
_SHOWN_EVENTS = frozenset({EventType.RUN_STARTED, EventType.REPORT_CHECKED, EventType.CALIBER_UPGRADE,
EventType.ISSUANCE})
#: node.finished 带规范字符串的节点:报告(预览就是嵌进来的那份文档的正文)、口径卡(指标集的 id 记在台账里)
_SHOWN_NODES = frozenset({NodeType.REPORT, NodeType.METRICS})
#: 目录条目的种类 → 嵌进来的工件种类。运行输入、检索、表和字段的条目不嵌:前者没有工件,后几样不在核对范围里
_CITED_KINDS = {"metric": "metric_set", "query": "query"}
#: 没挂在工作流上的运行(画布、问数据当场搭的):老数据叫「临时图」,新的叫「未保存的工作流」,也可能是空的。
#: 和前端 lib/terms.ts 的 runName 同一个规则
UNSAVED_NAME = "未保存的工作流"
_UNSAVED_NAMES = frozenset({"", "临时图", UNSAVED_NAME})
FILE_SUFFIX = "-可验证报告.html"
#: 文件名里不能出现的字(Windows 最严,按它来)和控制字符
_BAD_FILENAME = re.compile(r'[\\/:*?"<>|\x00-\x1f\x7f]')
_NAME_MAX = 80
Reader = Callable[[str], "bytes | None"]
# --------------------------------------------------------------------------
# 工件:原字节、解析、遮罩
# --------------------------------------------------------------------------
class _Store:
"""这一次构建里读过的工件:原字节、解析出来的内容、查询快照带不带遮罩。每份只读一次盘。"""
def __init__(self, read: Reader, source_masks: Mapping[str, Any]) -> None:
self._read = read
self._source_masks = source_masks
self._raw: dict[str, bytes | None] = {}
self._json: dict[str, Any] = {}
self._masked: dict[str, bool] = {}
def raw(self, artifact: str) -> bytes | None:
if artifact not in self._raw:
try:
data = self._read(artifact)
except (OSError, ValueError):
data = None
self._raw[artifact] = data if isinstance(data, (bytes, bytearray)) else None
return self._raw[artifact]
def content(self, artifact: str) -> Any:
"""解析出来的内容,读不出来、不是 JSON 的是 None。不看哈希:被改过的快照也要知道它记的遮罩。"""
if artifact not in self._json:
raw = self.raw(artifact)
try:
self._json[artifact] = json.loads(raw) if raw is not None else None
except ValueError:
self._json[artifact] = None
return self._json[artifact]
def intact(self, artifact: str) -> bool:
raw = self.raw(artifact)
return raw is not None and content_hash(bytes(raw)) == artifact
def masked(self, artifact: str) -> bool:
"""这份查询快照算不算带遮罩:查询当时记下了遮罩列(mask_columns),或者它的数据源现在设了遮罩
(合并查询看它合并的那几个数据源)。比证据面板粗:面板只遮那几列,这里整份不随文件附带——文件会
离开系统,遮罩只减少暴露,宁可多挡。读不出来的快照不知道带没带遮罩,按带遮罩算。"""
if artifact not in self._masked:
self._masked[artifact] = _snapshot_masked(self.content(artifact), self._source_masks)
return self._masked[artifact]
def _snapshot_masked(content: Any, source_masks: Mapping[str, Any]) -> bool:
if not isinstance(content, dict):
return True
recorded = content.get("mask_columns")
if isinstance(recorded, list) and any(isinstance(c, str) and c.strip() for c in recorded):
return True
sources = content.get("sources") if content.get("source") == MERGE_SOURCE else [content.get("source")]
return any(source_masks.get(str(name or "")) for name in sources or [])
def _query_artifacts(events: Iterable[tuple[int, str, str | None, Any]]) -> list[str]:
"""封存范围内交回过的查询快照:tool.end / merge.end 的 query_artifact,节点台账里的 query 条目。"""
out: list[str] = []
for _, etype, _, data in events:
if not isinstance(data, dict):
continue
if etype in (EventType.TOOL_END, EventType.MERGE_END) and isinstance(data.get("query_artifact"), str):
out.append(data["query_artifact"])
elif etype == EventType.NODE_FINISHED:
out += [str(e["artifact"]) for e in data.get("evidence") or []
if isinstance(e, dict) and e.get("kind") == "query" and e.get("artifact")]
return list(dict.fromkeys(a for a in out if a))
_UNSEEN = object()
def _query_returned(data: Mapping[str, Any], store: _Store) -> tuple[str, str | None]:
"""一次数据源查询(db_query__ 工具的 tool.end)交回了什么:("snapshot", 查询快照 id);("none", None) 没交回数据
(查询失败、SQL 被拒的那句原话);认不出来是 ("unknown", None)。
新运行在 query_artifact 里直接记了快照 id。没记的有三种:查询没成功;升级前的运行(证据台账之前,tool.end 只有
工具快照,查询快照的 id 嵌在工具快照记下的返回里,照证据接口 _legacy_pool 的办法认);协作团队成员的查询(连工具
快照都不落,只能看事件里的预览,长一点的返回被截断,解析不了)。"""
artifact = data.get("query_artifact")
if isinstance(artifact, str) and artifact:
return "snapshot", artifact
returned: Any = _UNSEEN
snapshot = data.get("artifact")
# 工具快照要哈希对得上、记的是同一个工具:被改过的不能拿来说这次查询没有遮罩
if isinstance(snapshot, str) and snapshot and store.intact(snapshot):
content = store.content(snapshot)
if isinstance(content, dict) and content.get("tool") == data.get("tool") and "result" in content:
returned = content["result"]
if returned is _UNSEEN:
preview = data.get("preview")
if not isinstance(preview, str):
return "unknown", None
returned = preview
if preview.startswith('"'):
# 调用工具节点的预览是把返回值再 json.dumps 一遍(文本外面套了一层引号),截断了就剥不开
try:
returned = json.loads(preview)
except ValueError:
return "unknown", None
fields = query_entry_fields(returned)
if fields:
return "snapshot", str(fields["artifact"])
# 数据源工具成功时交回的是 JSON 对象;不是对象的是一句原话,对象里没有 rows 的是报错
if isinstance(returned, str) and not returned.lstrip().startswith("{"):
return "none", None
if isinstance(returned, dict) and "rows" not in returned:
return "none", None
return "unknown", None
def _masked_run(events: Sequence[tuple[int, str, str | None, Any]], store: _Store, cited: Iterable[str],
source_masks: Mapping[str, Any]) -> bool:
"""运行里有没有哪次查询的结果可能带着遮罩列。有的话,成果节点和运行结束事件的预览可能直接带出查询结果,只给哈希。
宁严勿宽:封存范围内每一次数据源查询都要对得上一份没有遮罩的查询快照,或者确实没交回数据,才算没有。数据源
现在设了遮罩的,按工具名就知道,不必去认快照;认不出查了什么的,按带遮罩算。"""
known = [*_query_artifacts(events), *cited]
for _, etype, _, data in events:
if etype != EventType.TOOL_END or not isinstance(data, dict):
continue
tool = str(data.get("tool") or "")
if not tool.startswith(QUERY_PREFIX):
continue
if source_masks.get(tool[len(QUERY_PREFIX):]):
return True
kind, artifact = _query_returned(data, store)
if kind == "unknown":
return True
if artifact:
known.append(artifact)
return any(store.masked(a) for a in dict.fromkeys(known))
# --------------------------------------------------------------------------
# 报告文档和它引用到的工件
# --------------------------------------------------------------------------
def _report_events(events: Iterable[tuple[int, str, str | None, Any]]) -> list[dict[str, Any]]:
"""每个报告节点最后一次自查记下的文档,按第一次出现的顺序。和证据接口(api/evidence._reports)同一个认法:
报告重写过的,认最后一次。"""
latest: dict[str, dict[str, Any]] = {}
for seq, etype, node_id, data in events:
if etype == EventType.REPORT_CHECKED and node_id and isinstance(data, dict) and data.get("doc_artifact"):
latest[node_id] = {"node_id": node_id, "doc_artifact": str(data["doc_artifact"]), "seq": seq}
return list(latest.values())
def _cited_artifacts(doc: dict[str, Any]) -> list[tuple[str, str, str | None]]:
"""报告正文引用到的口径卡和查询快照:(工件 id, 种类, 来源节点),按正文顺序、去重。
看每个解析成功的引用(行内的数字、单元格,句末的 [[see:]])在文档目录里指向的条目:指标条目指向口径卡,
查询条目指向快照。整表在组装时已经展开成每格一个单元格引用,不用另看。"""
catalog = doc.get("catalog") if isinstance(doc.get("catalog"), dict) else {}
out: list[tuple[str, str, str | None]] = []
for _, unit in iter_units(doc):
if not isinstance(unit, dict):
continue
cites = [s.get("cite") for s in unit.get("segments") or [] if isinstance(s, dict)]
for cite in [*cites, *(unit.get("see") or [])]:
if not isinstance(cite, dict) or cite.get("status") != "resolved":
continue
entry = catalog.get(cite.get("alias"))
kind = _CITED_KINDS.get(entry.get("kind")) if isinstance(entry, dict) else None
if kind and isinstance(entry.get("artifact"), str) and entry["artifact"]:
owner = entry.get("node_id")
out.append((entry["artifact"], kind, str(owner) if owner else None))
return list(dict.fromkeys(out))
class _Artifacts:
"""嵌进页面的工件清单,按先后:报告文档在前,接着是它引用到的。同一份只列一次。"""
def __init__(self, store: _Store) -> None:
self.store = store
self.items: list[dict[str, Any]] = []
self._seen: set[str] = set()
def add(self, artifact: str, kind: str, node_id: str | None) -> None:
if artifact in self._seen:
return
self._seen.add(artifact)
item: dict[str, Any] = {"id": artifact, "kind": kind}
if node_id:
item["node_id"] = node_id
raw = self.store.raw(artifact)
if raw is None:
# 服务端的文件不在了:只给 id,浏览器照实说核对不了
item["missing"] = True
elif kind == "query" and self.store.masked(artifact):
item["masked"] = True
else:
# 原样嵌入,不在这里复验哈希:被改过的文件照实交出去,浏览器自己算出对不上
item["b64"] = base64.b64encode(bytes(raw)).decode("ascii")
self.items.append(item)
# --------------------------------------------------------------------------
# 事件
# --------------------------------------------------------------------------
def _shows_content(etype: str, node_id: str | None, data: Any, node_types: Mapping[str, str], store: _Store,
masked_run: bool) -> bool:
"""这条封存范围内的事件带不带规范字符串(白名单)。"""
if etype in _SHOWN_EVENTS:
return True
if etype == EventType.RUN_FINISHED:
# 运行结束事件里是成果:字段可以直接引用查询结果,运行里有带遮罩的快照时只给哈希
return not masked_run
if etype == EventType.NODE_FINISHED:
kind = node_types.get(node_id or "")
return kind in _SHOWN_NODES or (kind == NodeType.OUTPUT and not masked_run)
if etype == EventType.TOOL_END:
# 查询类:把查询快照的 id 和运行记录连起来。预览里是查询结果的前一截,数据源带遮罩的只给哈希
artifact = data.get("query_artifact") if isinstance(data, dict) else None
return isinstance(artifact, str) and bool(artifact) and not store.masked(artifact)
return False
def _clip(text: Any, limit: int = 60) -> str:
flat = re.sub(r"\s+", " ", str(text or "")).strip()
return flat if len(flat) <= limit else flat[:limit] + "…"
def _summary(etype: str, data: Any) -> str:
"""封存之后追加的事件说一句是什么。只挑不会带出数据的字段:判了几句、问的是什么、日志那一句。"""
if not isinstance(data, dict):
return ""
if etype == EventType.EVIDENCE_JUDGED:
verdicts = data.get("verdicts")
count = len(verdicts) if isinstance(verdicts, dict) else 0
model = data.get("model")
return f"按需裁判 {count} 句结论" + (f"(模型 {_clip(model, 40)})" if isinstance(model, str) and model else "")
if etype == EventType.APPROVAL_INQUIRY:
question = data.get("question")
return f"质询:{_clip(question)}" if isinstance(question, str) and question.strip() else "审批质询"
if etype == EventType.LOG and isinstance(data.get("message"), str):
return _clip(data["message"])
return ""
def _tier(events: Sequence[tuple[int, str, str | None, Any]]) -> dict[str, Any] | None:
"""出具档位:封存范围内最后一条出具判定事件里的 tier,带上序号(浏览器拿那条已核对的事件对照)。"""
for seq, etype, _, data in reversed(events):
if etype == EventType.ISSUANCE and isinstance(data, dict) and isinstance(data.get("tier"), str):
return {"value": data["tier"], "seq": seq}
return None
def _iso(value: Any) -> str | None:
if value is None:
return None
return value.isoformat() if isinstance(value, datetime) else str(value)
# --------------------------------------------------------------------------
# 构建
# --------------------------------------------------------------------------
def display_name(run: Mapping[str, Any]) -> str:
"""页面标题、附件名用的运行名:工作流名;没保存的工作流一律叫「未保存的工作流」。"""
name = str(run.get("workflow_name") or "").strip()
if not run.get("workflow_id") and name in _UNSAVED_NAMES:
return UNSAVED_NAME
return name or f"运行 {str(run.get('id') or '')[:8]}".strip()
def attachment_name(run: Mapping[str, Any]) -> str:
"""附件名「<运行名>-可验证报告.html」。文件名里不能有的字换成下划线。"""
name = _BAD_FILENAME.sub("_", display_name(run)).strip(" .")[:_NAME_MAX].strip(" .")
return f"{name or UNSAVED_NAME}{FILE_SUFFIX}"
def content_disposition(filename: str, *, run_id: str) -> str:
"""附件头:中文名按 RFC 5987 写在 filename* 里;filename 给不认 filename* 的老客户端一个纯 ASCII 的名字。"""
ascii_id = re.sub(r"[^A-Za-z0-9]", "", run_id)[:8]
return f"attachment; filename=\"agentlab-{ascii_id}-bundle.html\"; filename*=UTF-8''{quote(filename, safe='')}"
def build_bundle(
run: Mapping[str, Any],
events: Sequence[tuple[int, str, str | None, Any]],
*,
manifest_seq: int,
manifest_hash: str,
read: Reader,
exported_at: str,
legacy: bool = False,
graph: Mapping[str, Any] | None = None,
source_masks: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""一次已封存运行 → 页面里的数据(bundle-data)。
- run:运行记录的几样(id、workflow_id、workflow_name、version、version_hash、run_class、status、
started_at、finished_at)
- events:这次运行的全部事件 (seq, type, node_id, data),按数据库里 seq 的顺序。和封存核对
(runner.verify_manifest)读的是同一个顺序:seq 相同的几条,浏览器按这里的先后排,清单才算得一样
- manifest_seq / manifest_hash:封存范围(老运行是按结束事件推算的那个,legacy 为真)和封存哈希
- read:工件 id → 原字节(不复验哈希),没有就是 None
- source_masks:数据源名 → 它现在设的遮罩列(只列设了的)
"""
graph = graph if isinstance(graph, Mapping) else {}
node_types: dict[str, str] = {}
nodes: dict[str, dict[str, str]] = {}
for item in graph.get("nodes") or []:
if isinstance(item, Mapping) and item.get("id"):
nid = str(item["id"])
data = item.get("data") if isinstance(item.get("data"), Mapping) else {}
node_types[nid] = str(item.get("type") or "")
nodes[nid] = {"label": str(data.get("label") or nid), "type": node_types[nid]}
sealed = [e for e in events if e[0] <= manifest_seq]
later = [e for e in events if e[0] > manifest_seq]
store = _Store(read, source_masks or {})
artifacts = _Artifacts(store)
reports = _report_events(sealed)
cited_queries: list[str] = []
for report in reports:
doc_id, node_id = report["doc_artifact"], report["node_id"]
artifacts.add(doc_id, "report_doc", node_id)
doc = store.content(doc_id) if store.intact(doc_id) else None
# 文档要真是这次运行里这个节点写的:内容寻址只保证没被改,不保证没被张冠李戴。对不上的照实嵌进去让
# 浏览器标出来,但它的目录不可信,不顺着它去找别的工件
if not isinstance(doc, dict) or doc.get("run_id") != run.get("id") or doc.get("node_id") != node_id:
continue
for artifact, kind, owner in _cited_artifacts(doc):
artifacts.add(artifact, kind, owner)
if kind == "query":
cited_queries.append(artifact)
masked_run = _masked_run(sealed, store, cited_queries, source_masks or {})
shown_events = []
for seq, etype, node_id, data in sealed:
text = canonical_json(data)
item: dict[str, Any] = {"seq": seq, "type": etype, "node_id": node_id, "data_sha256": content_hash(text)}
if _shows_content(etype, node_id, data, node_types, store, masked_run):
item["canonical"] = text
shown_events.append(item)
return {
"schema": SCHEMA,
"meta": {
"run_id": run.get("id"), "workflow_id": run.get("workflow_id"), "workflow_name": display_name(run),
"version": run.get("version"), "version_hash": run.get("version_hash"),
"run_class": run.get("run_class"), "status": run.get("status"),
"started_at": _iso(run.get("started_at")), "finished_at": _iso(run.get("finished_at")),
"manifest_seq": manifest_seq, "manifest_hash": manifest_hash, "legacy": bool(legacy),
"tier": _tier(sealed), "exported_at": exported_at,
},
"nodes": nodes,
"events": shown_events,
"appended": [{"seq": seq, "type": etype, "node_id": node_id, "summary": _summary(etype, data)}
for seq, etype, node_id, data in later],
"reports": reports,
"artifacts": artifacts.items,
}
# --------------------------------------------------------------------------
# 页面
# --------------------------------------------------------------------------
#: 数据放在 <script type="application/json"> 里:< > & 换成转义,正文里的 </script>、<!-- 截不断数据块;
#: U+2028 / U+2029 在老的 JS 解析器里是换行,一并转义。这几个字只会出现在 JSON 的字符串里,转义后 JSON 不变
_JSON_ESCAPES = str.maketrans({"<": "\\u003c", ">": "\\u003e", "&": "\\u0026",
"\u2028": "\\u2028", "\u2029": "\\u2029"})
_PLACEHOLDER = re.compile(r"__BUNDLE_(TITLE|NAME|SEAL|RUN|FAILED|CSP|DATA|SCRIPT)__")
@lru_cache(maxsize=1)
def _assets() -> tuple[str, str]:
here = Path(__file__).parent
return (here / "template.html").read_text(encoding="utf-8"), (here / "verifier.js").read_text(encoding="utf-8")
def bundle_json(bundle: Mapping[str, Any]) -> str:
"""页面数据块里的 JSON 文本。"""
return json.dumps(bundle, ensure_ascii=False, separators=(",", ":")).translate(_JSON_ESCAPES)
def render_html(bundle: Mapping[str, Any]) -> str:
"""把数据和核对脚本填进模板:样式、脚本都内联,零外链。
内容安全策略按哈希只放行这一段核对脚本,默认什么都不许连:页面打开后不会、也不能向任何地方发请求。
工作流名、封存哈希、运行 ID 直接写进页面:邮件、即时通讯的附件预览和系统的快速查看常常不执行脚本,也要看得到
该拿什么去系统里对照。脚本执行后拿数据里的封存哈希核对,再核一遍这里写出的和它一致。失败结束的运行另写一句:
之后在系统中继续运行会重新封存,这个文件就再也对不上了。
占位符一次替换完,填进去的内容不会再被当成占位符。"""
template, script = _assets()
digest = base64.b64encode(hashlib.sha256(script.encode("utf-8")).digest()).decode("ascii")
meta = bundle.get("meta") or {}
name = str(meta.get("workflow_name") or UNSAVED_NAME)
values = {"TITLE": html.escape(f"{name} · 可验证报告"), "NAME": html.escape(name),
"SEAL": html.escape(str(meta.get("manifest_hash") or "")), "RUN": html.escape(str(meta.get("run_id") or "")),
"FAILED": "" if meta.get("status") == "failed" else " hidden",
"CSP": f"'sha256-{digest}'", "DATA": bundle_json(bundle), "SCRIPT": script}
return _PLACEHOLDER.sub(lambda m: values[m.group(1)], template)
+180
View File
@@ -0,0 +1,180 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src __BUNDLE_CSP__; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'">
<meta name="color-scheme" content="light dark">
<title>__BUNDLE_TITLE__</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff; --panel: #f5f6f8; --raise: #ffffff; --border: #dfe2e8; --fg: #16181d; --dim: #41454e; --faint: #5f6470;
--accent: #2a57c2; --ok: #1b7339; --ok-soft: #e5f4ea; --bad: #b92424; --bad-soft: #fcebeb; --na: #565b66; --na-soft: #eceef2;
--mark: #fff1b8; --code: #eff1f5; --shadow: 0 10px 32px rgba(16, 20, 28, .16);
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
--detail-w: 420px;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111318; --panel: #181b21; --raise: #1c1f26; --border: #2e323b; --fg: #e8eaee; --dim: #c1c5ce; --faint: #969ca8;
--accent: #8aaeff; --ok: #62d08c; --ok-soft: #14301f; --bad: #ff8080; --bad-soft: #3c1c1f; --na: #a9afbb; --na-soft: #262a31;
--mark: #4d4214; --code: #23262e; --shadow: 0 10px 32px rgba(0, 0, 0, .55);
}
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
/* 核对脚本一开始执行就给 html 标上 data-js:没执行(禁用脚本、附件预览)时只显示「尚未核对」那一句 */
html:not([data-js]) .js-only, html[data-js] .no-js { display: none !important; }
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.65 var(--sans); overflow-wrap: break-word; }
code, pre, .mono { font-family: var(--mono); }
.page { max-width: 920px; margin: 0 auto; padding: 28px 16px 72px; }
.brand { margin: 0; font-size: 13px; font-weight: 600; letter-spacing: .04em; color: var(--accent); }
h1 { margin: 2px 0 0; font-size: 24px; line-height: 1.35; }
.overall { display: flex; align-items: center; gap: 10px; margin: 16px 0 4px; padding: 12px 14px; border-radius: 10px;
border: 1px solid var(--border); background: var(--panel); font-size: 18px; font-weight: 650; }
.overall[data-overall="pass"] { color: var(--ok); background: var(--ok-soft); border-color: transparent; }
.overall[data-overall="fail"] { color: var(--bad); background: var(--bad-soft); border-color: transparent; }
.overall[data-overall="incomplete"] { color: var(--na); background: var(--na-soft); border-color: var(--na); }
.overall-mark { font-size: 20px; line-height: 1; }
.overall-sub { margin: 4px 2px 0; font-size: 13px; color: var(--dim); }
.seal { margin: 18px 0; padding: 14px 16px; border: 2px solid var(--accent); border-radius: 10px; }
.seal h2 { margin: 0 0 6px; font-size: 13px; font-weight: 600; color: var(--dim); }
.hash { display: block; font-size: 15px; font-weight: 600; line-height: 1.5; word-break: break-all; color: var(--fg); }
.seal p { margin: 10px 0 0; font-size: 13px; color: var(--dim); }
.seal p code { word-break: break-all; }
.seal .seal-warn { padding: 8px 10px; border-radius: 8px; background: var(--bad-soft); color: var(--bad); }
.meta { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 4px 16px; margin: 0; font-size: 13px; }
.meta dt { color: var(--faint); }
.meta dd { margin: 0; min-width: 0; color: var(--dim); overflow-wrap: anywhere; }
.meta-note { margin: 14px 0 6px; font-size: 12px; color: var(--faint); }
.block { margin-top: 36px; }
.block > h2 { margin: 0 0 10px; font-size: 18px; }
.muted { color: var(--faint); }
.bad { color: var(--bad); }
.note { margin: 6px 0 12px; font-size: 13px; color: var(--dim); }
.warn { margin: 0 0 10px; padding: 8px 10px; border-radius: 8px; background: var(--bad-soft); color: var(--bad); font-size: 13px; }
.warn[data-why="missing"], .warn[data-why="unreadable"] { background: var(--na-soft); color: var(--na); }
.report { margin: 14px 0; padding: 16px; border: 1px solid var(--border); border-radius: 10px; background: var(--raise); }
.report-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-bottom: 10px; }
.report-head h3 { margin: 0; font-size: 15px; }
.report-head .mono { font-size: 12px; }
.report-parts { flex: 1 1 100%; font-size: 12px; color: var(--dim); }
.doc { font-size: 15px; }
.doc p { margin: 8px 0; }
.doc .doc-h { margin: 18px 0 6px; line-height: 1.4; }
.doc .doc-h1 { font-size: 19px; } .doc .doc-h2 { font-size: 17px; } .doc .doc-h3 { font-size: 15px; }
.doc ul, .doc ol { margin: 8px 0; padding-left: 1.6em; }
.doc li { margin: 2px 0; }
.doc blockquote { margin: 8px 0; padding-left: 12px; border-left: 3px solid var(--accent); color: var(--dim); }
.doc pre { margin: 8px 0; padding: 10px 12px; border-radius: 8px; background: var(--code); overflow-x: auto; font-size: 13px; line-height: 1.55; }
.doc code { font-size: .92em; padding: 0 4px; border-radius: 4px; background: var(--code); }
.doc pre code { padding: 0; background: none; }
.doc hr { border: 0; border-top: 1px solid var(--border); margin: 14px 0; }
.doc .link { color: var(--accent); }
.table-wrap { max-width: 100%; margin: 8px 0; overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; }
.table-wrap table { width: 100%; border-collapse: collapse; font-size: 13px; }
.table-wrap th, .table-wrap td { padding: 5px 9px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
.table-wrap th { background: var(--panel); color: var(--dim); font-weight: 600; white-space: nowrap; }
.table-wrap tr:last-child td { border-bottom: 0; }
.table-wrap .n { text-align: right; font-variant-numeric: tabular-nums; }
.table-wrap .hit { background: var(--mark); }
.num { font: inherit; color: inherit; background: none; border: 0; margin: 0; padding: 0 1px; border-radius: 3px; cursor: pointer;
text-decoration-line: underline; text-decoration-thickness: 1.5px; text-underline-offset: 3px; }
.num[data-result="pass"] { text-decoration-style: solid; text-decoration-color: var(--ok); }
.num[data-result="fail"] { text-decoration-style: wavy; text-decoration-color: var(--bad); background: var(--bad-soft); }
.num[data-result="na"] { text-decoration-style: dotted; text-decoration-color: var(--na); }
.num:hover { background: var(--panel); }
.num:focus-visible, .close:focus-visible, .items button:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.num.is-active { background: var(--mark); }
.badge { display: inline-flex; align-items: center; gap: 3px; padding: 2px 8px; border-radius: 999px; font-size: 12px;
font-weight: 600; line-height: 1.5; white-space: nowrap; }
.badge[data-result="pass"] { color: var(--ok); background: var(--ok-soft); }
.badge[data-result="fail"] { color: var(--bad); background: var(--bad-soft); }
.badge[data-result="na"], .badge[data-result="partial"] { color: var(--na); background: var(--na-soft); }
.check { margin: 12px 0; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.check-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; padding: 10px 12px; background: var(--panel); }
.check-head h3 { margin: 0; font-size: 14px; }
.check-sum { font-size: 13px; color: var(--dim); }
.items { margin: 0; padding: 0; list-style: none; }
.items li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; padding: 7px 12px; border-top: 1px solid var(--border); font-size: 13px; }
.items .what { flex: 1 1 220px; min-width: 0; overflow-wrap: anywhere; }
.items .why { flex: 1 1 100%; color: var(--dim); }
.items button { font: inherit; color: var(--accent); background: none; border: 0; padding: 0; cursor: pointer; text-align: left; }
details > summary { padding: 8px 12px; border-top: 1px solid var(--border); font-size: 13px; color: var(--accent); cursor: pointer; }
.detail { position: fixed; top: 0; right: 0; bottom: 0; z-index: 10; width: min(var(--detail-w), 100vw); overflow-y: auto;
background: var(--raise); border-left: 1px solid var(--border); box-shadow: var(--shadow); }
.detail-head { position: sticky; top: 0; display: flex; align-items: flex-start; gap: 8px; padding: 14px 16px 10px;
background: var(--raise); border-bottom: 1px solid var(--border); }
.detail-head h2 { flex: 1; min-width: 0; margin: 0; font-size: 17px; overflow-wrap: anywhere; }
.close { flex: none; width: 30px; height: 30px; border: 1px solid var(--border); border-radius: 8px; background: var(--panel);
color: var(--dim); font: 18px/1 var(--sans); cursor: pointer; }
.detail-body { padding: 12px 16px 28px; font-size: 14px; }
.kv { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 7px 12px; margin: 0 0 12px; }
.kv dt { color: var(--faint); font-size: 13px; }
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.sql { margin: 6px 0 12px; padding: 8px 10px; border-radius: 8px; background: var(--code); font-size: 12px; line-height: 1.5;
white-space: pre-wrap; overflow-wrap: anywhere; }
.sub { margin: 14px 0 4px; font-size: 13px; color: var(--faint); font-weight: 600; }
.foot { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--faint); }
.foot p { margin: 4px 0; }
@media print {
.detail, .close, details > summary { display: none !important; }
.page, html.detail-open .page { max-width: none; margin: 0; padding: 0; }
.num { text-decoration-color: currentColor !important; background: none !important; }
}
/* 宽屏上出处面板停在右侧:打开时正文让出这一栏,表格里右对齐的数字不会被面板盖住 */
@media screen and (min-width: 761px) {
html.detail-open .page { margin-right: min(var(--detail-w), 100vw); }
}
@media (max-width: 760px) {
.detail { top: auto; left: 0; width: auto; max-height: 72vh; border-left: 0; border-top: 1px solid var(--border); border-radius: 14px 14px 0 0; }
}
@media (max-width: 520px) {
body { font-size: 14px; }
.page { padding: 20px 16px 56px; }
h1 { font-size: 20px; }
.overall { font-size: 16px; }
.hash { font-size: 13px; }
.meta { grid-template-columns: minmax(0, 1fr); gap: 0; }
.meta dt { margin-top: 6px; }
.report { padding: 12px; }
}
</style>
<script>__BUNDLE_SCRIPT__</script>
</head>
<body>
<div class="page">
<header>
<p class="brand">可验证报告</p>
<h1 id="title">__BUNDLE_NAME__</h1>
<div id="overall" class="overall" data-overall="running" role="status" aria-live="polite">
<span class="overall-mark js-only" aria-hidden="true">…</span><span id="overall-text"><span class="js-only">正在核对…</span><span class="no-js">尚未核对:需要在浏览器中打开本文件,并允许执行其中的核对脚本</span></span>
</div>
<p id="overall-sub" class="overall-sub"></p>
<section class="seal" aria-labelledby="seal-label">
<h2 id="seal-label">封存清单哈希</h2>
<code id="seal-hash" class="hash">__BUNDLE_SEAL__</code>
<p id="seal-note">这个文件只证明内部一致:报告里的数字能追到口径卡或查询快照,文档、快照和运行记录彼此对得上。它不能单独证明这些记录出自系统、未被整体替换。核对真伪:在系统中打开这次运行(运行 ID <code class="run-id">__BUNDLE_RUN__</code>),确认「凭证详情」里的清单哈希与上面一致,并点「核对」确认事件记录与封存时一致(接口 GET /api/runs/<span class="run-id">__BUNDLE_RUN__</span>/verify)。工作流名、版本、起止时间和节点名称取自运行记录,不在封存范围内,需要在系统中一并对照。</p>
<p id="seal-failed" class="seal-warn"__BUNDLE_FAILED__>这次运行以失败结束:之后若在系统中继续运行,系统会重新封存,本文件的封存哈希将无法再与系统对照。</p>
</section>
<dl id="meta" class="meta"></dl>
<p id="record-note" class="meta-note" hidden></p>
<dl id="record" class="meta"></dl>
</header>
<main class="js-only">
<section id="reports" class="block" aria-labelledby="reports-title"><h2 id="reports-title">报告</h2></section>
<section id="checks" class="block" aria-labelledby="checks-title"><h2 id="checks-title">核对明细</h2></section>
<section id="appended" class="block" aria-labelledby="appended-title" hidden><h2 id="appended-title">封存之后追加的事件</h2></section>
</main>
<footer id="foot" class="foot js-only"></footer>
</div>
<aside id="detail" class="detail" role="dialog" aria-modal="false" aria-labelledby="detail-title" hidden>
<div class="detail-head"><h2 id="detail-title"></h2><button type="button" id="detail-close" class="close" aria-label="关闭">×</button></div>
<div id="detail-body" class="detail-body"></div>
</aside>
<script type="application/json" id="bundle-data">__BUNDLE_DATA__</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+18
View File
@@ -97,6 +97,24 @@ def load(artifact_id: str) -> Any | None:
return json.loads(text)
def read_bytes(artifact_id: str, *, verify: bool = True) -> bytes | None:
"""按 id 取回工件文件的原始字节。文件本身就是 put_json 写下的规范 JSON,字节原样,不解析、不重排。
可验证报告要把工件按原字节嵌进页面,让拿到文件的人自己重算哈希,所以不能像 load 那样解析了再交出去。
默认同样复验哈希、对不上就抛 ValueError(和 load 一样的承诺);verify=False 只给要把字节原样交给别人
去核的调用方——被改过的工件照实交出去,核的那一方就会看到对不上。id 不对或者文件不在了返回 None。
"""
if not artifact_id or not artifact_id.isalnum():
return None
path = _path_of(artifact_id)
if not path.exists():
return None
data = path.read_bytes()
if verify and content_hash(data) != artifact_id:
raise ValueError(f"工件 {artifact_id[:12]}… 内容与哈希不符,疑似被篡改")
return data
def manifest_hash(rows: list[tuple[int, str, str | None, dict[str, Any]]]) -> str:
"""把一次运行的全部事件压成一个清单哈希。
+200
View File
@@ -0,0 +1,200 @@
"""可验证报告的虚构样本页:给 scripts/check-bundle.mjs 在浏览器里核对(pytest 不执行页面上的核对脚本)。
python -m tests.bundle_samples <目录>
在目录里写下几份页面(<名字>.html)。每份都是 build_bundle + render_html 真实生成的,封存哈希按 runner 的算法
(artifact_store.manifest_hash)对事件真实算出,报告文档用 compose_doc 按证据目录真实组装(标题、段落、列表、
表格、粗体、斜体,数字和取值的引用都和真实运行一样)。check-bundle 断网打开它们,逐项断言核对结果和原因,再在
这几份上做篡改。这样遮罩快照、多份报告、早期运行、服务端缺文件这些分支,不论真数据里有没有,都有人在浏览器里核过。
数据全是虚构的:门店、会员、金额都是编的。
- base:一份报告,引用口径卡、门店流水(没有遮罩)、会员消费(有遮罩)各一处,一个没有出处的数,一张门店流水整表
(数字列右对齐,数字与取值共 21 个,超过折叠的条数);一条封存之后追加的按需裁判
- two-reports:另有一份月报,两份报告各自的引用链
- legacy:早期运行,封存范围按结束事件推算(运行结束事件在范围外,运行结果无法核对)
- failed:失败结束的运行(没有出具判定),封存说明多一句「继续运行会重新封存」
- degraded:两条出具判定,先完整出具、后降档出具,认最后一条
- missing-doc / missing-card:导出时服务端已没有报告文档 / 口径卡的文件
- nan:运行开始事件和门店流水快照里有 json.dumps 写出的裸 NaN(快照里那一列报告没有引用)
- stray:报告自查记下的文档是另一次运行写的
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
from app.bundle.build import build_bundle, render_html
from app.core.artifact_store import canonical_json, content_hash, manifest_hash
from app.engine.evidence import build_catalog, compose_doc
RUN_ID = "5a3c0f4b9d2e4e1f8b7a6c5d4e3f2a1b"
RUN = {"id": RUN_ID, "workflow_id": "wf-sample-weekly", "workflow_name": "门店周报", "version": 3,
"version_hash": "ab" * 32, "run_class": "formal", "status": "succeeded",
"started_at": "2026-09-07T01:00:00+00:00", "finished_at": "2026-09-07T01:02:03+00:00"}
GRAPH = {"nodes": [{"id": n, "type": t, "data": {"label": label}} for n, t, label in [
("start", "input", "开始"), ("fetch", "tool", "门店流水"), ("members", "tool", "会员消费"),
("card", "metrics", "周报口径"), ("write", "report", "写周报"), ("monthly", "report", "写月报"),
("out", "output", "成果")]]}
EXPORTED_AT = "2026-10-05T08:00:00+00:00"
STORES = [["青石店", 1280.5], ["白沙店", "960.00"], ["临川店", 732], ["望江店", 1105.25], ["枫桥店", 418],
["云岭店", 2210], ["石桥店", 655.5], ["柳湾店", 980]]
CRM = {"columns": ["name", "phone", "spend"], "column_types": {"name": "text", "phone": "text", "spend": "number"},
"rows": [["林一", "000-0001", 320.0], ["周二", "000-0002", 188.5]], "source": "crm",
"sql": "SELECT name, phone, spend FROM members ORDER BY id", "truncated": False, "mask_columns": ["phone"]}
CARD = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2",
"metrics": [{"id": "gmv", "name": "销售额", "value": 8381.25, "unit": "元", "decimals": 2, "format": "thousands"}]}
WEEKLY = ("## 门店周报\n\n本周销售额 [[m:gmv]],*按发生周统计*。青石店流水 [[v:Q1.r0.amount]],白沙店 [[v:Q1.r1.amount]],"
"**会员消费** [[v:Q2.r0.spend]],另有 42 笔退款待入账。[[see:Q1]]\n\n- 云岭店流水最高\n- 枫桥店流水最低\n\n"
"[[table:Q1 cols=store,amount rows=0-7]]")
MONTHLY = "## 门店月报\n\n本月销售额 [[m:gmv]],青石店流水 [[v:Q1.r0.amount]]。[[see:Q1]]"
def stored(obj: Any) -> tuple[str, bytes]:
"""照工件库的样子落一份:规范 JSON 的字节,id 是它的 sha256。"""
raw = canonical_json(obj).encode()
return content_hash(raw), raw
def shop(nan: bool = False) -> dict[str, Any]:
"""门店流水的查询快照。ratio 那一列报告不引用;nan=True 时第一行是浮点 NaN(json.dumps 写成裸的 NaN)。"""
rows = [[name, amount, float("nan") if nan and i == 0 else round(0.05 * (i + 1), 2)]
for i, (name, amount) in enumerate(STORES)]
return {"columns": ["store", "amount", "ratio"],
"column_types": {"store": "text", "amount": "number", "ratio": "number"}, "rows": rows, "source": "shop",
"sql": "SELECT store, amount, ratio FROM orders ORDER BY id", "truncated": False}
class Sample:
"""一次虚构的已封存运行:工件(原字节)、证据目录、报告文档、事件。"""
def __init__(self, *, nan: bool = False) -> None:
self.files: dict[str, bytes] = {}
self.shop_id = self.put(shop(nan))
self.crm_id = self.put(CRM)
self.card_id = self.put(CARD)
self.ledger = [
{"kind": "query", "node_id": "fetch", "exec": 1, "artifact": self.shop_id, "tool": "db_query__shop",
"source": "shop", "columns": ["store", "amount", "ratio"], "rows": len(STORES), "truncated": False},
{"kind": "query", "node_id": "members", "exec": 1, "artifact": self.crm_id, "tool": "db_query__crm",
"source": "crm", "columns": CRM["columns"], "rows": len(CRM["rows"]), "truncated": False},
{"kind": "metric_set", "node_id": "card", "artifact": self.card_id},
]
self.catalog = build_catalog(nodes={"card": CARD}, ledger=self.ledger)
def put(self, obj: Any) -> str:
artifact, raw = stored(obj)
self.files[artifact] = raw
return artifact
def load(self, artifact: str) -> Any:
raw = self.files.get(artifact)
return json.loads(raw) if raw is not None else None
def doc(self, text: str, node_id: str, run_id: str = RUN_ID) -> str:
return self.put(compose_doc(text, self.catalog, node_id=node_id, run_id=run_id, loader=self.load))
def events(self, reports: dict[str, str], *, ending: str = "finished", tiers: tuple[str, ...] = ("formal",),
nan: bool = False) -> tuple[list[tuple[int, str, str | None, Any]], int]:
"""(全部事件, 封存范围)。封存范围到结束事件为止,后面再追加一条按需裁判。"""
rows: list[tuple[int, str, str | None, Any]] = []
def add(etype: str, node_id: str | None, data: dict[str, Any]) -> None:
rows.append((len(rows) + 1, etype, node_id, data))
add("run.started", None, {"nodes": len(GRAPH["nodes"]), "resumed": False, "memory_scope": "default",
"collection": "default", "approval_default": None, "tool_trust": "formal",
"agent_limits": None, "data_versions": None, "replay_protocol": 2,
**({"sample_rate": float("nan")} if nan else {})})
add("node.started", "start", {"node_type": "input", "label": "开始"})
add("node.finished", "start", {"duration_ms": 1, "attempt": 1, "preview": {"week": "2026-W37"}})
add("node.started", "fetch", {"node_type": "tool", "label": "门店流水"})
add("tool.end", "fetch", {"tool": "db_query__shop", "duration_ms": 12, "query_artifact": self.shop_id,
"preview": json.dumps({"columns": ["store", "amount"], "rows": STORES[:2]},
ensure_ascii=False)})
add("node.finished", "fetch", {"duration_ms": 14, "attempt": 1, "preview": "{\"rows\": …}",
"evidence": [self.ledger[0]]})
add("tool.end", "members", {"tool": "db_query__crm", "duration_ms": 9, "query_artifact": self.crm_id,
"preview": json.dumps(CRM["rows"][:1], ensure_ascii=False)})
add("node.finished", "members", {"duration_ms": 10, "attempt": 1, "evidence": [self.ledger[1]]})
add("node.finished", "card", {"duration_ms": 3, "attempt": 1, "preview": {"kind": "metric_set"},
"evidence": [self.ledger[2]]})
for node_id, doc_id in reports.items():
add("llm.end", node_id, {"model": "mock", "output": "(写作草稿)"})
add("report.checked", node_id, {"doc_artifact": doc_id, "ok": True})
add("node.finished", node_id, {"duration_ms": 20, "attempt": 1,
"preview": {"text": "(报告正文)", "doc_artifact": doc_id}})
if ending == "finished":
for tier in tiers:
add("issuance", "out", {"tier": tier, "mode": "citations"})
add("node.finished", "out", {"duration_ms": 2, "attempt": 1, "preview": {"周报": "(报告正文)"}})
add("run.finished", None, {"output": {"周报": "(报告正文)"}})
else:
add("node.failed", "out", {"error": "出口复核没有通过"})
add("run.failed", None, {"error": "出口复核没有通过"})
bound = len(rows)
add("evidence.judged", next(iter(reports), None),
{"verdicts": {"u1": {"verdict": "supported"}}, "model": "judge-x", "post_seal": True})
return rows, bound
def render(sample: Sample, events: list[tuple[int, str, str | None, Any]], bound: int, *, run: dict[str, Any] | None = None,
legacy: bool = False, drop: tuple[str, ...] = ()) -> str:
"""真实封存哈希的页面。drop:导出时服务端已没有文件的工件。"""
sealed = [e for e in events if e[0] <= bound]
files = {k: v for k, v in sample.files.items() if k not in drop}
bundle = build_bundle(run or RUN, events, manifest_seq=bound, manifest_hash=manifest_hash(sealed), read=files.get,
exported_at=EXPORTED_AT, legacy=legacy, graph=GRAPH, source_masks={})
return render_html(bundle)
def pages() -> dict[str, str]:
"""{页面名: HTML}"""
out: dict[str, str] = {}
s = Sample()
weekly = s.doc(WEEKLY, "write")
events, bound = s.events({"write": weekly})
out["base"] = render(s, events, bound)
out["missing-doc"] = render(s, events, bound, drop=(weekly,))
out["missing-card"] = render(s, events, bound, drop=(s.card_id,))
# 早期运行:运行结束事件落库之前封存,范围到它前一条为止(runner.verify_manifest 的老口径)
finished = next(seq for seq, etype, _, _ in events if etype == "run.finished")
out["legacy"] = render(s, events, finished - 1, legacy=True)
monthly = s.doc(MONTHLY, "monthly")
events, bound = s.events({"write": weekly, "monthly": monthly})
out["two-reports"] = render(s, events, bound)
events, bound = s.events({"write": weekly}, ending="failed")
out["failed"] = render(s, events, bound, run={**RUN, "status": "failed"})
events, bound = s.events({"write": weekly}, tiers=("formal", "degraded"))
out["degraded"] = render(s, events, bound)
stray = s.doc(WEEKLY, "write", run_id="another-run-0000")
events, bound = s.events({"write": stray})
out["stray"] = render(s, events, bound)
n = Sample(nan=True)
events, bound = n.events({"write": n.doc(WEEKLY, "write")}, nan=True)
out["nan"] = render(n, events, bound)
return out
def main(argv: list[str]) -> int:
if len(argv) != 2:
print("用法:python -m tests.bundle_samples <目录>", file=sys.stderr)
return 2
target = Path(argv[1])
target.mkdir(parents=True, exist_ok=True)
for name, page in pages().items():
(target / f"{name}.html").write_text(page, encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+374
View File
@@ -0,0 +1,374 @@
"""可验证报告的接口:GET /api/runs/{run_id}/bundle.html。
照证据测试的夹具真跑一次:两个虚构的 SQLite 数据源(门店流水没设遮罩,会员库遮了 phone 列),口径卡、
报告引用两份快照里的格子和一个指标。核对:
- 只给已封存且封存核对一致的运行:没封存、封存后被改过都回 409;
- 导出的数据里,事件行按浏览器的拼法重算等于运行记下的 manifest_hash;
- 遮罩库的快照只有 id 没有内容,它的查询事件只有哈希;运行之后才设的遮罩同样生效;
- 附件名是「<运行名>-可验证报告.html」,中文名按 RFC 5987 编码;
- 只读:导出前后运行记录、事件一条不多一条不少。
另外核对工件库新加的 read_bytes:原样取回文件字节。
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
import re
import sqlite3
from urllib.parse import quote
import pytest
from httpx import ASGITransport, AsyncClient
from langchain_core.messages import AIMessage
from sqlalchemy import func, select
from app.core.artifact_store import canonical_json, content_hash, put_json, read_bytes
from app.core.config import settings
from app.data.engine import engines
from app.db.base import SessionLocal
from app.db.models import DataSource, Run, RunEvent
from app.engine.runner import run_manager
from app.main import app
SHOP_SQL = "SELECT id, store, amount FROM orders ORDER BY id"
CRM_SQL = "SELECT id, name, phone, spend FROM members ORDER BY id"
TEXT = ("## 门店周报\n\n青石店本周流水 [[v:Q1.r0.amount]],会员消费 [[v:Q2.r1.spend]],"
"口径卡销售额 [[m:gmv]]。[[see:Q1]]")
@pytest.fixture(autouse=True)
async def engine_up():
await run_manager.setup()
yield
await run_manager.shutdown()
@pytest.fixture
async def client():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def set_source(name, path, options=None):
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one_or_none()
if row is None:
row = DataSource(name=name, kind="sqlite", database=str(path), readonly=True, enabled=True)
session.add(row)
row.database, row.kind, row.enabled, row.readonly = str(path), "sqlite", True, True
row.options = dict(options or {})
await session.commit()
return row.id
@pytest.fixture(autouse=True)
async def sources(tmp_path):
shop, crm = tmp_path / "shop.db", tmp_path / "crm.db"
db = sqlite3.connect(shop)
db.executescript("CREATE TABLE orders (id INTEGER PRIMARY KEY, store TEXT, amount REAL);")
db.executemany("INSERT INTO orders VALUES (?, ?, ?)", [(1, "青石店", 1280.5), (2, "白沙店", 960.0)])
db.commit()
db.close()
db = sqlite3.connect(crm)
db.executescript("CREATE TABLE members (id INTEGER PRIMARY KEY, name TEXT, phone TEXT, spend REAL);")
db.executemany("INSERT INTO members VALUES (?, ?, ?, ?)", [(1, "林一", "000-0001", 320.0),
(2, "周二", "000-0002", 188.5)])
db.commit()
db.close()
ids = [await set_source("bundle_shop", shop), await set_source("bundle_crm", crm, {"mask_columns": ["phone"]})]
for source_id in ids:
await engines.invalidate(source_id)
yield
for source_id in ids:
await engines.invalidate(source_id)
def node(nid, ntype, **config):
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
def weekly(text_nodes=()):
nodes = [
node("start", "input"),
node("fetch", "tool", tool="db_query__bundle_shop", args={"sql": SHOP_SQL}),
node("members", "tool", tool="db_query__bundle_crm", args={"sql": CRM_SQL}),
node("card", "metrics", caliber="周报口径", caliber_version="v2", metrics=[
{"id": "gmv", "name": "销售额", "unit": "元", "decimals": 1, "format": "thousands",
"expression": "cell(nodes.fetch, 0, 'amount')"}]),
node("write", "report", instructions="写周报", on_violation="flag"),
*text_nodes,
node("out", "output", fields=[{"name": "周报", "value": "{{ nodes.write.text }}"}]),
]
return {"nodes": nodes, "edges": [{"source": a["id"], "target": b["id"]} for a, b in zip(nodes, nodes[1:])]}
def script(monkeypatch, text=TEXT):
from app.providers import mock_model
monkeypatch.setattr(mock_model.MockChatModel, "_decide", lambda self, messages: AIMessage(content=text))
async def wait(run_id: str) -> Run:
for _ in range(400):
await asyncio.sleep(0.05)
async with SessionLocal() as session:
row = await session.get(Run, run_id)
if row.status in ("succeeded", "failed") and row.manifest_seq is not None:
return row
raise AssertionError(f"run {run_id} 没跑完:{row.status}")
async def sealed_run(monkeypatch, text=TEXT) -> Run:
script(monkeypatch, text)
row = await wait((await run_manager.start(graph=weekly(), input_payload={})).id)
assert row.status == "succeeded", row.error
return row
def data_of(html: str) -> dict:
m = re.search(r'<script type="application/json" id="bundle-data">(.*?)</script>', html, re.S)
assert m, "页面里没有数据块"
return json.loads(m.group(1))
async def query_artifact(run_id: str, node_id: str) -> str:
async with SessionLocal() as session:
[end] = (await session.execute(select(RunEvent).where(
RunEvent.run_id == run_id, RunEvent.type == "tool.end", RunEvent.node_id == node_id))).scalars()
return end.data["query_artifact"]
def file_of(artifact: str):
return settings.data_dir / "artifacts" / artifact[:2] / f"{artifact}.json"
# --------------------------------------------------------------------------
# 导出
# --------------------------------------------------------------------------
async def test_export_rebuilds_the_runners_manifest(client, monkeypatch):
row = await sealed_run(monkeypatch)
resp = await client.get(f"/api/runs/{row.id}/bundle.html")
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith("text/html")
bundle = data_of(resp.text)
meta = bundle["meta"]
assert meta["run_id"] == row.id and meta["manifest_hash"] == row.manifest_hash
assert meta["manifest_seq"] == row.manifest_seq and meta["legacy"] is False
lines = [f"{e['seq']}|{e['type']}|{e['node_id'] or ''}|{e['data_sha256']}"
for e in sorted(bundle["events"], key=lambda e: e["seq"])]
assert hashlib.sha256("\n".join(lines).encode()).hexdigest() == row.manifest_hash
assert max(e["seq"] for e in bundle["events"]) == row.manifest_seq
async with SessionLocal() as session:
rows = {r.seq: r for r in (await session.execute(
select(RunEvent).where(RunEvent.run_id == row.id))).scalars()}
for item in bundle["events"]:
if "canonical" in item:
assert item["canonical"] == canonical_json(rows[item["seq"]].data)
assert content_hash(item["canonical"]) == item["data_sha256"]
types = {e["type"] for e in bundle["events"] if "canonical" in e}
assert {"run.started", "report.checked", "tool.end", "node.finished"} <= types
assert not types & {"llm.start", "llm.end", "node.started", "tool.start", "log"}
async def test_the_masked_source_ships_only_hashes(client, monkeypatch):
row = await sealed_run(monkeypatch)
shop, crm = await query_artifact(row.id, "fetch"), await query_artifact(row.id, "members")
bundle = data_of((await client.get(f"/api/runs/{row.id}/bundle.html")).text)
by_id = {a["id"]: a for a in bundle["artifacts"]}
assert by_id[crm] == {"id": crm, "kind": "query", "node_id": "members", "masked": True}
assert base64.b64decode(by_id[shop]["b64"]) == file_of(shop).read_bytes()
report = next(a for a in bundle["artifacts"] if a["kind"] == "report_doc")
assert json.loads(base64.b64decode(report["b64"]))["run_id"] == row.id
assert {a["kind"] for a in bundle["artifacts"]} == {"report_doc", "metric_set", "query"}
ends = {e["node_id"]: e for e in bundle["events"] if e["type"] == "tool.end"}
assert "canonical" in ends["fetch"] and "canonical" not in ends["members"]
# 运行里有带遮罩的快照:成果节点、运行结束事件的预览可能带出查询结果,只给哈希
out = next(e for e in bundle["events"] if e["type"] == "node.finished" and e["node_id"] == "out")
finished = next(e for e in bundle["events"] if e["type"] == "run.finished")
assert "canonical" not in out and "canonical" not in finished
assert "000-0001" not in json.dumps(bundle, ensure_ascii=False)
async def test_the_page_is_named_after_the_run(client, monkeypatch):
row = await sealed_run(monkeypatch)
resp = await client.get(f"/api/runs/{row.id}/bundle.html")
header = resp.headers["content-disposition"]
assert header.startswith("attachment;")
assert f"filename*=UTF-8''{quote('未保存的工作流-可验证报告.html', safe='')}" in header
assert resp.headers.get("cache-control") == "no-store"
async with SessionLocal() as session:
run = await session.get(Run, row.id)
run.workflow_name = "门店周报"
await session.commit()
header = (await client.get(f"/api/runs/{row.id}/bundle.html")).headers["content-disposition"]
assert f"filename*=UTF-8''{quote('门店周报-可验证报告.html', safe='')}" in header
async def set_masks(name: str, columns: list[str]) -> None:
"""只改数据源的遮罩列(options.mask_columns),别的不动。"""
async with SessionLocal() as session:
row = (await session.execute(select(DataSource).where(DataSource.name == name))).scalar_one()
row.options = {**(row.options or {}), "mask_columns": columns}
await session.commit()
source_id = row.id
await engines.invalidate(source_id)
async def test_a_mask_set_after_the_run_still_applies(client, monkeypatch):
"""运行之后才给数据源设遮罩:快照里没记 mask_columns,导出时照样按数据源现在的遮罩挡(和证据面板一个道理)。"""
await set_masks("bundle_crm", [])
row = await sealed_run(monkeypatch)
shop = await query_artifact(row.id, "fetch")
def shown(bundle) -> dict[str, bool]:
events = {(e["type"], e["node_id"]): "canonical" in e for e in bundle["events"]}
return {"fetch": events[("tool.end", "fetch")], "members": events[("tool.end", "members")],
"out": events[("node.finished", "out")], "finished": events[("run.finished", None)]}
# 对照:查询时、导出时都没有遮罩,快照随文件附带,成果和运行结束事件带规范字符串
before = data_of((await client.get(f"/api/runs/{row.id}/bundle.html")).text)
assert all("b64" in a for a in before["artifacts"])
assert shown(before) == {"fetch": True, "members": True, "out": True, "finished": True}
await set_masks("bundle_shop", ["amount"])
after = data_of((await client.get(f"/api/runs/{row.id}/bundle.html")).text)
assert next(a for a in after["artifacts"] if a["id"] == shop) == {
"id": shop, "kind": "query", "node_id": "fetch", "masked": True}
assert shown(after) == {"fetch": False, "members": True, "out": False, "finished": False}
async def test_report_text_cannot_close_the_data_block(client, monkeypatch):
row = await sealed_run(monkeypatch, text="结论见下:</script><script>alert(1)</script> 流水 [[v:Q1.r0.amount]]。")
html = (await client.get(f"/api/runs/{row.id}/bundle.html")).text
assert html.count("</script>") == 2
bundle = data_of(html)
write = next(e for e in bundle["events"] if e["type"] == "node.finished" and e["node_id"] == "write")
assert "</script><script>alert(1)</script>" in write["canonical"]
async def test_events_after_the_seal_are_listed_apart(client, monkeypatch):
row = await sealed_run(monkeypatch)
async with SessionLocal() as session:
session.add(RunEvent(run_id=row.id, seq=row.manifest_seq + 3, type="evidence.judged", node_id="write",
ts=0.0, data={"verdicts": {"u1": {"verdict": "supported"}}, "model": "judge-x",
"post_seal": True}))
await session.commit()
resp = await client.get(f"/api/runs/{row.id}/bundle.html")
assert resp.status_code == 200, resp.text
bundle = data_of(resp.text)
assert max(e["seq"] for e in bundle["events"]) == row.manifest_seq
[later] = bundle["appended"]
assert later["seq"] == row.manifest_seq + 3 and later["type"] == "evidence.judged"
# --------------------------------------------------------------------------
# 拒绝、只读
# --------------------------------------------------------------------------
async def test_an_unsealed_run_is_refused(client):
async with SessionLocal() as session:
run = Run(workflow_name="门店周报", status="running", graph={"nodes": [], "edges": []})
session.add(run)
await session.commit()
run_id = run.id
resp = await client.get(f"/api/runs/{run_id}/bundle.html")
assert resp.status_code == 409
assert resp.json() == {"detail": "运行尚未结束封存,结束后才能导出", "code": "bundle_unsealed"}
@pytest.mark.parametrize("status", ["running", "interrupted", "queued"])
async def test_a_run_going_again_on_an_old_seal_is_refused(client, monkeypatch, status):
"""失败后接着跑的运行留着上一次的封存,跑完会重新封存、换掉清单哈希;早年的运行停在审批时也封存过。
这时导出去的文件,封存哈希和系统里之后记的对不上,页面上「去系统里对照」那一步就作废了:一样回 409。"""
row = await sealed_run(monkeypatch)
async with SessionLocal() as session:
run = await session.get(Run, row.id)
run.status = status
await session.commit()
assert (await client.get(f"/api/runs/{row.id}/verify")).json()["ok"] is True
resp = await client.get(f"/api/runs/{row.id}/bundle.html")
assert resp.status_code == 409
assert resp.json() == {"detail": "运行尚未结束封存,结束后才能导出", "code": "bundle_unsealed"}
async def test_a_failed_run_is_exportable(client, monkeypatch):
"""失败、取消的运行同样封存了:报告已经写出来的,照样导得出去。"""
row = await sealed_run(monkeypatch)
async with SessionLocal() as session:
run = await session.get(Run, row.id)
run.status = "failed"
await session.commit()
resp = await client.get(f"/api/runs/{row.id}/bundle.html")
assert resp.status_code == 200, resp.text
assert data_of(resp.text)["meta"]["status"] == "failed"
async def test_a_broken_seal_is_refused(client, monkeypatch):
row = await sealed_run(monkeypatch)
async with SessionLocal() as session:
event = (await session.execute(select(RunEvent).where(
RunEvent.run_id == row.id, RunEvent.type == "report.checked"))).scalar_one()
event.data = {**event.data, "ok": not event.data.get("ok")}
await session.commit()
resp = await client.get(f"/api/runs/{row.id}/bundle.html")
assert resp.status_code == 409
# 原样出现在记录页的错误提示里:一句说清,不叠冒号、不把封存核对的整句结论再拼进来
assert resp.json() == {"detail": "封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。"
"点「核对」可查看核对结果", "code": "bundle_seal_broken"}
async def test_an_unknown_run_is_404(client):
resp = await client.get("/api/runs/nope/bundle.html")
assert resp.status_code == 404
async def test_exporting_appends_nothing(client, monkeypatch):
row = await sealed_run(monkeypatch)
async def snapshot():
async with SessionLocal() as session:
run = await session.get(Run, row.id)
count = (await session.execute(
select(func.count()).select_from(RunEvent).where(RunEvent.run_id == row.id))).scalar_one()
return run.last_seq, count, run.manifest_hash, run.manifest_seq, run.status
before = await snapshot()
for _ in range(2):
assert (await client.get(f"/api/runs/{row.id}/bundle.html")).status_code == 200
assert await snapshot() == before
assert (await client.get(f"/api/runs/{row.id}/verify")).json()["ok"] is True
# --------------------------------------------------------------------------
# 工件库:read_bytes
# --------------------------------------------------------------------------
async def test_read_bytes_returns_the_file_as_stored():
artifact = await put_json({"门店": "青石店", "流水": 1280.5}, kind="test")
assert read_bytes(artifact) == file_of(artifact).read_bytes()
assert read_bytes(artifact) == canonical_json({"门店": "青石店", "流水": 1280.5}).encode()
assert read_bytes("0" * 64) is None and read_bytes("../etc/passwd") is None and read_bytes("") is None
async def test_read_bytes_checks_the_hash_unless_told_not_to():
artifact = await put_json({"门店": "白沙店", "流水": 960.0}, kind="test")
path = file_of(artifact)
original = path.read_bytes()
path.write_bytes(original.replace(b"960.0", b"999.0"))
try:
with pytest.raises(ValueError):
read_bytes(artifact)
assert read_bytes(artifact, verify=False) == original.replace(b"960.0", b"999.0")
finally:
path.write_bytes(original)
+498
View File
@@ -0,0 +1,498 @@
"""可验证报告的构建(bundle/build.py):纯函数,不碰数据库。
拿一份手搭的已封存运行(事件、报告文档、口径卡、两份查询快照,其中一份带遮罩列)核对:
- 每条封存范围内的事件都带 data_sha256,按浏览器的拼法(seq|type|node_id|data_sha256 按 seq 排、换行连接)
重算出来的清单哈希等于 runner 记下的那个;
- 只有白名单里的事件带 canonical(逐字等于 canonical_json(data)),其余只给哈希;运行里有带遮罩的快照时,
成果节点和运行结束事件的预览可能带出查询结果,也只给哈希。认不出快照的查询(升级前的运行从工具快照里认,
协作团队成员的查询认不出)按带遮罩算,查询失败的原话不算;
- 报告文档、它引用到的口径卡和查询快照按原字节嵌入;带遮罩的快照只给 id;没被引用的工件不嵌;
- 封存之后追加的事件单列,不进清单;
- 页面零外链,数据里的 </script> 截不断数据块;封存哈希、运行 ID、工作流名不执行脚本也写在页面上;
- scripts/check-bundle.mjs 在浏览器里打开的样本页(tests/bundle_samples.py)封存哈希是真算的、形状如预期。
"""
from __future__ import annotations
import base64
import hashlib
import json
import re
from urllib.parse import quote
import pytest
from app.bundle.build import (
SCHEMA, attachment_name, build_bundle, bundle_json, content_disposition, display_name, render_html,
)
from app.core.artifact_store import canonical_json, content_hash, manifest_hash
def stored(obj) -> tuple[str, bytes]:
"""照工件库的样子落一份:规范 JSON 的字节,id 是它的 sha256。"""
raw = canonical_json(obj).encode()
return content_hash(raw), raw
SNAP_OPEN = {"columns": ["store", "amount"], "rows": [["青石店", 1280.5], ["白沙店", "960.00"]],
"column_types": {"store": "text", "amount": "number"}, "source": "shop",
"sql": "SELECT store, amount FROM orders", "truncated": False}
SNAP_MASKED = {"columns": ["name", "phone", "spend"], "rows": [["林一", "000-0001", 320.0]],
"source": "crm", "sql": "SELECT name, phone, spend FROM members", "mask_columns": ["phone"]}
SNAP_UNCITED = {"columns": ["n"], "rows": [[7]], "source": "shop", "sql": "SELECT 7 AS n"}
RETRIEVAL = {"hits": [{"content": "退款按发生周统计"}]}
OPEN_ID, OPEN_RAW = stored(SNAP_OPEN)
MASKED_ID, MASKED_RAW = stored(SNAP_MASKED)
UNCITED_ID, UNCITED_RAW = stored(SNAP_UNCITED)
RETRIEVAL_ID, RETRIEVAL_RAW = stored(RETRIEVAL)
CARD = {"kind": "metric_set", "caliber": "周报口径", "caliber_version": "v2",
"metrics": [{"id": "gmv", "name": "销售额", "value": 1280.5, "unit": "元", "decimals": 1}]}
CARD_ID, CARD_RAW = stored(CARD)
def cite(alias, kind, value, **locator):
return {"alias": alias, "kind": kind, "locator": locator, "role": "value", "status": "resolved",
"value": value, "rendered": str(value)}
def doc_of(run_id="r1", node_id="write", extra_catalog=None):
return {
"schema": "agentlab.report/1", "run_id": run_id, "node_id": node_id, "markdown": "…",
"catalog": {
"m:gmv": {"alias": "m:gmv", "kind": "metric", "artifact": CARD_ID, "node_id": "card",
"locator": {"metric": "gmv"}, "value": 1280.5},
"Q1": {"alias": "Q1", "kind": "query", "artifact": OPEN_ID, "node_id": "fetch"},
"Q2": {"alias": "Q2", "kind": "query", "artifact": MASKED_ID, "node_id": "members"},
"Q3": {"alias": "Q3", "kind": "query", "artifact": UNCITED_ID, "node_id": "fetch"},
"K1": {"alias": "K1", "kind": "retrieval", "artifact": RETRIEVAL_ID, "node_id": "docs"},
"i:week": {"alias": "i:week", "kind": "input", "value": "2026-W37", "locator": {"field": "week"}},
**(extra_catalog or {}),
},
"blocks": [{"id": "b0", "type": "paragraph", "units": [{
"id": "u0", "kind": "claim", "cites": ["m:gmv", "Q1", "Q2", "K1"],
"see": [{"alias": "K1", "kind": "retrieval", "role": "support", "status": "resolved"}],
"segments": [
{"id": "s0", "kind": "number", "text": "1,280.5元", "state": "deterministic",
"cite": cite("m:gmv", "metric", 1280.5, metric="gmv")},
{"id": "s1", "kind": "number", "text": "960", "state": "deterministic",
"cite": cite("Q1", "cell", 960, row=1, column="amount")},
{"id": "s2", "kind": "number", "text": "320", "state": "deterministic",
"cite": cite("Q2", "cell", 320.0, row=0, column="spend")},
{"id": "s3", "kind": "number", "text": "42", "state": "none", "issue": "uncited_number"},
]}]}],
}
DOC = doc_of()
DOC_ID, DOC_RAW = stored(DOC)
STORE = {DOC_ID: DOC_RAW, CARD_ID: CARD_RAW, OPEN_ID: OPEN_RAW, MASKED_ID: MASKED_RAW,
UNCITED_ID: UNCITED_RAW, RETRIEVAL_ID: RETRIEVAL_RAW}
RUN = {"id": "r1", "workflow_id": "wf1", "workflow_name": "门店周报", "version": 3, "version_hash": "ab" * 32,
"run_class": "formal", "status": "succeeded",
"started_at": "2026-09-07T01:00:00+00:00", "finished_at": "2026-09-07T01:02:03+00:00"}
GRAPH = {"nodes": [{"id": n, "type": t, "data": {"label": label}} for n, t, label in [
("start", "input", "开始"), ("fetch", "tool", "门店流水"), ("members", "tool", "会员消费"),
("docs", "retrieve", "口径说明"), ("card", "metrics", "周报口径"), ("write", "report", "写周报"),
("out", "output", "成果")]]}
def sample_events(doc_id=DOC_ID):
return [
(1, "run.started", None, {"nodes": 7, "memory_scope": "default"}),
(2, "node.started", "start", {"label": "开始"}),
(3, "node.finished", "start", {"preview": {"week": "2026-W37"}, "artifact": "f" * 64}),
(4, "tool.end", "fetch", {"tool": "db_query__shop", "preview": "[[\"青石店\", 1280.5]]",
"artifact": "e" * 64, "query_artifact": OPEN_ID}),
(5, "node.finished", "fetch", {"preview": "{\"rows\": …}", "evidence": [
{"kind": "query", "node_id": "fetch", "artifact": OPEN_ID}]}),
(6, "tool.end", "members", {"tool": "db_query__crm", "preview": "[[\"林一\", \"000-0001\"]]",
"query_artifact": MASKED_ID}),
(7, "tool.end", "docs", {"tool": "web_search", "preview": "外部结果"}),
(8, "node.finished", "card", {"preview": {"kind": "metric_set"}, "evidence": [
{"kind": "metric_set", "node_id": "card", "artifact": CARD_ID}]}),
(9, "caliber.upgrade", "card", {"caliber_node": "card", "pinned": "v2", "latest": "v3", "policy": "pin"}),
(10, "llm.end", "write", {"model": "mock", "output": "系统提示和草稿"}),
(11, "report.checked", "write", {"doc_artifact": doc_id, "ok": True}),
(12, "node.finished", "write", {"preview": {"text": "青石店本周流水…", "doc_artifact": doc_id}}),
(13, "issuance", "out", {"tier": "formal", "mode": "citations"}),
(14, "node.finished", "out", {"preview": {"周报": "…", "明细": "[[\"林一\", \"000-0001\"]]"}}),
(15, "run.finished", None, {"output": {"周报": "…"}}),
# 封存之后追加:按需裁判的判定
(16, "evidence.judged", "write", {"verdicts": {"u0": {"verdict": "supported"}}, "model": "judge-x",
"post_seal": True}),
]
def build(events=None, store=None, source_masks=None, **kw):
data = store if store is not None else STORE
return build_bundle(RUN, events if events is not None else sample_events(), manifest_seq=kw.pop("seq", 15),
manifest_hash=kw.pop("manifest", "0" * 64), graph=GRAPH, source_masks=source_masks or {},
read=data.get, exported_at="2026-10-05T08:00:00+00:00", **kw)
def browser_manifest(events: list[dict]) -> str:
"""照浏览器里 verifier.js 的拼法重算:每行 seq|type|node_id|data_sha256,node_id 为空写空串,按 seq 排序。"""
lines = [f"{e['seq']}|{e['type']}|{e['node_id'] or ''}|{e['data_sha256']}"
for e in sorted(events, key=lambda e: e["seq"])]
return hashlib.sha256("\n".join(lines).encode()).hexdigest()
# --------------------------------------------------------------------------
# 事件:哈希、清单、规范字符串
# --------------------------------------------------------------------------
def test_event_hashes_rebuild_the_runners_manifest():
events = sample_events()
bundle = build()
assert bundle["schema"] == SCHEMA
sealed = [e for e in events if e[0] <= 15]
assert [e["seq"] for e in bundle["events"]] == [e[0] for e in sealed]
for item, (seq, etype, node_id, data) in zip(bundle["events"], sealed):
assert (item["seq"], item["type"], item["node_id"]) == (seq, etype, node_id)
assert item["data_sha256"] == content_hash(canonical_json(data))
assert browser_manifest(bundle["events"]) == manifest_hash(sealed)
def test_only_whitelisted_events_carry_their_canonical_text():
events = {e[0]: e for e in sample_events()}
bundle = build()
shown = {e["seq"]: e["canonical"] for e in bundle["events"] if "canonical" in e}
# run.started、无遮罩的查询 tool.end、口径卡和报告的 node.finished、升版处置、报告自查、出具判定
assert sorted(shown) == [1, 4, 8, 9, 11, 12, 13], sorted(shown)
for seq, text in shown.items():
assert text == canonical_json(events[seq][3])
assert hashlib.sha256(text.encode()).hexdigest() == next(
e["data_sha256"] for e in bundle["events"] if e["seq"] == seq)
def test_output_and_finish_show_content_only_when_nothing_is_masked():
"""成果字段可以直接引用查询结果:运行里有带遮罩的快照时,成果节点和运行结束事件只给哈希。"""
unmasked = {**SNAP_MASKED}
unmasked.pop("mask_columns")
plain_id, plain_raw = stored(unmasked)
doc = doc_of(extra_catalog={"Q2": {"alias": "Q2", "kind": "query", "artifact": plain_id, "node_id": "members"}})
doc_id, doc_raw = stored(doc)
events = [(s, t, n, {**d, "query_artifact": plain_id} if s == 6 else d) for s, t, n, d in sample_events(doc_id)]
store = {**STORE, plain_id: plain_raw, doc_id: doc_raw}
bundle = build(events, store)
shown = sorted(e["seq"] for e in bundle["events"] if "canonical" in e)
assert shown == [1, 4, 6, 8, 9, 11, 12, 13, 14, 15], shown
def test_masks_set_on_the_source_now_count_too():
"""快照查询当时没记遮罩、数据源事后设了遮罩:照样算带遮罩——证据面板也按两者之和遮。"""
bundle = build(source_masks={"shop": ["store"]})
assert 4 not in {e["seq"] for e in bundle["events"] if "canonical" in e}
entry = next(a for a in bundle["artifacts"] if a["id"] == OPEN_ID)
assert entry["masked"] is True and "b64" not in entry
def test_an_unreadable_snapshot_counts_as_masked():
"""读不出来的快照不知道带没带遮罩:它的查询事件只给哈希,成果和运行结束事件也一样。"""
store = {k: v for k, v in STORE.items() if k != OPEN_ID}
bundle = build(store=store)
shown = {e["seq"] for e in bundle["events"] if "canonical" in e}
assert 4 not in shown and 14 not in shown and 15 not in shown
entry = next(a for a in bundle["artifacts"] if a["id"] == OPEN_ID)
assert entry["missing"] is True and "b64" not in entry
# 没有遮罩的报告、口径卡:成果和运行结束事件本该带规范字符串,下面几种查询让它们只给哈希
PLAIN_DOC = {**doc_of(), "catalog": {k: v for k, v in doc_of()["catalog"].items() if k != "Q2"}}
PLAIN_DOC_ID, PLAIN_DOC_RAW = stored(PLAIN_DOC)
TOOL_SNAP_CRM = {"tool": "db_query__crm", "args": {"sql": SNAP_MASKED["sql"]},
"result": json.dumps({**{k: v for k, v in SNAP_MASKED.items() if k != "mask_columns"},
"artifact": MASKED_ID}, ensure_ascii=False)}
TOOL_SNAP_CRM_ID, TOOL_SNAP_CRM_RAW = stored(TOOL_SNAP_CRM)
def plain_events(members_end):
"""报告只引用门店流水(没有遮罩)的运行;第 6 条是会员库那次查询,形状由调用方给。"""
events = []
for seq, etype, node_id, data in sample_events(PLAIN_DOC_ID):
if seq == 6:
data = members_end
events.append((seq, etype, node_id, data))
return events
def shown_seqs(bundle) -> set[int]:
return {e["seq"] for e in bundle["events"] if "canonical" in e}
def test_a_run_without_masked_queries_shows_its_output():
"""对照组:会员库那次查询有快照、没有遮罩,成果节点(14)和运行结束事件(15)带规范字符串。"""
unmasked = {k: v for k, v in SNAP_MASKED.items() if k != "mask_columns"}
plain_id, plain_raw = stored(unmasked)
events = plain_events({"tool": "db_query__crm", "preview": "[[\"林一\"]]", "query_artifact": plain_id})
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW, plain_id: plain_raw})
assert {14, 15} <= shown_seqs(bundle)
def test_a_pre_ledger_query_is_traced_through_its_tool_snapshot():
"""升级前的运行:tool.end 只有工具快照(artifact),查询快照的 id 嵌在工具快照的返回里。照证据接口
(_legacy_pool)的办法从工具快照认出查询快照:它带遮罩,成果和运行结束事件只给哈希。"""
events = plain_events({"tool": "db_query__crm", "preview": "{\"rows\": [[\"林一\", \"000-0001\"…",
"artifact": TOOL_SNAP_CRM_ID})
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW, TOOL_SNAP_CRM_ID: TOOL_SNAP_CRM_RAW})
assert not {6, 14, 15} & shown_seqs(bundle)
assert "000-0001" not in json.dumps(bundle, ensure_ascii=False)
def test_a_query_that_cannot_be_traced_counts_as_masked():
"""协作团队成员的查询:tool.end 既没有查询快照也没有工具快照,预览是截断的返回,认不出查的是什么。
宁可多挡:按带遮罩算,成果和运行结束事件只给哈希。"""
events = plain_events({"tool": "db_query__crm", "agent": "分析员", "call_id": "c1",
"preview": "{\"columns\": [\"name\", \"phone\"], \"rows\": [[\"林一\", \"000-0001\"]"})
events[13] = (14, "node.finished", "out", {"preview": {"明细": "林一 000-0001"}})
events[14] = (15, "run.finished", None, {"output": {"明细": "林一 000-0001"}})
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW})
assert not {6, 14, 15} & shown_seqs(bundle)
assert "000-0001" not in json.dumps(bundle, ensure_ascii=False)
def test_a_failed_query_returned_no_data():
"""查询失败、SQL 被拒:返回的是一句原话,没有数据,不因此挡住成果。"""
events = plain_events({"tool": "db_query__crm", "preview": "查询失败:no such table: members_x",
"call_id": "c2"})
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW})
assert {14, 15} <= shown_seqs(bundle)
def test_a_source_masked_now_hides_queries_without_a_snapshot():
"""数据源现在设了遮罩:不必认出快照,按工具名就知道这次查询可能带着遮罩列。"""
events = plain_events({"tool": "db_query__crm", "preview": "查询失败:timeout"})
bundle = build(events, {**STORE, PLAIN_DOC_ID: PLAIN_DOC_RAW}, source_masks={"crm": ["phone"]})
assert not {14, 15} & shown_seqs(bundle)
# --------------------------------------------------------------------------
# 工件:只嵌报告文档和它引用到的口径卡、查询快照
# --------------------------------------------------------------------------
def test_report_doc_and_what_it_cites_are_embedded_byte_for_byte():
bundle = build()
by_id = {a["id"]: a for a in bundle["artifacts"]}
assert [a["id"] for a in bundle["artifacts"]] == [DOC_ID, CARD_ID, OPEN_ID, MASKED_ID]
assert by_id[DOC_ID]["kind"] == "report_doc" and by_id[DOC_ID]["node_id"] == "write"
assert by_id[CARD_ID]["kind"] == "metric_set" and by_id[CARD_ID]["node_id"] == "card"
assert by_id[OPEN_ID]["kind"] == "query" and by_id[OPEN_ID]["node_id"] == "fetch"
for art in (DOC_ID, CARD_ID, OPEN_ID):
assert base64.b64decode(by_id[art]["b64"]) == STORE[art]
assert bundle["reports"] == [{"node_id": "write", "doc_artifact": DOC_ID, "seq": 11}]
def test_a_masked_snapshot_ships_only_its_id():
entry = next(a for a in build()["artifacts"] if a["id"] == MASKED_ID)
assert entry == {"id": MASKED_ID, "kind": "query", "node_id": "members", "masked": True}
assert "000-0001" not in render_html(build())
def test_artifacts_the_report_does_not_cite_stay_out():
"""目录里有、正文没引用的查询(Q3),检索快照,节点产出、工具快照:都不嵌。"""
ids = {a["id"] for a in build()["artifacts"]}
assert UNCITED_ID not in ids and RETRIEVAL_ID not in ids
assert "f" * 64 not in ids and "e" * 64 not in ids
def test_the_latest_report_checked_of_a_node_wins():
"""同一个报告节点自查了两次(重写过):和证据接口一样认最后一次。"""
old_id, old_raw = stored({**DOC, "markdown": "旧稿"})
events = sample_events()
events.insert(10, (10, "report.checked", "write", {"doc_artifact": old_id, "ok": False}))
events = [(i + 1, t, n, d) for i, (_, t, n, d) in enumerate(events)]
bundle = build(events, {**STORE, old_id: old_raw}, seq=16)
assert bundle["reports"] == [{"node_id": "write", "doc_artifact": DOC_ID, "seq": 12}]
assert old_id not in {a["id"] for a in bundle["artifacts"]}
def test_a_doc_from_another_run_is_embedded_but_not_followed():
"""文档记的运行不是这一次:照实嵌进去让浏览器标出来,但它的目录不可信,不顺着它找工件。"""
stray = doc_of(run_id="other")
stray_id, stray_raw = stored(stray)
bundle = build(sample_events(stray_id), {**STORE, stray_id: stray_raw})
assert [a["id"] for a in bundle["artifacts"]] == [stray_id]
def test_a_missing_or_changed_artifact_is_listed_for_the_browser_to_judge():
"""服务端的文件不在了:只给 id 并标明;被改过的:原样嵌进去,浏览器自己算出对不上。"""
changed = CARD_RAW.replace(b"1280.5", b"1999.5")
bundle = build(store={**STORE, CARD_ID: changed, OPEN_ID: None})
by_id = {a["id"]: a for a in bundle["artifacts"]}
assert base64.b64decode(by_id[CARD_ID]["b64"]) == changed
assert by_id[OPEN_ID] == {"id": OPEN_ID, "kind": "query", "node_id": "fetch", "missing": True}
# --------------------------------------------------------------------------
# 元信息、封存之后追加的事件
# --------------------------------------------------------------------------
def test_meta_names_the_seal_and_the_run():
bundle = build(manifest="ab" * 32)
meta = bundle["meta"]
assert meta["run_id"] == "r1" and meta["workflow_name"] == "门店周报" and meta["workflow_id"] == "wf1"
assert meta["version"] == 3 and meta["run_class"] == "formal"
assert meta["manifest_seq"] == 15 and meta["manifest_hash"] == "ab" * 32 and meta["legacy"] is False
assert meta["started_at"] == RUN["started_at"] and meta["finished_at"] == RUN["finished_at"]
assert meta["exported_at"] == "2026-10-05T08:00:00+00:00"
# 出具档位取封存范围内最后一条出具判定事件,带上序号,浏览器拿已核对的事件对照
assert meta["tier"] == {"value": "formal", "seq": 13}
assert bundle["nodes"]["write"] == {"label": "写周报", "type": "report"}
def test_events_after_the_seal_are_listed_apart():
bundle = build()
assert 16 not in {e["seq"] for e in bundle["events"]}
[later] = bundle["appended"]
assert later["seq"] == 16 and later["type"] == "evidence.judged" and later["node_id"] == "write"
assert "1 句" in later["summary"] and "judge-x" in later["summary"]
assert "canonical" not in later and "data_sha256" not in later
def test_display_name_and_attachment_name():
assert display_name({"workflow_id": None, "workflow_name": "临时图"}) == "未保存的工作流"
assert display_name({"workflow_id": None, "workflow_name": ""}) == "未保存的工作流"
assert display_name({"workflow_id": "w", "workflow_name": "门店周报"}) == "门店周报"
assert attachment_name({"workflow_id": "w", "workflow_name": "门店/周报: v2?"}) == "门店_周报_ v2_-可验证报告.html"
assert attachment_name({"workflow_id": None, "workflow_name": ""}) == "未保存的工作流-可验证报告.html"
header = content_disposition("门店周报-可验证报告.html", run_id="0123456789abcdef")
assert header.startswith("attachment; ")
assert f"filename*=UTF-8''{quote('门店周报-可验证报告.html', safe='')}" in header
# 不认 filename* 的老客户端拿到一个纯 ASCII 的名字
fallback = re.search(r'filename="([^"]+)"', header).group(1)
assert fallback.isascii() and "01234567" in fallback
# --------------------------------------------------------------------------
# 页面:自包含、数据块截不断
# --------------------------------------------------------------------------
def data_block(html: str) -> str:
m = re.search(r'<script type="application/json" id="bundle-data">(.*?)</script>', html, re.S)
assert m, "页面里没有数据块"
return m.group(1)
def test_the_page_has_no_external_links():
html = render_html(build())
assert html.lstrip().lower().startswith("<!doctype html>")
assert not re.search(r"https?://", html), re.search(r".{0,40}https?://.{0,40}", html).group(0)
assert not re.search(r"<script[^>]*\ssrc\s*=", html, re.I)
assert not re.search(r"<(?:link|img|iframe|object|embed|audio|video|source)\b", html, re.I)
assert not re.search(r"url\s*\(|@import", html, re.I)
# 内容安全策略:默认什么都不许连,脚本只认内联的那一段核对脚本(按哈希)
csp = re.search(r'<meta http-equiv="Content-Security-Policy" content="([^"]+)"', html).group(1)
assert "default-src 'none'" in csp
script = re.search(r"<script>(.*?)</script>", html, re.S).group(1)
digest = base64.b64encode(hashlib.sha256(script.encode()).digest()).decode()
assert f"script-src 'sha256-{digest}'" in csp
def test_data_cannot_break_out_of_its_script_tag():
nasty = "</script><script>alert(1)</script><!-- & \u2028\u2029 >"
bundle = build()
bundle["meta"]["workflow_name"] = nasty
bundle["events"][0]["canonical"] = nasty
text = bundle_json(bundle)
assert not re.search("[<>&\u2028\u2029]", text)
assert json.loads(text) == bundle
html = render_html(bundle)
assert html.count("</script>") == 2 # 数据块和核对脚本各一个,再没有别的
assert json.loads(data_block(html)) == bundle
title = re.search(r"<title>(.*?)</title>", html, re.S).group(1)
assert "<script>" not in title and "&lt;/script&gt;" in title
def test_the_verifier_script_is_inline_and_cannot_close_early():
html = render_html(build())
script = re.search(r"<script>(.*?)</script>", html, re.S).group(1)
assert "</script" not in script.lower() and "<!--" not in script
# 脚本在 <head> 里、排在数据块前面:它的注释里也不能出现「<script」,按标签找数据块的人才不会先找到它
assert "<script" not in script.lower()
assert html.index("<script>") < html.index('<script type="application/json" id="bundle-data">')
assert "bundle-data" in script and "crypto.subtle" in script.replace("window.", "")
def text_of(html: str, element_id: str) -> str:
m = re.search(rf'<(\w+)[^>]*\sid="{element_id}"[^>]*>(.*?)</\1>', html, re.S)
assert m, f"页面里没有 #{element_id}"
return re.sub(r"<[^>]+>", "", m.group(2))
def test_the_seal_and_run_are_written_without_script():
"""附件预览、快速查看常常不执行脚本:封存哈希、运行 ID、工作流名直接写在页面上,状态说清还没核对。"""
nasty = {**RUN, "id": "r1<b>", "workflow_name": "门店<i>周报</i>"}
bundle = build_bundle(nasty, sample_events(), manifest_seq=15, manifest_hash="ab" * 32, graph=GRAPH,
read=STORE.get, exported_at="2026-10-05T08:00:00+00:00")
html = render_html(bundle)
assert text_of(html, "seal-hash") == "ab" * 32
assert "r1&lt;b&gt;" in text_of(html, "seal-note") and "<b>" not in text_of(html, "seal-note")
assert text_of(html, "title") == "门店&lt;i&gt;周报&lt;/i&gt;"
# 没执行脚本时总览写的是「尚未核对」,不是一直转着的「正在核对」(那句只在脚本开始执行后才显示)
assert re.search(r'class="no-js">尚未核对:[^<]*需要在浏览器中打开本文件', html)
assert re.search(r'class="js-only">正在核对', html)
# 成功结束的运行没有「继续运行会重新封存」那一句;失败的有
assert re.search(r'id="seal-failed"[^>]*\shidden', html)
failed = render_html(build_bundle({**RUN, "status": "failed"}, sample_events(), manifest_seq=15,
manifest_hash="ab" * 32, graph=GRAPH, read=STORE.get, exported_at="x"))
assert not re.search(r'id="seal-failed"[^>]*\shidden', failed)
assert "重新封存" in text_of(failed, "seal-failed")
@pytest.mark.parametrize("legacy", [True, False])
def test_legacy_seals_are_flagged(legacy):
assert build(legacy=legacy)["meta"]["legacy"] is legacy
# --------------------------------------------------------------------------
# 浏览器检查用的样本页(tests/bundle_samples.py)
# --------------------------------------------------------------------------
def test_the_sample_pages_for_the_browser_check_are_sealed_for_real():
"""scripts/check-bundle.mjs 断网打开这些样本页,断言核对结果、再做篡改。pytest 不执行页面上的脚本,这里守住样本
本身:封存哈希是对事件真算的(浏览器的拼法重算得出),各页的形状是 check-bundle 的断言预期的那样。"""
from tests.bundle_samples import pages
got = {name: json.loads(data_block(page)) for name, page in pages().items()}
assert set(got) == {"base", "two-reports", "legacy", "failed", "degraded", "missing-doc", "missing-card", "nan",
"stray"}
for name, bundle in got.items():
assert browser_manifest(bundle["events"]) == bundle["meta"]["manifest_hash"], name
def segments(bundle, node_id="write"):
doc_id = next(r["doc_artifact"] for r in bundle["reports"] if r["node_id"] == node_id)
doc = json.loads(base64.b64decode(next(a["b64"] for a in bundle["artifacts"] if a["id"] == doc_id)))
return [s for b in doc["blocks"] for u in b["units"] for s in u["segments"] if s["kind"] in ("number", "value")]
base = got["base"]
shapes = {(a["kind"], a.get("node_id")): {k for k in a if k in ("b64", "masked", "missing")} for a in base["artifacts"]}
assert shapes == {("report_doc", "write"): {"b64"}, ("metric_set", "card"): {"b64"}, ("query", "fetch"): {"b64"},
("query", "members"): {"masked"}}
cited = segments(base)
# 数字与取值超过折叠的条数(20);没有出处的一个、引用遮罩快照的一个
assert len(cited) == 21
assert sum(1 for s in cited if not s.get("cite")) == 1 and sum(1 for s in cited if s["kind"] == "value") == 8
assert [a["type"] for a in base["appended"]] == ["evidence.judged"]
assert base["meta"]["tier"]["value"] == "formal" and base["meta"]["run_class"] == "formal"
assert [(a["kind"], a.get("missing")) for a in got["missing-doc"]["artifacts"]] == [("report_doc", True)]
assert next(a for a in got["missing-card"]["artifacts"] if a["kind"] == "metric_set").get("missing") is True
assert got["legacy"]["meta"]["legacy"] is True and "run.finished" in [a["type"] for a in got["legacy"]["appended"]]
assert [r["node_id"] for r in got["two-reports"]["reports"]] == ["write", "monthly"] and segments(got["two-reports"], "monthly")
failed = got["failed"]
assert failed["meta"]["status"] == "failed" and failed["meta"]["tier"] is None
assert max(failed["events"], key=lambda e: e["seq"])["type"] == "run.failed"
issuances = [e["seq"] for e in got["degraded"]["events"] if e["type"] == "issuance"]
assert len(issuances) == 2 and got["degraded"]["meta"]["tier"] == {"value": "degraded", "seq": issuances[-1]}
assert [a["kind"] for a in got["stray"]["artifacts"]] == ["report_doc"]
nan = got["nan"]
assert "NaN" in next(e["canonical"] for e in nan["events"] if e["type"] == "run.started")
assert b"NaN" in base64.b64decode(next(a["b64"] for a in nan["artifacts"] if a.get("node_id") == "fetch"))
+40 -2
View File
@@ -1,7 +1,45 @@
/**
* 可验证报告的接口(/api/runs/{run_id}/…)。现在是空的,可验证报告的工作包往里加,挂在 api.bundle 上。
* 可验证报告的接口(/api/runs/{run_id}/…),挂在 api.bundle 上。
*
* 请求函数(get / post / del……)和 ApiError 从 ./http 导入,不要从 ./client 导入:client.ts 要导入
* 这个文件把它挂到 api 上,反过来导入就成了循环(见 http.ts 开头的说明)。
*/
export const bundle = {}
import { ApiError, BASE, NETWORK_MESSAGE, actorHeader, failure, report } from './http'
/**
* Content-Disposition 里的文件名:先看 RFC 5987 的 filename*(中文名写在这里),没有或解不开再看 filename。
* 服务端给的 filename 是纯 ASCII 的退路(agentlab-<运行号>-bundle.html),只在老客户端那边派上用场
*/
export function dispositionName(header: string | null): string | null {
if (!header) return null
const star = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(header)?.[1]
if (star) {
try {
return decodeURIComponent(star.trim().replace(/^"|"$/g, ''))
} catch {
// 百分号编码坏了:退回 filename
}
}
return /filename\s*=\s*"?([^";]+)"?/i.exec(header)?.[1]?.trim() || null
}
export const bundle = {
/**
* 下载可验证报告(GET /runs/{id}/bundle.html,附件):回来的是文件本身和服务端给的文件名,没给就是 null,
* 由调用方按运行名补。运行未封存、封存核对不一致时服务端回 409,ApiError 的 message 是给人看的原因
*/
download: async (runId: string): Promise<{ blob: Blob; name: string | null }> => {
const path = `/runs/${encodeURIComponent(runId)}/bundle.html`
let res: Response
try {
res = await fetch(BASE + path, { headers: actorHeader() })
} catch (e: any) {
const err = new ApiError(0, NETWORK_MESSAGE, { kind: 'network', raw: `${e?.name ?? 'Error'}: ${e?.message ?? e}` })
report(false, err)
throw err
}
if (!res.ok) throw failure(res.status, res.statusText, await res.text().catch(() => ''), path)
report(true)
return { blob: await res.blob(), name: dispositionName(res.headers.get('Content-Disposition')) }
},
}
+56
View File
@@ -0,0 +1,56 @@
import type { JSX } from 'react'
import { ProvenanceBar } from '../../pages/runs/Telemetry'
import type { RunPhase } from '../../run/trace'
import type { Run } from '../../types'
/**
* 可验证报告的预览用例:记录页封存凭证条上的「导出可验证报告」。scripts/check-bundle.mjs 按 ?syn=<用例名>
* 打开,用 page.route 假装 /api/runs/{id}/bundle.html,不碰真实数据。
*
* - bundle-provenance-sealed:已封存,可以导出
* - bundle-provenance-running / -waiting:还在运行、等待审批,按钮不可用并说明原因
* - bundle-provenance-unsealed:结束了却没有封存清单(加封存之前的老运行)
* - bundle-provenance-again:失败后接着跑,留着上一次的封存,正在运行:同样不可用(跑完会换掉清单哈希),左边写着
* 「已封存」,原因写在屏幕上
* - bundle-provenance-failed:失败结束、已封存:可以导出,提示里说明之后继续运行会重新封存
*/
const SEALED: Run = {
id: 'f4b0f4b0f4b0f4b0f4b0f4b0f4b0f4b0',
workflow_id: 'wf-bundle-sample',
workflow_name: '门店周报',
status: 'succeeded',
input: {},
output: {},
error: null,
usage: {},
run_class: 'formal',
version: 3,
version_hash: 'a1'.repeat(32),
manifest_hash: 'c3b5'.repeat(16),
manifest_seq: 59,
started_by: '值班分析员',
created_at: '2026-09-07T01:00:00Z',
started_at: '2026-09-07T01:00:01Z',
finished_at: '2026-09-07T01:02:03Z',
}
const unsealed = (status: Run['status']): Run => ({ ...SEALED, status, manifest_hash: null, manifest_seq: null, finished_at: null })
function Frame({ run, phase }: { run: Run; phase: RunPhase }) {
return (
<div className="mx-auto max-w-3xl p-4" data-fixture="bundle-provenance">
<div className="overflow-hidden rounded-lg border bg-panel">
<ProvenanceBar run={run} eventCount={25} phase={phase} />
</div>
</div>
)
}
export const cases: Record<string, () => JSX.Element> = {
'bundle-provenance-sealed': () => <Frame run={SEALED} phase="succeeded" />,
'bundle-provenance-running': () => <Frame run={unsealed('running')} phase="running" />,
'bundle-provenance-waiting': () => <Frame run={unsealed('interrupted')} phase="waiting" />,
'bundle-provenance-unsealed': () => <Frame run={{ ...unsealed('succeeded'), finished_at: SEALED.finished_at }} phase="succeeded" />,
'bundle-provenance-again': () => <Frame run={{ ...SEALED, status: 'running', finished_at: null }} phase="running" />,
'bundle-provenance-failed': () => <Frame run={{ ...SEALED, status: 'failed' }} phase="failed" />,
}
+16 -1
View File
@@ -2748,9 +2748,24 @@ export const RUN_COMPARE_TEXT = {
pendingHint: '运行对比尚未开放,完成后可从记录页选择两次运行进行对照',
}
/** 可验证报告:导出可独立核对的报告 */
/**
* 可验证报告:把一次已封存运行的报告导出成一个 HTML 文件,收件人不连服务也能在浏览器里核对。
* 入口在记录页的封存凭证条上;页面本身的文案在服务端 bundle/verifier.js 里(它是离开系统独立打开的文件)
*/
export const BUNDLE_TEXT = {
title: '可验证报告',
export: '导出可验证报告',
hint: '导出为单个 HTML 文件:收件人不连服务,也能在浏览器中核对数字的出处和运行记录',
/** 失败结束的运行:之后继续运行会重新封存,导出的文件就再也对不上系统里的封存了 */
hintFailed: '导出为单个 HTML 文件。这次运行以失败结束:之后若继续运行,系统会重新封存,导出的文件将无法再与系统对照',
/** 不能导出时说明的原因:和凭证条左边「未封存 · …」的说法对得上 */
blockedRunning: '运行结束并封存后才能导出',
blockedWaiting: '等待审批中,运行结束并封存后才能导出',
blockedUnsealed: '这次运行没有封存清单,无法导出',
exported: (name: string) => `已导出「${name}」`,
exportedFailed: (name: string) => `已导出「${name}」。之后若继续运行,系统会重新封存,这份文件将无法再与系统对照`,
/** 服务端没给文件名时按运行名补,规则同服务端的附件名 */
fileName: (name: string) => `${name}-可验证报告.html`,
}
/** 审批质询:审批人在批准前就待审的报告提出质询 */
+56 -4
View File
@@ -1,8 +1,8 @@
import { useState, type ReactNode } from 'react'
import { ChevronDown, Shield, ShieldAlert, ShieldCheck } from 'lucide-react'
import { useId, useState, type ReactNode } from 'react'
import { ChevronDown, Download, Shield, ShieldAlert, ShieldCheck } from 'lucide-react'
import clsx from 'clsx'
import { api } from '../../api/client'
import { StatusPill, toast, useTicker } from '../../components/ui'
import { Spinner, StatusPill, toast, useTicker } from '../../components/ui'
import {
NONE, formatClock, formatCost, formatDateTime, formatNumber, formatSpan, formatTime, formatTokens, shortId,
} from '../../lib/format'
@@ -10,7 +10,7 @@ import { type StatusCode } from '../../lib/status'
import { isExecuting, liveAt, project, type Projection, type RunPhase, type Trace } from '../../run/trace'
import { useRunClock } from '../../run/useRunClock'
import type { Approval, Run } from '../../types'
import { COST_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms'
import { BUNDLE_TEXT, COST_TEXT, UNSAVED_HINT, isUnsaved, runCostText, runName } from '../../lib/terms'
import {
LONG_WAIT_MS, ageMs, cancelReason, runScope, runTiming, type RunTiming,
} from './model'
@@ -257,6 +257,8 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
const [verifying, setVerifying] = useState(false)
const [fresh, setFresh] = useState(false)
const [open, setOpen] = useState(false)
const [exporting, setExporting] = useState(false)
const blockedId = useId()
const sealed = !!run.manifest_hash
const doVerify = async () => {
@@ -281,6 +283,31 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
? '等待审批中,运行结束后封存'
: '本次运行没有封存清单'
// 可验证报告只给结束了、封存了的运行:没封存的事件还会变;失败后接着跑的运行留着上一次的封存,跑完会换掉清单
// 哈希,导出去的文件就和系统对不上了(规则同服务端 api/bundle.py)。封存核对不一致由服务端回 409,提示里照实说
const finished = phase === 'succeeded' || phase === 'failed' || phase === 'cancelled'
const exportBlocked = sealed && finished ? null
: phase === 'waiting' ? BUNDLE_TEXT.blockedWaiting
: !finished ? BUNDLE_TEXT.blockedRunning
: BUNDLE_TEXT.blockedUnsealed
// 失败结束的运行可以「继续运行」:接着跑完会重新封存、换掉清单哈希,导出的文件从此对不上系统,导出前后都说一句
const failedRun = phase === 'failed'
const doExport = async () => {
setExporting(true)
try {
const { blob, name } = await api.bundle.download(run.id)
const file = name ?? BUNDLE_TEXT.fileName(runName(run))
saveFile(file, blob)
toast.ok(failedRun ? BUNDLE_TEXT.exportedFailed(file) : BUNDLE_TEXT.exported(file))
} catch (e) {
toast.error(e, { key: `bundle:${run.id}` })
} finally {
setExporting(false)
}
}
// 扫描线的时长按事件条数估:重算的就是这些事件
const scanMs = Math.min(2400, Math.max(600, eventCount * 0.8))
@@ -321,6 +348,21 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
{verify ? '重新核对' : '核对'}
</button>
)}
{/* 禁用的按钮收不到指针事件,悬停提示挂在外面那层上;读屏经 aria-describedby 读到原因 */}
<span className="inline-flex" title={exportBlocked ?? (failedRun ? BUNDLE_TEXT.hintFailed : BUNDLE_TEXT.hint)}>
<button type="button" className="btn btn-xs btn-ghost" disabled={!!exportBlocked || exporting}
onClick={() => void doExport()} aria-describedby={exportBlocked ? blockedId : undefined}
data-bundle-export={exportBlocked ? 'blocked' : exporting ? 'busy' : 'ready'}>
{exporting ? <Spinner size={10} /> : <Download size={11} aria-hidden />} {BUNDLE_TEXT.export}
</button>
</span>
{/* 没封存时左边写着「未封存 · …」,原因只给读屏和悬停;留着旧封存又在运行(失败后接着跑)时左边写着
「已封存」,按钮却不能点,原因得写在屏幕上,触屏上看不到悬停提示 */}
{exportBlocked && (
<span id={blockedId} className={sealed ? 'text-faint' : 'sr-only'} data-bundle-blocked={sealed ? 'shown' : ''}>
{exportBlocked}
</span>
)}
<button
type="button"
className="btn btn-xs btn-ghost"
@@ -339,6 +381,16 @@ export function ProvenanceBar({ run, eventCount, phase }: { run: Run; eventCount
)
}
/** 存成文件:同 EvidencePane 的下载写法(对象 URL 交给一个临时的链接点一下,稍后回收) */
function saveFile(name: string, blob: Blob) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = name
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
function VerifyState({ verify, verifying, fresh }: { verify: VerifyResult | null; verifying: boolean; fresh: boolean }) {
if (verifying) {
return <span className="text-dim" role="status" data-verify="running">核对中…</span>
+1 -1
View File
@@ -96,7 +96,7 @@ const ESTIMATE = {
stream: 173, chat: 160, studio: 149, manage: 145, runs: 112, publish: 92, shell: 87,
evidence: 91, versions: 60, catalog: 45, 'ui-kit': 70, ui: 61, guards: 8, 'canvas-layout': 2, copy: 2, tokens: 1, trace: 1, decode: 1,
// 占位,换成真检查时按实测改
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 1, whatif: 1,
schedules: 1, compare: 1, verify: 1, inquiry: 1, bundle: 16, whatif: 1,
}
// 最慢的两项按段拆成几份。用的是它们自己的段过滤:最后一份用 *_SKIP 兜住「其余全部」,以后新加的段
// 不用改这里也跑得到(拆出去的那几份要是一段都没对上,那一份会报「一项都没跑」)。
+817 -7
View File
@@ -1,8 +1,818 @@
// 可验证报告的检查。现在只是占位:功能还在开发,这里只打一行通过,让 check-all 的清单先排上它。
// 可验证报告的工作包把它换成真检查:沿用别的检查的输出格式——每项缩进两格、以 ✓ / ✗ 开头,最后顶格一行
// 总结,没过的退出码非 0(check-all 按缩进的 ✓ / ✗ 计数)。
// 可验证报告的检查:真服务端导出、断网打开核对、篡改变红;虚构样本页上的各种形状;外加记录页上的导出按钮。
//
// node scripts/check-bundle.mjs
console.log('=== 可验证报告 ===')
console.log(' ✓ 占位:功能开发中')
console.log('\n✓ 可验证报告:占位通过')
// 跑之前前后端都得起着(check-all 另起的那套,或者 ./scripts/dev.sh)。默认连 5273 / 8000;对别的实例跑时带上地址:
// AGENTLAB_WEB=http://localhost:<前端端口> AGENTLAB_API=http://localhost:<后端端口>/api node scripts/check-bundle.mjs
// 样本页用后端解释器生成(CHECK_PYTHON,默认 ~/miniforge3/envs/agentlab/bin/python),从这份工作区的 backend 导入。
//
// 分几段:
// - 导出:从服务端找一份已封存、封存核对一致、有报告文档的运行(GET /api/runs 找成功结束的,再看 /verify 和证据接口),
// 下载 bundle.html:状态码、类型、附件名(RFC 5987)、页面本身零外链。找不到可导出的运行时照实报失败并说明原因;
// - 断网打开:拦掉一切网络请求,从本地文件打开:各项核对没有不通过、总览通过、没有发出任何请求,数字点开看得到出处;
// - 去掉 crypto.subtle:页面加载前删掉它,照常全部通过,页脚写明用的是本页内置的实现;
// - 篡改:① 一条带规范字符串的事件内容、② 一份嵌入工件的一个字节、③ 元信息里的封存哈希,各自对应的核对项不通过、
// 总览显示不通过;另做一次「哈希全部重算」的伪造:改报告里一个数字的引用值,文档、事件、清单、页面顶部的封存哈希
// 都重算一遍,①–④ 都对得上,只有 ⑤ 拿口径卡、快照对出来不通过,报告标题旁的徽标跟着不通过;
// - 深浅色、窄屏:390 宽不横向溢出,点开的数字面板在窄屏上从底部抽出,深色下底是深的、字是浅的;
// - 样本页:tests/bundle_samples.py 用虚构数据真实生成的几份页面(遮罩快照、两份报告、早期运行、失败结束、两条出具
// 判定、服务端缺文件、裸 NaN、别的运行的文档),逐份断言各项结果和原因文案;在上面篡改 ④ 引用链(换文档、拿掉报告、
// 多一份没人引用的工件)、⑥ 运行信息(运行结果、类别、出具档位)、拿掉被引用的口径卡、把口径卡标成遮罩、只改页面顶部
// 的封存哈希;宽屏点开表格里右对齐的数字不被面板盖住;不执行脚本时封存哈希、运行 ID 照样看得到,状态写「尚未核对」;
// - 记录页的导出按钮(预览用例 ?syn=bundle-provenance-*,page.route 假装导出接口):已封存时可点、下载的文件名同服务端;
// 未封存时不可点并说明原因(左边写着「已封存」却不能点时原因写在屏幕上);服务端回 409 时提示原因;失败结束的运行
// 提示之后继续运行会重新封存。
//
// 读的是真数据:输出里不打印工作流名、报告内容和数字,只打印计数和运行号前 8 位。下载的文件写在临时目录,跑完删掉。
// 导出接口只读(不追加事件、不改运行),页面检查里的写接口一律是假的。
import { spawnSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
const CHROME = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
const BACKEND = join(dirname(dirname(fileURLToPath(import.meta.url))), 'backend')
const PYTHON = [process.env.CHECK_PYTHON, join(homedir(), 'miniforge3/envs/agentlab/bin/python')]
.find((p) => p && existsSync(p))
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const note = (text) => console.log(` · ${text}`)
/** 等一处元素出现:locator.isVisible() 不等待,提示、按钮恢复要等一会儿才到 */
const appears = (locator, timeout = 5000) => locator.first().waitFor({ state: 'visible', timeout }).then(() => true, () => false)
const TMP = mkdtempSync(join(tmpdir(), 'agentlab-bundle-'))
// 导出的页面带着真数据:中途崩了也要删掉
process.on('exit', () => rmSync(TMP, { recursive: true, force: true }))
const browser = await chromium.launch({ executablePath: CHROME })
const cleanup = async () => {
await browser.close().catch(() => {})
rmSync(TMP, { recursive: true, force: true })
}
/** 一节一节地跑:某一节里元素找不到、等待超时,只记成这一节失败,接着跑下一节 */
async function section(name, fn) {
console.log(`\n=== ${name} ===`)
try {
await fn()
} catch (e) {
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
}
}
// ---------------------------------------------------------------- 页面数据的读写(篡改用)
const DATA_RE = /(<script type="application\/json" id="bundle-data">)([\s\S]*?)(<\/script>)/
const SEAL_RE = /(<code id="seal-hash" class="hash">)([^<]*)(<\/code>)/
const dataOf = (html) => JSON.parse(DATA_RE.exec(html)[2])
// 和服务端 bundle_json 一样转义 < > & 和 U+2028 / U+2029,改过的数据照样截不断数据块
const escapeJson = (obj) => JSON.stringify(obj)
.replace(/[<>&\u2028\u2029]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`)
const withData = (html, obj) => html.replace(DATA_RE, (_, open, __, close) => open + escapeJson(obj) + close)
/** 只改页面顶部静态写出的封存哈希(不执行脚本也看得到的那一份) */
const withSeal = (html, hash) => html.replace(SEAL_RE, (_, open, __, close) => open + hash + close)
const sha = (data) => createHash('sha256').update(data).digest('hex')
function writePage(name, html) {
const file = join(TMP, name)
writeFileSync(file, html)
return file
}
/**
* 断网打开一份本地文件:除了 file:// 本身,任何请求都拦掉并记下来。等页面核对完(html[data-verified])再交回。
* noSubtle:页面脚本跑之前删掉 crypto.subtle(Crypto.prototype 上的 getter),逼它走内置实现
*/
async function openFile(file, { noSubtle = false, colorScheme = 'light', width = 1280 } = {}) {
const ctx = await browser.newContext({ viewport: { width, height: 900 }, colorScheme })
const requests = []
await ctx.route('**/*', (route) => {
const url = route.request().url()
if (url.startsWith('file:')) return route.continue()
requests.push(url)
return route.abort('blockedbyclient')
})
const page = await ctx.newPage()
const errors = []
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`))
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()) })
if (noSubtle) {
await page.addInitScript(() => {
Object.defineProperty(Crypto.prototype, 'subtle', { get: () => undefined, configurable: true })
})
}
await page.goto(pathToFileURL(file).href)
await page.waitForSelector('html[data-verified]', { timeout: 30_000 })
return { ctx, page, requests, errors }
}
const stateOf = (page) => page.evaluate(() => ({
overall: document.getElementById('overall')?.getAttribute('data-overall'),
text: document.getElementById('overall-text')?.textContent ?? '',
checks: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check, c.dataset.result])),
sums: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check,
c.querySelector('.check-sum')?.textContent ?? ''])),
failsIn: Object.fromEntries([...document.querySelectorAll('[data-check]')].map((c) => [c.dataset.check,
new Set([...c.querySelectorAll('li[data-result="fail"]')].map((li) => li.textContent)).size])),
// 每一条核对:在哪一块、结果、文字(含原因)、是不是收在「展开全部」里
items: [...document.querySelectorAll('[data-check] li[data-result]')].map((li) => ({
check: li.closest('[data-check]').dataset.check, result: li.dataset.result, text: li.textContent,
folded: !!li.closest('details'),
})),
reports: [...document.querySelectorAll('article.report')].map((a) => ({
node: a.dataset.report, result: a.dataset.result, parts: a.querySelector('[data-report-parts]')?.textContent ?? '',
warn: a.querySelector('.warn')?.textContent ?? '', why: a.querySelector('.warn')?.dataset.why ?? '',
})),
meta: Object.fromEntries([...document.querySelectorAll('[data-meta]')].map((m) => [m.dataset.meta,
{ result: m.dataset.result, text: m.textContent }])),
numbers: document.querySelectorAll('button.num').length,
numberStates: [...document.querySelectorAll('button.num')].reduce((acc, b) => {
acc[b.dataset.result] = (acc[b.dataset.result] ?? 0) + 1
return acc
}, {}),
sha: document.querySelector('[data-sha]')?.dataset.sha ?? null,
seal: document.getElementById('seal-hash')?.textContent ?? '',
overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
}))
const describe = (s) => Object.entries(s.checks).map(([k, v]) => `${k}=${v}`).join(' ')
/** 有通过、没有不通过:「部分无法核对」也算(遮罩快照、没有出处的数字是正常的无法核对) */
const noFail = (r) => r === 'pass' || r === 'partial'
const hasItem = (s, check, result, text) => s.items.some((i) => i.check === check && i.result === result
&& (!text || i.text.includes(text)))
/**
* 「哈希全部重算」的伪造:改报告里一个能核对的数字的引用值,再把文档、引用它的事件、清单哈希、页面顶部的封存哈希
* 全部重算。返回改了哪一处的说明,找不到可改的数字返回 null
*/
function forge(data) {
const docArt = data.artifacts.find((a) => a.kind === 'report_doc' && typeof a.b64 === 'string')
if (!docArt) return null
const doc = JSON.parse(Buffer.from(docArt.b64, 'base64').toString('utf8'))
const embedded = new Set(data.artifacts.filter((a) => typeof a.b64 === 'string').map((a) => a.id))
let hit = null
for (const block of doc.blocks ?? []) for (const unit of block.units ?? []) for (const seg of unit.segments ?? []) {
const c = seg.cite
if (!hit && c?.status === 'resolved' && ['metric', 'cell'].includes(c.kind) && typeof c.value === 'number'
&& embedded.has(doc.catalog?.[c.alias]?.artifact)) hit = seg
}
if (!hit) return null
hit.cite.value += 1
const bytes = Buffer.from(JSON.stringify(doc), 'utf8')
const oldId = docArt.id
const newId = sha(bytes)
docArt.id = newId
docArt.b64 = bytes.toString('base64')
for (const r of data.reports) if (r.doc_artifact === oldId) r.doc_artifact = newId
for (const ev of data.events) {
if (typeof ev.canonical === 'string' && ev.canonical.includes(oldId)) {
ev.canonical = ev.canonical.split(oldId).join(newId)
ev.data_sha256 = sha(ev.canonical)
}
}
const lines = data.events.map((e, i) => [e, i]).sort((x, y) => x[0].seq - y[0].seq || x[1] - y[1])
.map(([e]) => `${e.seq}|${e.type}|${e.node_id ?? ''}|${e.data_sha256}`)
data.meta.manifest_hash = sha(lines.join('\n'))
return { what: `片段 ${hit.id}(${hit.cite.kind})`, node: data.reports.find((r) => r.doc_artifact === newId)?.node_id }
}
/** 改一份嵌入工件的一个字节(优先改数字),返回说明 */
function flipByte(art) {
const bytes = Buffer.from(art.b64, 'base64')
const at = bytes.findIndex((b) => b >= 0x30 && b <= 0x39)
const index = at >= 0 ? at : bytes.length - 2
bytes[index] = at >= 0 ? (bytes[index] === 0x39 ? 0x30 : bytes[index] + 1) : bytes[index] ^ 0x01
art.b64 = bytes.toString('base64')
return `${art.kind} ${art.id.slice(0, 8)} 的第 ${index} 字节`
}
// ---------------------------------------------------------------- 导出
async function getJson(path) {
const r = await fetch(API + path, { signal: AbortSignal.timeout(20_000) })
if (!r.ok) throw new Error(`${path} 返回 HTTP ${r.status}`)
return r.json()
}
/** 找一份可导出的运行:已封存、核对一致、证据图里有哈希复验通过的报告文档。优先有带出处的数字的那份 */
async function findRun() {
let seen = 0
let sealedCount = 0
let fallback = null
let before = ''
for (let page = 0; page < 5; page++) {
let runs
try {
runs = await getJson(`/runs?status=succeeded&limit=200${before ? `&before=${encodeURIComponent(before)}` : ''}`)
} catch (e) {
return { why: `无法读取运行列表:${e.message}` }
}
for (const run of runs) {
seen++
if (!run.manifest_hash || run.manifest_seq == null) continue
sealedCount++
const verdict = await getJson(`/runs/${run.id}/verify`).catch(() => null)
if (verdict?.ok !== true) continue
const graph = await getJson(`/runs/${run.id}/evidence`).catch(() => null)
const reports = (graph?.mode === 'cited' ? graph.reports ?? [] : []).filter((r) => r.doc_artifact && r.hash_ok === true)
if (!reports.length) continue
const cited = reports.reduce((n, r) => n + (r.stats?.numbers_cited ?? 0), 0)
if (cited > 0) return { run, cited, reports: reports.length }
fallback ??= { run, cited, reports: reports.length }
}
if (runs.length < 200) break
before = runs[runs.length - 1].created_at
}
if (fallback) return fallback
if (!seen) return { why: '服务端里没有成功结束的运行' }
return { why: `${seen} 次成功结束的运行里 ${sealedCount} 次已封存,没有一次封存核对一致且带报告文档` }
}
let picked = null
let html = ''
await section('导出', async () => {
picked = await findRun()
if (!picked.run) {
check('找到一份已封存、核对一致、有报告文档的运行', false, picked.why)
return
}
const { run } = picked
check('找到一份已封存、核对一致、有报告文档的运行', true,
`运行 ${run.id.slice(0, 8)},${picked.reports} 份报告、${picked.cited} 个带出处的数字`)
const before = await getJson(`/runs/${run.id}/events`).catch(() => null)
const resp = await fetch(`${API}/runs/${run.id}/bundle.html`)
const body = await resp.text()
const ok = resp.status === 200 && /^text\/html/.test(resp.headers.get('content-type') ?? '')
check('导出接口返回 200 和 text/html', ok, `HTTP ${resp.status},${Math.round(Buffer.byteLength(body) / 1024)} KB`)
// 只有真拿到了页面,后面几节才有东西可查;没拿到的原因上面已经报了,后面几节跳过,不重复报
html = ok && DATA_RE.test(body) ? body : ''
const disposition = resp.headers.get('content-disposition') ?? ''
const star = /filename\*=UTF-8''([^;]+)/.exec(disposition)?.[1]
let name = ''
try { name = star ? decodeURIComponent(star) : '' } catch { name = '' }
check('附件名按 RFC 5987 编码,以「-可验证报告.html」结尾', disposition.startsWith('attachment') && name.endsWith('-可验证报告.html'))
check('另带一个纯 ASCII 的文件名给老客户端', /filename="[\x20-\x7e]+"/.test(disposition))
// 页面本身(去掉数据块)不许有任何 http(s):// 引用;数据块里的字是数据,浏览器不会去取
const shell = html.replace(DATA_RE, '$1$3')
check('页面零外链:没有 http(s)://、外部脚本、样式表、图片和字体', !/https?:\/\//i.test(shell)
&& !/<script[^>]*\ssrc\s*=|<link\b|<img\b|@import|url\s*\(/i.test(shell))
check('内容安全策略默认不许连任何地方', /default-src 'none'/.test(shell))
check('数据块里没有裸的 < > &(截不断)', !/[<>&]/.test(DATA_RE.exec(html)?.[2] ?? '<'))
check('封存哈希直接写在页面上(不执行脚本也看得到)', SEAL_RE.exec(html)?.[2] === run.manifest_hash)
const after = await getJson(`/runs/${run.id}/events`).catch(() => null)
check('导出不追加事件', !!before && !!after && before.length === after.length, before && after ? `${before.length} → ${after.length} 条` : '')
// 没封存的,或者还没结束的(失败后接着跑、早年停在审批时封存过的运行留着旧的封存):都该回 409
const unfinished = await getJson('/runs?status=queued,running,interrupted,suspended&limit=5').catch(() => [])
const recent = await getJson('/runs?limit=200').catch(() => [])
const unsealed = [...unfinished, ...recent].find((r) => !r.manifest_hash || !['succeeded', 'failed', 'cancelled'].includes(r.status))
if (unsealed) {
const refused = await fetch(`${API}/runs/${unsealed.id}/bundle.html`)
const body = await refused.json().catch(() => ({}))
check('未封存或未结束的运行回 409 并说明原因', refused.status === 409 && body.code === 'bundle_unsealed' && !!body.detail,
`运行 ${unsealed.id.slice(0, 8)}(${unsealed.manifest_hash ? '留着旧封存' : '未封存'})HTTP ${refused.status}`)
} else {
note('数据里没有未封存或未结束的运行,409 由服务端单测覆盖')
}
})
// ---------------------------------------------------------------- 断网打开
let tamperable = null // 篡改要用:原始数据
const skipped = () => {
if (html) return false
note('没有拿到导出的页面(原因见上一节),这一节跳过')
return true
}
await section('断网打开', async () => {
if (skipped()) return
tamperable = dataOf(html)
const { ctx, page, requests, errors } = await openFile(writePage('bundle.html', html))
try {
const s = await stateOf(page)
check('总览通过', s.overall === 'pass', `${s.overall}:${s.text}`)
for (const key of ['events', 'manifest', 'chain']) check(`核对项「${key}」通过`, s.checks[key] === 'pass', s.checks[key])
// 遮罩快照、服务端已没有文件的工件是正常的无法核对;没有出处的数字同理
check('核对项「artifacts」没有不通过', noFail(s.checks.artifacts), s.checks.artifacts)
check('数字与取值没有不通过的', noFail(s.checks.numbers) || s.checks.numbers === 'na',
`${s.checks.numbers}:${JSON.stringify(s.numberStates)}`)
// 早期运行的运行结果、升级前的运行类别核对不了(无法核对),只要求没有对不上的
check('运行信息没有与已核对的事件对不上的', s.checks.meta !== 'fail', `${s.checks.meta}:${JSON.stringify(s.meta)}`)
check('没有发出任何网络请求', requests.length === 0, requests.slice(0, 3).join('、'))
check('页面没有报错', errors.length === 0, errors.slice(0, 2).join(' | '))
check('用的是浏览器内置的 SHA-256', s.sha === 'subtle', String(s.sha))
check('封存哈希醒目地写在页面上', s.seal === tamperable.meta.manifest_hash)
// 页面列出的清单原文:拿出去用别的工具(这里是 node 的 crypto)算,等于封存哈希——读的人可以不信页面上的脚本
const lines = await page.locator('[data-manifest-lines]').textContent()
check('页面列出的清单原文在页面之外重算,等于封存哈希', sha(lines ?? '') === tamperable.meta.manifest_hash)
const sealNote = await page.locator('#seal-note').innerText()
check('页面写明只证明内部一致、真伪要去系统里对照', /内部一致/.test(sealNote) && /\/api\/runs\/[^/]+\/verify/.test(sealNote))
check('页面写明哪几项取自运行记录、不在封存范围内', /取自运行记录/.test(await page.locator('#record-note').innerText()))
if (s.numbers) {
const first = page.locator('button.num[data-kind="number"][data-result="pass"]').first()
const target = (await first.count()) ? first : page.locator('button.num').first()
const key = await target.getAttribute('data-num')
await target.click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const text = await page.locator('#detail').innerText()
const cited = await target.getAttribute('data-kind') === 'number'
check('点开数字看得到出处和核对结果', /出处/.test(text) && /核对结果/.test(text)
&& (!cited || /显示格式由服务端渲染/.test(text)) && await page.locator('#detail').getAttribute('data-open') === key)
await page.keyboard.press('Escape')
check('Esc 关掉出处面板', await page.locator('#detail').isHidden())
} else {
check('报告里有可点的数字', picked?.cited === 0, '证据图说有带出处的数字,页面上却一个都没有')
}
} finally {
await ctx.close()
}
})
await section('去掉 crypto.subtle', async () => {
if (skipped()) return
const { ctx, page, requests, errors } = await openFile(join(TMP, 'bundle.html'), { noSubtle: true })
try {
const s = await stateOf(page)
check('照常全部通过', s.overall === 'pass' && !Object.values(s.checks).includes('fail'), describe(s))
check('页脚写明用的是本页内置的实现', s.sha === 'js', String(s.sha))
check('没有发出任何网络请求、没有报错', !requests.length && !errors.length, [...requests, ...errors].slice(0, 2).join(' | '))
} finally {
await ctx.close()
}
})
// ---------------------------------------------------------------- 篡改
/**
* 在一份页面上篡改后断网打开。mutate(data) 改数据、返回说明(找不到可改的返回 null);expect(state) 返回
* [[检查名, 条件, 说明]…]。seal:同时把页面顶部的封存哈希换成改过的数据里的那一份(重算全部哈希的伪造会这么做)
*/
async function tamper(source, label, mutate, expect, { seal = false } = {}) {
const data = structuredClone(dataOf(source))
const what = mutate(data)
if (!what) {
check(`篡改:${label}`, false, '这份页面里找不到可以篡改的对象')
return
}
let page = withData(source, data)
if (seal) page = withSeal(page, data.meta.manifest_hash)
const opened = await openFile(writePage(`tampered-${label.replace(/[^\w一-鿿]+/g, '_')}.html`, page))
try {
const s = await stateOf(opened.page)
for (const [name, cond, detail] of expect(s, what)) check(`篡改${label}:${name}`, cond, detail ?? `${typeof what === 'string' ? what : what.what};${describe(s)}`)
} finally {
await opened.ctx.close()
}
}
/** 一项变红、总览不通过,另几项仍通过 */
const turnsRed = (key, keepPass = []) => (s) => [
[`「${key}」不通过、总览不通过`, s.checks[key] === 'fail' && s.overall === 'fail' && s.failsIn[key] >= 1],
...keepPass.map((k) => [`「${k}」仍没有不通过`, noFail(s.checks[k]), s.checks[k]]),
]
await section('篡改', async () => {
if (skipped() || !tamperable) return
// ① 改一条带规范字符串的事件内容(不动它记的哈希):只有 ① 变红,清单照样对得上
await tamper(html, '一条事件的内容', (data) => {
const ev = data.events.find((e) => e.type === 'run.started' && typeof e.canonical === 'string')
?? data.events.find((e) => typeof e.canonical === 'string')
if (!ev) return null
ev.canonical = /\d/.test(ev.canonical) ? ev.canonical.replace(/\d/, (d) => String((Number(d) + 1) % 10)) : `${ev.canonical} `
return `第 ${ev.seq} 条(${ev.type})`
}, turnsRed('events', ['manifest']))
// ② 改一份嵌入工件的一个字节:优先口径卡、快照,没有就改报告文档
await tamper(html, '一份工件的一个字节', (data) => {
const embedded = data.artifacts.filter((a) => typeof a.b64 === 'string')
const art = embedded.find((a) => a.kind !== 'report_doc') ?? embedded[0]
return art ? flipByte(art) : null
}, turnsRed('artifacts', ['events', 'manifest']))
// 伪造:改报告里一个数字的引用值,再把文档、引用它的事件、清单、页面顶部的封存哈希全部重算
await tamper(html, '一个数字的引用值并重算全部哈希', forge, (s, forged) => [
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain'])(s),
['那份报告标题旁的徽标跟着不通过', s.reports.find((r) => r.node === forged.node)?.result === 'fail',
JSON.stringify(s.reports.map((r) => [r.node, r.result]))],
], { seal: true })
// ③ 改元信息里的封存哈希
await tamper(html, '元信息里的封存哈希', (data) => {
const h = data.meta.manifest_hash
if (typeof h !== 'string' || !h) return null
data.meta.manifest_hash = h.slice(0, -1) + (h.endsWith('0') ? '1' : '0')
return '最后一位'
}, turnsRed('manifest', ['events', 'artifacts']))
})
// ---------------------------------------------------------------- 深浅色、窄屏
const luminance = (rgb) => {
const [r, g, b] = (rgb.match(/\d+(\.\d+)?/g) ?? ['0', '0', '0']).slice(0, 3).map(Number).map((v) => {
const c = v / 255
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
})
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
await section('深浅色与窄屏', async () => {
if (skipped()) return
for (const colorScheme of ['light', 'dark']) {
const { ctx, page } = await openFile(join(TMP, 'bundle.html'), { colorScheme, width: 390 })
try {
const s = await stateOf(page)
check(`${colorScheme === 'dark' ? '深色' : '浅色'} 390 宽:不横向溢出`, s.overflow <= 0, `溢出 ${s.overflow}px`)
const colors = await page.evaluate(() => {
const body = getComputedStyle(document.body)
const doc = document.querySelector('.doc') ?? document.body
return { bg: body.backgroundColor, fg: getComputedStyle(doc).color }
})
const [bg, fg] = [luminance(colors.bg), luminance(colors.fg)]
const ratio = (Math.max(bg, fg) + 0.05) / (Math.min(bg, fg) + 0.05)
check(`${colorScheme === 'dark' ? '深色' : '浅色'}:底色${colorScheme === 'dark' ? '深' : '浅'}、正文对比度够`,
(colorScheme === 'dark' ? bg < 0.1 : bg > 0.8) && ratio >= 7, `对比度 ${ratio.toFixed(1)}`)
if (s.numbers) {
await page.locator('button.num').first().click()
const box = await page.locator('#detail').boundingBox()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
check(`${colorScheme === 'dark' ? '深色' : '浅色'} 390 宽:出处面板从底部抽出、不溢出`,
!!box && box.width <= 390 && box.y > 0 && overflow <= 0, box ? `面板 ${Math.round(box.width)}×${Math.round(box.height)}` : '')
}
} finally {
await ctx.close()
}
}
})
// ---------------------------------------------------------------- 样本页
/** tests/bundle_samples.py 生成的页面:{名字: HTML}。生成不了时抛出,原因写清 */
function samplePages() {
if (!PYTHON) throw new Error('找不到后端解释器,用 CHECK_PYTHON 指定')
const out = join(TMP, 'samples')
const r = spawnSync(PYTHON, ['-m', 'tests.bundle_samples', out], {
cwd: BACKEND, encoding: 'utf8', timeout: 120_000,
// 从这份工作区的 backend 导入;导入时会建数据目录,指到临时目录里
env: { ...process.env, PYTHONPATH: BACKEND, AGENTLAB_DATA_DIR: join(TMP, 'py-data'), AGENTLAB_SECRET_KEY: 'check-bundle-samples' },
})
if (r.status !== 0) throw new Error(`生成样本页失败:${(r.stderr || String(r.error ?? '')).trim().split('\n').pop()}`)
return Object.fromEntries(readdirSync(out).filter((f) => f.endsWith('.html'))
.map((f) => [f.replace(/\.html$/, ''), readFileSync(join(out, f), 'utf8')]))
}
let samples = null
/** 断网打开一份样本页,交给 fn(state, page) 断言 */
async function sample(name, fn, opts = {}) {
const { ctx, page, requests, errors } = await openFile(writePage(`sample-${name}.html`, samples[name]), opts)
try {
const s = await stateOf(page)
if (requests.length || errors.length) check(`${name}:没有发出请求、没有报错`, false, [...requests, ...errors].slice(0, 2).join(' | '))
await fn(s, page)
} finally {
await ctx.close()
}
}
await section('样本页:各种形状', async () => {
try {
samples = samplePages()
} catch (e) {
check('生成样本页(tests/bundle_samples.py)', false, String(e?.message ?? e))
return
}
check('生成样本页(tests/bundle_samples.py)', Object.keys(samples).length === 9, Object.keys(samples).join('、'))
await sample('base', async (s, page) => {
check('base:总览通过,有无法核对的项时写「可核对的项全部通过」', s.overall === 'pass' && s.text === '可核对的项全部通过',
`${s.overall}:${s.text}`)
check('base:①②④⑥ 通过,③⑤ 部分无法核对', ['events', 'manifest', 'chain', 'meta'].every((k) => s.checks[k] === 'pass')
&& s.checks.artifacts === 'partial' && s.checks.numbers === 'partial', describe(s))
check('base:遮罩快照在 ③ 无法核对,写明「含遮罩列,未随文件附带,只能核对到哈希」',
hasItem(s, 'artifacts', 'na', '含遮罩列,未随文件附带,只能核对到哈希'))
check('base:引用遮罩快照的数字无法核对,原因同上', hasItem(s, 'numbers', 'na', '含遮罩列,未随文件附带,只能核对到哈希'))
check('base:没有出处的数字写明未使用引用标记', hasItem(s, 'numbers', 'na', '该数字未使用引用标记,无法核对其来源'))
const outside = s.items.filter((i) => i.check === 'numbers' && !i.folded)
check('base:⑤ 超过 20 项收起来时,无法核对的列在外面', outside.length === 2 && outside.every((i) => i.result === 'na')
&& s.items.some((i) => i.check === 'numbers' && i.folded), `外面 ${outside.length} 条`)
check('base:⑤ 分开数数字和取值,写明分项', s.sums.numbers.includes('数字 13 个 · 取值 8 个') && s.sums.numbers.includes('无法核对 2'),
s.sums.numbers)
check('base:报告徽标连同数字一起算(部分无法核对),旁边写分项', s.reports[0]?.result === 'partial'
&& s.reports[0].parts.includes('文档 通过') && s.reports[0].parts.includes('无法核对 2'), JSON.stringify(s.reports[0]))
check('base:运行结果、类别、出具档位标着已核对', ['status', 'class', 'tier'].every((k) => s.meta[k]?.result === 'pass'),
JSON.stringify(s.meta))
check('base:封存之后追加的事件单列,标明不在封存范围内',
(await page.locator('[data-appended]').allTextContents()).some((t) => t.includes('封存之后追加,不在封存范围内')))
check('base:斜体渲染成斜体', (await page.locator('.doc em').allTextContents()).includes('按发生周统计'))
const cell = page.locator('td.n button.num')
check('base:表格里数字列右对齐', await cell.count() === 8)
// 「显示格式由服务端渲染」只附在带出处的数字上:模型自己写的数、表格里的文字取值都是原文
const detailOf = async (locator) => {
await locator.click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const text = await page.locator('#detail').innerText()
await page.keyboard.press('Escape')
return text
}
const cellNumber = page.locator('td.n button.num[data-kind="number"]').first()
const value = page.locator('button.num[data-kind="value"]').first()
const bare = page.locator('button.num[data-kind="number"][data-result="na"]').filter({ hasText: /^42$/ })
check('base:带出处的数字注明显示格式由服务端渲染', /显示格式由服务端渲染/.test(await detailOf(cellNumber)))
check('base:文字取值、没有出处的数字不注明显示格式', !/显示格式由服务端渲染/.test(await detailOf(value))
&& !/显示格式由服务端渲染/.test(await detailOf(bare)))
check('base:读屏把文字取值读作「取值」、数字读作「数字」', (await value.getAttribute('aria-label')).startsWith('取值 ')
&& (await cellNumber.getAttribute('aria-label')).startsWith('数字 '))
})
// 宽屏:出处面板停在右侧,正文让出这一栏。点开表格里右对齐的数字,它和同列的数字都不被面板盖住
for (const width of [1280, 1440]) {
await sample('base', async (s, page) => {
const cells = page.locator('td.n button.num')
await cells.nth(2).click()
await page.locator('#detail').waitFor({ state: 'visible', timeout: 5000 })
const covered = await cells.nth(2).evaluate((el) => {
const r = el.getBoundingClientRect()
const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
return !!at && !!at.closest('#detail')
})
const panel = await page.locator('#detail').boundingBox()
const spot = await cells.nth(2).boundingBox()
check(`${width} 宽:点开的数字不被出处面板盖住`, !covered && !!panel && !!spot && spot.x + spot.width <= panel.x,
spot && panel ? `数字 ${Math.round(spot.x)}–${Math.round(spot.x + spot.width)},面板从 ${Math.round(panel.x)} 起` : '')
const next = await cells.nth(4).click({ timeout: 3000 }).then(() => true, () => false)
check(`${width} 宽:面板开着时同列别的数字照样点得到`, next
&& await page.locator('#detail').getAttribute('data-open') === await cells.nth(4).getAttribute('data-num'))
check(`${width} 宽:不横向溢出`, await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth))
}, { width })
}
await sample('two-reports', async (s) => {
check('two-reports:两份报告各自显示,引用链都通过', s.reports.length === 2 && s.checks.chain === 'pass'
&& s.items.filter((i) => i.check === 'chain' && i.text.includes('的文档')).length === 2, JSON.stringify(s.reports.map((r) => r.node)))
check('two-reports:月报的数字全部通过', s.reports.find((r) => r.node === 'monthly')?.result === 'pass')
})
await sample('legacy', async (s, page) => {
check('legacy:总览通过,范围写明按结束事件推算', s.overall === 'pass'
&& (await page.locator('#meta').innerText()).includes('早期运行,范围按结束事件推算'), s.text)
check('legacy:运行结果无法核对,写明早期运行的结束事件不在封存范围内', s.meta.status?.result === 'na'
&& hasItem(s, 'meta', 'na', '早期运行在结束事件落库之前封存'), JSON.stringify(s.meta.status))
check('legacy:运行结束事件列在封存之后', (await page.locator('[data-appended]').allTextContents()).some((t) => t.includes('运行结束')))
})
await sample('failed', async (s, page) => {
check('failed:运行结果「已失败」与运行失败事件对上', s.meta.status?.result === 'pass' && s.meta.status.text.includes('已失败'))
check('failed:封存说明写明之后继续运行会重新封存', await page.locator('#seal-failed').isVisible()
&& (await page.locator('#seal-failed').innerText()).includes('重新封存'))
})
await sample('base', async (_, page) => {
check('成功结束的运行不提重新封存', await page.locator('#seal-failed').isHidden())
})
await sample('degraded', async (s) => {
check('degraded:出具档位认最后一条出具判定(降档出具)', s.meta.tier?.result === 'pass' && s.meta.tier.text.includes('降档出具'),
JSON.stringify(s.meta.tier))
})
await sample('missing-doc', async (s, page) => {
check('missing-doc:总览不是绿色的通过,写明有报告未能核对', s.overall === 'incomplete' && s.text.includes('份报告未能核对'),
`${s.overall}:${s.text}`)
check('missing-doc:报告处写明导出时服务端已没有这份报告文档', s.reports[0]?.why === 'missing'
&& s.reports[0].warn.includes('导出时服务端已没有这份报告文档'), s.reports[0]?.warn)
check('missing-doc:③ ④ 无法核对,原因写明', hasItem(s, 'artifacts', 'na', '导出时服务端已没有这份工件的文件')
&& hasItem(s, 'chain', 'na', '导出时服务端已没有这份报告文档'))
check('missing-doc:总览框不是通过的绿底', await page.locator('#overall').evaluate((el) => {
const probe = document.body.appendChild(document.createElement('div'))
probe.style.background = 'var(--ok-soft)'
const ok = getComputedStyle(probe).backgroundColor
probe.remove()
return getComputedStyle(el).backgroundColor !== ok
}))
})
await sample('missing-card', async (s) => {
check('missing-card:引用口径卡的数字无法核对,写明服务端已没有文件', hasItem(s, 'numbers', 'na', '导出时服务端已没有这份工件的文件'))
check('missing-card:服务端缺文件是正常的无法核对,总览通过', s.overall === 'pass', `${s.overall}:${s.text}`)
})
await sample('nan', async (s) => {
check('nan:内容里有裸 NaN 的事件和工件照常通过', s.overall === 'pass' && s.checks.events === 'pass'
&& !s.items.some((i) => i.result === 'fail'), describe(s))
})
await sample('stray', async (s) => {
check('stray:另一次运行的文档,④ 不通过并写明', s.overall === 'fail' && hasItem(s, 'chain', 'fail', '文档记下的运行或节点与这次不符'))
check('stray:文档没通过核对时数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')
&& s.reports[0]?.warn.includes('其中的数字不予核对'), s.checks.numbers)
})
// 不执行脚本(附件预览、快速查看):封存哈希、运行 ID、工作流名照样看得到,状态写「尚未核对」,不是一直「正在核对」
{
const data = dataOf(samples.base)
const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, javaScriptEnabled: false })
try {
const page = await ctx.newPage()
await page.goto(pathToFileURL(writePage('sample-nojs.html', samples.base)).href)
check('不执行脚本:封存哈希写在页面上', await page.locator('#seal-hash').innerText() === data.meta.manifest_hash)
check('不执行脚本:运行 ID、工作流名写在页面上', (await page.locator('#seal-note').innerText()).includes(data.meta.run_id)
&& await page.locator('#title').innerText() === data.meta.workflow_name)
check('不执行脚本:状态写「尚未核对」,不显示「正在核对」', await page.locator('.no-js').isVisible()
&& (await page.locator('#overall').innerText()).includes('尚未核对') && !(await page.locator('#overall').innerText()).includes('正在核对'))
} finally {
await ctx.close()
}
}
})
await section('样本页:篡改', async () => {
if (!samples) {
note('没有样本页(原因见上一节),这一节跳过')
return
}
const base = samples.base
const byKind = (data, kind) => data.artifacts.find((a) => a.kind === kind)
// ④ 引用链
await tamper(base, '报告记录指向另一份工件', (data) => {
data.reports[0].doc_artifact = byKind(data, 'metric_set').id
return '报告的文档换成口径卡'
}, (s) => [...turnsRed('chain')(s), ['原因写明', hasItem(s, 'chain', 'fail', '这个节点最后一次报告自查记下的不是这份文档')]])
await tamper(base, '拿掉报告记录', (data) => {
data.reports = []
return '清空 reports'
}, (s) => [...turnsRed('chain')(s), ['原因写明', hasItem(s, 'chain', 'fail', '运行记录里有这个报告节点的自查事件,文件里却没有附带它的文档')]])
await tamper(base, '多一份没人引用的工件', (data) => {
const bytes = Buffer.from(JSON.stringify({ columns: ['n'], rows: [[7]], source: 'shop', sql: 'SELECT 7 AS n' }), 'utf8')
data.artifacts.push({ id: sha(bytes), kind: 'query', node_id: 'fetch', b64: bytes.toString('base64') })
return '追加一份哈希正确的快照'
}, (s) => [...turnsRed('chain', ['artifacts'])(s), ['原因写明', hasItem(s, 'chain', 'fail', '已核对的事件和报告文档里都没有引用这份工件')]])
// 服务端不会给出的状态:只能是改过
await tamper(base, '拿掉被引用的口径卡', (data) => {
data.artifacts = data.artifacts.filter((a) => a.kind !== 'metric_set')
return '删掉口径卡条目'
}, (s) => [...turnsRed('numbers')(s), ['原因写明', hasItem(s, 'numbers', 'fail', '引用的工件不在文件的工件清单里')]])
await tamper(base, '口径卡标成遮罩', (data) => {
const card = byKind(data, 'metric_set')
delete card.b64
card.masked = true
return '口径卡去掉内容、标遮罩'
}, (s) => [...turnsRed('artifacts')(s), ['原因写明', hasItem(s, 'artifacts', 'fail', '只有查询快照会因遮罩不随文件附带')]])
await tamper(base, '报告文档的一个字节', (data) => flipByte(byKind(data, 'report_doc')), (s) => [
...turnsRed('artifacts')(s),
['数字不予核对', s.checks.numbers === 'na' && hasItem(s, 'numbers', 'na', '所在的报告文档没有通过核对')],
['报告处写明没有通过核对', s.reports[0]?.result === 'fail' && s.reports[0].why === 'untrusted'],
])
// ⑥ 运行信息:改元信息不动事件,清单照样对得上,只有 ⑥ 变红,页面上显示的是事件里的值
await tamper(base, '运行结果', (data) => {
data.meta.status = 'failed'
return 'succeeded → failed'
}, (s) => [...turnsRed('meta', ['events', 'manifest'])(s),
['页面显示事件里的「已完成」并标明文件里写的不同', s.meta.status?.result === 'fail' && s.meta.status.text.includes('已完成')
&& s.meta.status.text.includes('已失败'), JSON.stringify(s.meta.status)]])
await tamper(base, '运行类别', (data) => {
data.meta.run_class = 'exploratory'
return 'formal → exploratory'
}, (s) => [...turnsRed('meta')(s), ['原因写明', hasItem(s, 'meta', 'fail', '记下的运行参数不符')]])
await tamper(base, '去掉出具档位', (data) => {
data.meta.tier = null
return 'tier → null'
}, (s) => [...turnsRed('meta')(s), ['页面显示事件里的档位', s.meta.tier?.result === 'fail' && s.meta.tier.text.includes('完整出具')]])
await tamper(samples.degraded, '出具档位改指早先那条', (data) => {
const first = data.events.find((e) => e.type === 'issuance')
data.meta.tier = { value: 'formal', seq: first.seq }
return `指向第 ${first.seq} 条(完整出具)`
}, (s) => [...turnsRed('meta')(s),
['页面显示最后一条的降档出具', s.meta.tier?.result === 'fail' && s.meta.tier.text.includes('降档出具'), JSON.stringify(s.meta.tier)]])
// 只改页面顶部静态写出的封存哈希:数据里的没动,② 的清单照样对得上,但顶上那份对不上,核对后换成核对用的
{
const data = dataOf(base)
const fake = data.meta.manifest_hash.replace(/^./, (c) => (c === '0' ? '1' : '0'))
const opened = await openFile(writePage('tampered-seal.html', withSeal(base, fake)))
try {
const s = await stateOf(opened.page)
check('篡改页面顶部的封存哈希:② 不通过、总览不通过', s.checks.manifest === 'fail' && s.overall === 'fail'
&& hasItem(s, 'manifest', 'fail', '页面被改过'), describe(s))
check('篡改页面顶部的封存哈希:核对后顶上显示核对用的那一份', s.seal === data.meta.manifest_hash)
} finally {
await opened.ctx.close()
}
}
// 重算全部哈希的伪造在样本页上同样露馅:⑤ 不通过,报告徽标跟着不通过
await tamper(base, '样本页上重算全部哈希的伪造', forge, (s) => [
...turnsRed('numbers', ['events', 'manifest', 'artifacts', 'chain', 'meta'])(s),
['报告徽标不通过', s.reports[0]?.result === 'fail' && s.reports[0].parts.includes('不通过 1'), JSON.stringify(s.reports[0])],
], { seal: true })
})
// ---------------------------------------------------------------- 记录页的导出按钮
const SAMPLE_NAME = '门店周报-可验证报告.html'
// 和服务端 api/bundle.py 的 SEAL_BROKEN 一字不差(服务端单测断言那一边):提示里原样显示
const SEAL_BROKEN = '封存核对不一致:封存后有事件被修改、删除或插入,不能导出可验证报告。点「核对」可查看核对结果'
async function preview(syn, { width = 1280, bundle } = {}) {
const ctx = await browser.newContext({ viewport: { width, height: 700 }, acceptDownloads: true })
const page = await ctx.newPage()
const hits = []
// 后注册的路由先匹配:兜底的先注册,假装导出接口的后注册,导出请求才落到假接口上。
// 别的写接口一律掐掉:预览用例只该请求导出接口
await page.route('**/api/**', (route) => (route.request().method() === 'GET' ? route.continue() : route.abort()))
await page.route('**/api/runs/*/bundle.html', (route) => {
hits.push(new URL(route.request().url()).pathname)
return route.fulfill(bundle ?? {
status: 200,
headers: { 'content-type': 'text/html; charset=utf-8',
'content-disposition': `attachment; filename="agentlab-f4b0f4b0-bundle.html"; filename*=UTF-8''${encodeURIComponent(SAMPLE_NAME)}` },
body: '<!doctype html><title>bundle</title>',
})
})
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
await page.goto(`${WEB}/preview.html?syn=${syn}`, { waitUntil: 'networkidle' })
await page.locator('[data-fixture="bundle-provenance"]').waitFor({ timeout: 15_000 })
return { ctx, page, hits, errors }
}
await section('记录页的导出按钮', async () => {
{
const { ctx, page, hits, errors } = await preview('bundle-provenance-sealed')
try {
const button = page.locator('[data-bundle-export]')
check('已封存:导出按钮可点', await button.isEnabled() && await button.getAttribute('data-bundle-export') === 'ready')
check('按钮写着「导出可验证报告」', (await button.innerText()).includes('导出可验证报告'))
const [download] = await Promise.all([page.waitForEvent('download', { timeout: 10_000 }), button.click()])
check('下载的文件名同服务端(RFC 5987 的中文名)', download.suggestedFilename() === SAMPLE_NAME, download.suggestedFilename())
check('请求的是这次运行的导出接口', hits.length === 1 && hits[0] === '/api/runs/f4b0f4b0f4b0f4b0f4b0f4b0f4b0f4b0/bundle.html',
hits.join('、'))
check('导出后提示文件名', await appears(page.getByText(`已导出「${SAMPLE_NAME}」`)))
check('页面没有报错', !errors.length, errors.slice(0, 2).join(' | '))
} finally {
await ctx.close()
}
}
{
const refusal = { status: 409, contentType: 'application/json', body: JSON.stringify({ detail: SEAL_BROKEN, code: 'bundle_seal_broken' }) }
const { ctx, page } = await preview('bundle-provenance-sealed', { bundle: refusal })
try {
await page.locator('[data-bundle-export]').click()
check('服务端回 409 时原样提示原因', await appears(page.getByText(SEAL_BROKEN)))
check('提示之后按钮恢复可点', await appears(page.locator('[data-bundle-export="ready"]')))
} finally {
await ctx.close()
}
}
{
// 失败结束的运行:导出前后都说明之后继续运行会重新封存
const { ctx, page } = await preview('bundle-provenance-failed')
try {
const button = page.locator('[data-bundle-export]')
const hover = await button.locator('xpath=..').getAttribute('title')
check('失败结束:可以导出,悬停提示写明继续运行会重新封存', await button.isEnabled() && (hover ?? '').includes('重新封存'), hover ?? '')
const [download] = await Promise.all([page.waitForEvent('download', { timeout: 10_000 }), button.click()])
check('失败结束:导出后的提示写明继续运行会重新封存', !!download
&& await appears(page.getByText(`已导出「${SAMPLE_NAME}」。之后若继续运行,系统会重新封存`, { exact: false })))
} finally {
await ctx.close()
}
}
for (const [syn, reason, shown] of [['bundle-provenance-running', '运行结束并封存后才能导出', false],
['bundle-provenance-waiting', '等待审批中', false], ['bundle-provenance-unsealed', '没有封存清单', false],
['bundle-provenance-again', '运行结束并封存后才能导出', true]]) {
const { ctx, page, hits } = await preview(syn)
const label = syn.replace('bundle-provenance-', '')
try {
const button = page.locator('[data-bundle-export]')
const described = await button.getAttribute('aria-describedby')
const spoken = described ? await page.locator(`[id="${described}"]`).innerText() : ''
const hover = await page.locator('[data-bundle-export]').locator('xpath=..').getAttribute('title')
check(`${label}:按钮不可点,说明原因`, await button.isDisabled()
&& spoken.includes(reason) && (hover ?? '').includes(reason), spoken || '没有说明')
if (shown) {
// 左边写着「已封存」,按钮却不能点:原因要写在屏幕上,触屏看不到悬停提示
const visible = page.locator('[data-bundle-blocked="shown"]')
const box = await visible.boundingBox()
check(`${label}:左边写着「已封存」时,原因直接写在按钮旁`, await page.locator('[data-seal="sealed"]').isVisible()
&& await visible.isVisible() && !!box && box.width > 20 && (await visible.innerText()).includes(reason))
}
await button.click({ force: true }).catch(() => {})
check(`${label}:点了也不请求导出接口`, hits.length === 0)
} finally {
await ctx.close()
}
}
for (const syn of ['bundle-provenance-sealed', 'bundle-provenance-again']) {
const { ctx, page } = await preview(syn, { width: 390 })
try {
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
const box = await page.locator('[data-bundle-export]').boundingBox()
check(`390 宽(${syn.replace('bundle-provenance-', '')}):凭证条不横向溢出,导出按钮在屏幕内`,
overflow <= 0 && !!box && box.x >= 0 && box.x + box.width <= 390, `溢出 ${overflow}px`)
} finally {
await ctx.close()
}
}
})
await cleanup()
if (failed) {
console.log(`\n✗ 可验证报告:${failed} 项未通过`)
process.exit(1)
}
console.log('\n✓ 可验证报告:全部通过')