aps-agent/tests/golden/test_fallback_highrisk.py

914 lines
40 KiB
Python
Raw Normal View History

# ============================================================
# 智能兜底 P3(高风险场景 S4/S6/S7 + 白名单治理)黄金测试 —— 全部确定性:
# fake runner 注入、FakeStore 挂 .checkpoints、白名单文件指 tmp、
# MES 客户端注入 tmp 镜像、bind_identity 绑角色(test_approval_role_policies 先例)。
# 覆盖 P3-DESIGN §8 测试矩阵 H-1..H-21。不依赖真实 node/pi/网络/LLM/MES。
# ============================================================
from __future__ import annotations
import contextlib
import copy
import hashlib
import json
from pathlib import Path
from typing import ClassVar
import pytest
from server.agent_core import fallback_highrisk, fallback_lane, harness
from server.agent_core.providers import reset_provider
from server.aps_domain.workflow import execute_confirmed
from server.auth.context import IdentityContext, bind_identity, reset_identity
from server.contracts import IntentResult
from server.state.checkpoints import CheckpointStore
from server.state.seed import seed_world
DEMO_PRODUCT = "CTRL-A" # demo 世界成品(seed_world APS_SEED_DEMO=1)
@pytest.fixture(autouse=True)
def _isolate(tmp_path, monkeypatch):
"""环境隔离:run 目录 / 开关文件 / P3 白名单文件指 tmp;清掉 LLM env。"""
monkeypatch.setenv("APS_FALLBACK_DIR", str(tmp_path / "fb"))
monkeypatch.setenv("APS_FEATURES_PATH", str(tmp_path / "features.json"))
monkeypatch.setenv("APS_FALLBACK_HIGHRISK_PATH", str(tmp_path / "fallback-highrisk.json"))
monkeypatch.delenv("LLM_API_KEY", raising=False)
monkeypatch.delenv("LLM_BASE_URL", raising=False)
monkeypatch.delenv("LLM_MODEL", raising=False)
monkeypatch.delenv("LLM_PROVIDER", raising=False)
reset_provider()
from server.state import checkpoints as _cp_mod
from server.state import store as _store_mod
kept_scoped = dict(_store_mod._stores) # scoped 仓注册表快照(防跨测试泄漏)
kept_cps = dict(_cp_mod._checkpoints) # checkpoint 仓注册表快照(同上)
yield
_store_mod._stores.clear()
_store_mod._stores.update(kept_scoped)
_cp_mod._checkpoints.clear()
_cp_mod._checkpoints.update(kept_cps)
reset_provider()
class FakeStore:
"""与 test_fallback_execute 同款:挂 .checkpoints 注入点;restore 深拷贝整体替换。"""
_KIND_TABLE: ClassVar[dict[str, str]] = {
"salesOrder": "salesOrders", "material": "materials",
"audit": "auditEvents", "importBatch": "importBatches"}
def __init__(self, tmp_path: Path):
self.data = seed_world()
self._counters: dict[str, int] = {}
self.tenant_uuid = "platform"
self.world_key = "default"
self.path = str(tmp_path / "world.json") # get_checkpoints/get_plan_store 取目录用
self.checkpoints = CheckpointStore(str(tmp_path / "checkpoints.json"))
def next_id(self, kind: str) -> int:
if kind not in self._counters:
table = self._KIND_TABLE.get(kind)
self._counters[kind] = max(
(x.get("id", 0) for x in self.data.get(table, [])
if isinstance(x.get("id"), int)), default=0) if table else 0
self._counters[kind] += 1
return self._counters[kind]
def save(self) -> None:
pass
def restore(self, world: dict) -> None:
self.data = copy.deepcopy(world)
self._counters.clear()
def _bind_personal_world(store: FakeStore, user_id: int) -> None:
"""身份绑定场景下把 FakeStore 注入 scoped 仓注册表(personal-{uid})。
绑定身份后出卡记录的 worldKey=personal-{uid},resolve_confirmation_store
会绕过测试仓直接解析 scoped 仓(审计/漂移比对落到别的世界)——注入后
出卡指纹捕获、执行端解析、审计落链全部回到本测试仓。
"""
from server.state import store as store_mod
store.world_key = f"personal-{user_id}"
store_mod._stores[("platform", store.world_key)] = store
def _cfg(tmp_path: Path, **kw) -> fallback_lane.FallbackConfig:
return fallback_lane.FallbackConfig(pi_home=str(tmp_path / "pi-home"), **kw)
def _write_features(tmp_path: Path, features: dict) -> None:
(tmp_path / "features.json").write_text(
json.dumps({"version": 1, "features": features}, ensure_ascii=False),
encoding="utf-8")
def _write_whitelist(tmp_path: Path, doc) -> None:
path = tmp_path / "fallback-highrisk.json"
if isinstance(doc, str):
path.write_text(doc, encoding="utf-8") # 损坏形态直接写原文
else:
path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8")
def _wl_doc(*, s4=True, s6=True, s7=True, s6_intents=None, s6_max=None,
s7_roles=("ops", "admin")) -> dict:
scenarios = {
"S4": {"enabled": s4,
"intents": ["flex.simulate_due", "flex.compare",
"scenario.compare", "scenario.sensitivity"],
"roles": ["planner", "admin"]},
"S6": {"enabled": s6, "intents": list(s6_intents or ["mes.report"]),
"roles": ["planner", "admin"]},
"S7": {"enabled": s7,
"intents": ["agent.fallback.ops.config.apply",
"agent.fallback.policy.update"],
"roles": list(s7_roles)},
}
if s6_max is not None:
scenarios["S6"]["maxItemsPerRun"] = s6_max
return {"whitelistVersion": 1, "updatedAt": "2026-09-04T10:00:00",
"updatedBy": "test", "scenarios": scenarios}
def _intent(query: str, name: str = "unknown", **params) -> IntentResult:
return IntentResult(intent=name, params={"query": query, **params},
confidence=0.1, source="LLM")
def _fp(world: dict) -> str:
return harness.world_fingerprint(world)
@contextlib.contextmanager
def _as(user_id: int, *roles: str):
"""bind_identity 绑角色(test_approval_role_policies.py `_as` 先例)。"""
token = bind_identity(IdentityContext(
user_id, f"user-{user_id}", f"User {user_id}", "platform", roles=tuple(roles)))
try:
yield
finally:
reset_identity(token)
def _run_dir_of(tmp_path: Path) -> Path:
runs = [p for p in (tmp_path / "fb").iterdir() if p.is_dir() and p.name != "pi-home"]
assert len(runs) == 1
return runs[0]
def _pending_record(confirm_id: str) -> dict:
return harness._approval_store.pending[confirm_id]
def make_plan_runner(plan_builder, report: str = "status: success\n\n已生成执行计划。"):
"""propose 段 fake runner:先写 outbox/plan.json,再 stop 报告。"""
def runner(task: str, work_dir: Path):
run_dir = work_dir.parent
plan = plan_builder(run_dir)
if plan is not None:
(run_dir / "outbox").mkdir(parents=True, exist_ok=True)
(run_dir / "outbox" / "plan.json").write_text(
json.dumps(plan, ensure_ascii=False), encoding="utf-8")
yield {"type": "message_end", "message": {"role": "assistant",
"stopReason": "stop", "content": [{"type": "text", "text": report}]}}
yield {"type": "agent_end", "messages": []}
return runner
def _stop_runner(task: str, work_dir: Path):
"""只产 stop 报告的 fake runner(对账叙事段 / 只读诊断用)。"""
yield {"type": "message_end", "message": {"role": "assistant",
"stopReason": "stop",
"content": [{"type": "text", "text": "status: success\n\n建议人工核对差异项。"}]}}
yield {"type": "agent_end", "messages": []}
async def _stage(store: FakeStore, tmp_path: Path, runner,
query: str = "把这份客户表格导进来"):
"""propose 出卡辅助:返回 (reply, confirm_id_list)。"""
reply = await fallback_lane.propose_reply(
store, "s1", _intent(query), runner=runner, config=_cfg(tmp_path))
confirm_ids = []
if reply is not None and getattr(reply, "blocks", None):
confirm_ids = [b.props["confirmId"] for b in reply.blocks
if getattr(b, "type", "") == "confirm-card"]
return reply, confirm_ids
def _approve(store: FakeStore, confirm_id: str, *, approve: bool = True,
actor: str = "tester") -> str:
return execute_confirmed(store, confirm_id, approve=approve, actor=actor)
# -- S6 世界与 MES 镜像种子 ----------------------------------------------------
def _seed_s6_world(store: FakeStore, items: list[tuple]) -> None:
"""items: (wo_id, ext_id, status, progressPct, qtyDone) → workOrders + mesLinks。"""
store.data.setdefault("workOrders", [])
store.data.setdefault("mesLinks", [])
for wo_id, ext_id, status, pct, qty in items:
store.data["workOrders"].append({
"id": wo_id, "mesExternalId": ext_id, "status": status,
"progressPct": pct, "qtyDone": qty, "productCode": DEMO_PRODUCT})
store.data["mesLinks"].append({
"kind": "dispatch", "woId": wo_id, "externalWoId": ext_id,
"idemKey": f"idem-{wo_id}"})
def _seed_mes_mirror(tmp_path: Path, monkeypatch, count: int):
"""MockMesClient 指 tmp 镜像并造 count 张外部工单(monkeypatch 自动还原,零仓库污染)。"""
from server.integrations import mes_stub
from server.integrations.mes_stub import MockMesClient
client = MockMesClient(tmp_path / "mes_mirror.json")
monkeypatch.setattr(mes_stub, "_client", client)
for i in range(count):
client.create_work_order({"productCode": DEMO_PRODUCT}, idem_key=f"idem-m-{i}")
return client # 外部工单号 MES-WO-0001..000n
def _s6_plan(items: list[dict]) -> dict:
"""S6 补录计划:每项一个 frozen mes.report 步(params 内联,track=fixed)。"""
return {"planVersion": 1, "scenario": "S6", "goal": "MES 断连补录",
"steps": [{"seq": i + 1, "mode": "frozen", "intent": "mes.report",
"summary": f"补录工单 {p['woId']}", "params": p,
"constraints": {}, "expected": []}
for i, p in enumerate(items)]}
def _s4_plan(steps: list[dict] | None = None) -> dict:
return {"planVersion": 1, "scenario": "S4", "goal": "缺策略沙盒试排",
"steps": steps or [{
"seq": 1, "mode": "frozen", "intent": "flex.simulate_due",
"summary": "交期探测", "constraints": {},
"params": {"productCode": DEMO_PRODUCT, "quantity": 10},
"expected": [{"sandboxOutput": "乐观/预计/悲观完工时间"}],
}]}
def _world_write_audits(store: FakeStore) -> list[dict]:
return [e for e in store.data.get("auditEvents", [])
if e.get("category") == "WORLD_WRITE"]
# ---------------------------------------------------------------------------
# H-1:白名单文件缺失 → fail-closed 全拒;S6 计划出卡拒绝(显式「白名单文件缺失」)
# ---------------------------------------------------------------------------
async def test_whitelist_missing_file_denies_all(tmp_path):
_write_features(tmp_path, {"fallback": True})
store = FakeStore(tmp_path)
fp_before = _fp(store.data)
loaded = fallback_highrisk.load_highrisk_whitelist()
assert loaded["ok"] is False
assert "白名单文件缺失" in loaded["error"]
reply, confirm_ids = await _stage(
store, tmp_path,
make_plan_runner(lambda rd: _s6_plan([{"woId": 1, "progressPct": 50,
"track": "fixed"}])),
query="MES 连不上了帮我补录")
assert confirm_ids == [] # 未生成确认卡
assert "未通过校验" in reply.text
assert "白名单文件缺失" in reply.text
assert _fp(store.data) == fp_before # 世界零变更
# ---------------------------------------------------------------------------
# H-2:白名单 JSON 损坏 → 全拒 + error 显式;S4/S6/S7 计划均拒绝出卡
# ---------------------------------------------------------------------------
async def test_whitelist_corrupt_json_denies_all(tmp_path):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, "{not a json")
store = FakeStore(tmp_path)
loaded = fallback_highrisk.load_highrisk_whitelist()
assert loaded["ok"] is False
assert "JSON 解析失败" in loaded["error"]
for plan in (
_s4_plan(),
_s6_plan([{"woId": 1, "progressPct": 50, "track": "fixed"}]),
{"planVersion": 1, "scenario": "S7", "goal": "x",
"steps": [{"seq": 1, "mode": "frozen",
"intent": "agent.fallback.policy.update",
"params": {"scenario": "S6", "addIntents": ["mes.report"]},
"constraints": {}}]},
):
with pytest.raises(fallback_lane.PlanError, match="JSON 解析失败"):
fallback_lane.validate_plan(plan, tmp_path, world=store.data)
# ---------------------------------------------------------------------------
# H-3:未知场景键 → 整个文件判损坏全拒(不是忽略未知键——与 features.json 相反)
# ---------------------------------------------------------------------------
def test_whitelist_unknown_scenario_key_denies_all(tmp_path):
doc = _wl_doc()
doc["scenarios"]["S99"] = {"enabled": True, "intents": [], "roles": ["admin"]}
_write_whitelist(tmp_path, doc)
loaded = fallback_highrisk.load_highrisk_whitelist()
assert loaded["ok"] is False
assert "S99" in loaded["error"]
assert loaded["grants"] == {} # 全拒,不做局部放行
assert fallback_highrisk.intent_allowed(loaded, "S6", "mes.report") is False
# ---------------------------------------------------------------------------
# H-4:场景段含未知字段 → 全拒 + error 带 offending key
# ---------------------------------------------------------------------------
def test_whitelist_unknown_field_denies_all(tmp_path):
doc = _wl_doc()
doc["scenarios"]["S6"]["backdoor"] = True
_write_whitelist(tmp_path, doc)
loaded = fallback_highrisk.load_highrisk_whitelist()
assert loaded["ok"] is False
assert "backdoor" in loaded["error"]
# ---------------------------------------------------------------------------
# H-5:intents 引用未在 _POWER_MAP 登记的意图 → 全拒 + error 带 intent 名
# ---------------------------------------------------------------------------
def test_whitelist_unregistered_intent_denies_all(tmp_path):
doc = _wl_doc()
doc["scenarios"]["S6"]["intents"] = ["mes.report", "order.explode"]
_write_whitelist(tmp_path, doc)
loaded = fallback_highrisk.load_highrisk_whitelist()
assert loaded["ok"] is False
assert "order.explode" in loaded["error"]
# ---------------------------------------------------------------------------
# H-6:S6.enabled=false → S6 计划拒绝(「未在白名单启用」);S4 不受影响(场景隔离)
# ---------------------------------------------------------------------------
async def test_whitelist_disabled_scenario_refuses_plan(tmp_path):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc(s6=False))
store = FakeStore(tmp_path)
_seed_s6_world(store, [(9001, "MES-WO-0001", "RUNNING", 0, 0)])
reply, confirm_ids = await _stage(
store, tmp_path,
make_plan_runner(lambda rd: _s6_plan([{"woId": 9001, "progressPct": 80,
"track": "fixed"}])),
query="MES 连不上了帮我补录")
assert confirm_ids == []
assert "未在白名单启用" in reply.text
# 场景隔离:S4 计划照常出卡
reply_s4, confirm_s4 = await _stage(
store, tmp_path, make_plan_runner(lambda rd: _s4_plan()),
query="这个工艺没有现成策略,帮我试几个思路")
assert len(confirm_s4) == 1
assert "沙盒" in reply_s4.blocks[0].props["summary"][-2] \
or any("不改动任何正式数据" in line
for line in reply_s4.blocks[0].props["summary"])
# ---------------------------------------------------------------------------
# H-7:白名单 ok 但场景 intents 不含该意图 → 整计划拒绝(P2 T-3 语义的白名单版)
# ---------------------------------------------------------------------------
async def test_p3_intent_not_in_whitelist_refused(tmp_path):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc()) # S6.intents 只有 mes.report
store = FakeStore(tmp_path)
def bad_plan(run_dir: Path) -> dict:
return {"planVersion": 1, "scenario": "S6", "goal": "x",
"steps": [{"seq": 1, "mode": "frozen", "intent": "mes.dispatch",
"params": {"versionId": 1}, "constraints": {}}]}
reply, confirm_ids = await _stage(
store, tmp_path, make_plan_runner(bad_plan), query="MES 连不上了")
assert confirm_ids == []
assert "未通过校验" in reply.text
assert "未列入 S6 白名单放行集" in reply.text
# ---------------------------------------------------------------------------
# H-8:白名单文件缺失时 P2 存量路径零影响(向后兼容核心断言)
# ---------------------------------------------------------------------------
async def test_p2_scenarios_unaffected_by_whitelist(tmp_path):
_write_features(tmp_path, {"fallback": True})
store = FakeStore(tmp_path) # 白名单文件不存在
rows = [{"customerName": "锐扬精密", "productCode": DEMO_PRODUCT,
"quantity": 10, "deliveryDate": "2026-09-20", "orderNo": "RY-9001"}]
def p2_plan(run_dir: Path) -> dict:
return {"planVersion": 1, "scenario": "S3", "goal": "导入订单",
"steps": [{"seq": 1, "mode": "frozen", "intent": "import.commit",
"summary": "导入订单批 1 行",
"params": {"batches": [{"kind": "orders", "rows": rows}]},
"constraints": {"kinds": ["orders"], "maxRows": 500},
"expected": [{"table": "salesOrders", "added": 1}]}]}
reply, confirm_ids = await _stage(store, tmp_path, make_plan_runner(p2_plan))
assert len(confirm_ids) == 1 # P2 照常出卡
assert reply.blocks[0].props["action"] == "agent.fallback.execute"
assert reply.blocks[0].props["power"] == "P2"
# ---------------------------------------------------------------------------
# H-9:出卡后白名单变更 → 执行拒绝(双端 sha256 比对);零写入;DENIED 审计
# ---------------------------------------------------------------------------
async def test_whitelist_changed_between_stage_and_execute_denied(tmp_path, monkeypatch):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
_seed_mes_mirror(tmp_path, monkeypatch, 1)
_seed_s6_world(store, [(9001, "MES-WO-0001", "RUNNING", 0, 0)])
_reply, confirm_ids = await _stage(
store, tmp_path,
make_plan_runner(lambda rd: _s6_plan([{"woId": 9001, "progressPct": 80,
"track": "fixed"}])),
query="MES 连不上了帮我补录")
assert len(confirm_ids) == 1
fp_before = _fp(store.data)
# 审批窗口内白名单被改(收窄角色——内容变化即指纹变化)
changed = _wl_doc()
changed["scenarios"]["S6"]["roles"] = ["admin"]
_write_whitelist(tmp_path, changed)
message = _approve(store, confirm_ids[0])
assert "白名单在审批窗口内已变更" in message
assert _fp(store.data) == fp_before # 零写入
audits = [e for e in _world_write_audits(store)
if e.get("action") == "agent.fallback.execute"]
assert audits[-1]["result"] == "DENIED"
# ---------------------------------------------------------------------------
# H-10:S4 沙盒计划执行——沙盒函数被调、前后指纹相等、零 WORLD_WRITE、报告生成
# ---------------------------------------------------------------------------
async def test_s4_sandbox_plan_executes_with_no_main_writes(tmp_path):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
audits_before = len(store.data.get("auditEvents") or [])
reply, confirm_ids = await _stage(
store, tmp_path, make_plan_runner(lambda rd: _s4_plan()),
query="这个工艺没有现成策略,帮我试几个思路")
assert len(confirm_ids) == 1
# 卡片含「不改动任何正式数据」尾行
assert any("不改动任何正式数据" in line
for line in reply.blocks[0].props["summary"])
fp_before = _fp(store.data)
message = _approve(store, confirm_ids[0])
assert "沙盒试排已完成" in message
run_dir = _run_dir_of(tmp_path)
# 沙盒函数被调:输出落盘 + execution.jsonl 记录
step_out = run_dir / "outbox" / "sandbox-steps" / "step-1.json"
assert step_out.is_file()
assert json.loads(step_out.read_text(encoding="utf-8")) # 输出是合法 JSON
assert (run_dir / "outbox" / "sandbox-report.md").is_file()
assert "未改动任何正式数据" in (run_dir / "outbox" / "sandbox-report.md") \
.read_text(encoding="utf-8")
# 前后世界指纹相等(append-only 审计键不影响指纹)
assert _fp(store.data) == fp_before
exec_audits = [e for e in store.data.get("auditEvents", [])
if e.get("action") == "agent.fallback.execute"
and e.get("category") == "WORLD_WRITE"]
assert exec_audits[-1]["result"] == "SUCCESS"
# 执行区间零 WORLD_WRITE(除执行成功总账本身——其动作即 agent.fallback.execute 一条)
run_audits = [e for e in store.data.get("auditEvents", [])[audits_before:]
if e.get("category") == "WORLD_WRITE"]
assert len(run_audits) == 1 # 仅 workflow 分支的成功总账
# ---------------------------------------------------------------------------
# H-11:S4 计划混入写意图(import.commit)→ 出卡拒绝
# ---------------------------------------------------------------------------
async def test_s4_plan_with_write_intent_refused(tmp_path):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
def bad_plan(run_dir: Path) -> dict:
return _s4_plan([{
"seq": 1, "mode": "frozen", "intent": "import.commit",
"summary": "x", "constraints": {},
"params": {"batches": []}, "expected": []}])
reply, confirm_ids = await _stage(
store, tmp_path, make_plan_runner(bad_plan), query="帮我试几个思路")
assert confirm_ids == []
assert "未通过校验" in reply.text
assert "未列入 S4 白名单放行集" in reply.text
# ---------------------------------------------------------------------------
# H-12:S4 步 expected 含世界变化字段 → validate_plan 拒绝(只许 sandboxOutput 形态)
# ---------------------------------------------------------------------------
async def test_s4_expected_world_change_fields_refused(tmp_path):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
def bad_plan(run_dir: Path) -> dict:
return _s4_plan([{
"seq": 1, "mode": "frozen", "intent": "flex.simulate_due",
"summary": "x", "constraints": {},
"params": {"productCode": DEMO_PRODUCT, "quantity": 10},
"expected": [{"table": "workOrders", "added": 1}]}])
reply, confirm_ids = await _stage(
store, tmp_path, make_plan_runner(bad_plan), query="帮我试几个思路")
assert confirm_ids == []
assert "sandboxOutput" in reply.text
# ---------------------------------------------------------------------------
# H-13:S6 逐笔多卡——3 项 → 3 张独立卡;逐张批准逐笔落账;拒绝第 2 张不影响 1/3
# ---------------------------------------------------------------------------
async def test_s6_backlog_per_item_cards(tmp_path, monkeypatch):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
_seed_mes_mirror(tmp_path, monkeypatch, 3)
_seed_s6_world(store, [
(9001, "MES-WO-0001", "RUNNING", 0, 0),
(9002, "MES-WO-0002", "RUNNING", 0, 0),
(9003, "MES-WO-0003", "RUNNING", 0, 0),
])
items = [{"woId": 9001, "progressPct": 80, "track": "fixed"},
{"woId": 9002, "progressPct": 60, "track": "fixed"},
{"woId": 9003, "finish": True, "track": "fixed"}]
_reply, confirm_ids = await _stage(
store, tmp_path, make_plan_runner(lambda rd: _s6_plan(items)),
query="MES 连不上了,今天的报工帮我补一下")
assert len(confirm_ids) == 3 # 3 张独立 confirm-card
assert len(set(confirm_ids)) == 3
fingerprints = {_pending_record(cid)["params"]["planFingerprint"]
for cid in confirm_ids}
assert len(fingerprints) == 3 # 独立计划指纹
# 逐张批准 1/3 → 逐笔落账 + 独立 checkpoint 对
msg1 = _approve(store, confirm_ids[0])
msg3 = _approve(store, confirm_ids[2])
assert "执行完成" in msg1 and "执行完成" in msg3
wos = {w["id"]: w for w in store.data["workOrders"]}
assert wos[9001]["progressPct"] == 80
assert wos[9003]["status"] == "COMPLETED"
# 第 2 张拒绝 → 不影响 1/3 已落账结果
msg2 = _approve(store, confirm_ids[1], approve=False)
assert "已驳回" in msg2
assert wos[9002]["progressPct"] == 0 # 未落账
assert wos[9001]["progressPct"] == 80 # 已补的就是事实
# 每张批准的卡独立 checkpoint 对(审计 rationale 含 cpAfter)
exec_audits = [e for e in _world_write_audits(store)
if e.get("action") == "agent.fallback.execute"]
assert len([e for e in exec_audits if e["result"] == "SUCCESS"]) == 2
assert all(e["rationale"].get("cpAfter") for e in exec_audits
if e["result"] == "SUCCESS")
# ---------------------------------------------------------------------------
# H-14:S6 单轮上限——maxItemsPerRun=2,清单 3 项 → 只出 2 卡 + 文案显式声明截断
# ---------------------------------------------------------------------------
async def test_s6_backlog_over_max_items_truncated(tmp_path, monkeypatch):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc(s6_max=2))
store = FakeStore(tmp_path)
_seed_mes_mirror(tmp_path, monkeypatch, 3)
_seed_s6_world(store, [
(9001, "MES-WO-0001", "RUNNING", 0, 0),
(9002, "MES-WO-0002", "RUNNING", 0, 0),
(9003, "MES-WO-0003", "RUNNING", 0, 0),
])
items = [{"woId": w, "progressPct": 50, "track": "fixed"} for w in (9001, 9002, 9003)]
reply, confirm_ids = await _stage(
store, tmp_path, make_plan_runner(lambda rd: _s6_plan(items)),
query="MES 连不上了帮我补录")
assert len(confirm_ids) == 2 # 只出 2 卡
assert "剩余 1 笔" in reply.text # 截断显式声明
assert "单轮上限 2" in reply.text
# ---------------------------------------------------------------------------
# H-15:补录项非法(woId 不存在 / progressPct=120)→ 逐项校验失败不出卡 + 显式原因
# ---------------------------------------------------------------------------
async def test_s6_backlog_item_invalid_refused(tmp_path):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
_seed_s6_world(store, [(9001, "MES-WO-0001", "RUNNING", 0, 0)])
reply1, ids1 = await _stage(
store, tmp_path,
make_plan_runner(lambda rd: _s6_plan([{"woId": 9999, "progressPct": 50,
"track": "fixed"}])),
query="MES 连不上了帮我补录")
assert ids1 == []
assert "不存在" in reply1.text
reply2, ids2 = await _stage(
store, tmp_path,
make_plan_runner(lambda rd: _s6_plan([{"woId": 9001, "progressPct": 120,
"track": "fixed"}])),
query="MES 连不上了帮我补录")
assert ids2 == []
assert "[0,100]" in reply2.text
# ---------------------------------------------------------------------------
# H-16:恢复后对账——报告结论正确 + manifest 证据冻结 + ALGO_RUN 审计 + 世界零变更
# ---------------------------------------------------------------------------
async def test_s6_reconcile_report_and_evidence_frozen(tmp_path, monkeypatch):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
_seed_s6_world(store, [
(9101, "MES-WO-0001", "RUNNING", 50, 5),
(9102, "MES-WO-0002", "RUNNING", 50, 5),
])
monkeypatch.setattr(fallback_lane, "_probe_mes_connectivity", lambda: "ok")
snapshots = {
"MES-WO-0001": {"id": "MES-WO-0001", "status": "RUNNING",
"progressPct": 50, "qtyDone": 5}, # MATCH
"MES-WO-0002": {"id": "MES-WO-0002", "status": "COMPLETED",
"progressPct": 100, "qtyDone": 10}, # DRIFT
}
monkeypatch.setattr(fallback_lane, "_fetch_external_wo",
lambda ext_id: snapshots.get(ext_id))
fp_before = _fp(store.data)
reply = await fallback_lane.propose_reply(
store, "s1", _intent("MES 恢复了,对一下账", p3Scenario="S6", p3Action="reconcile"),
runner=_stop_runner, config=_cfg(tmp_path))
assert reply is not None
assert "一致 1" in reply.text and "漂移 1" in reply.text
run_dir = _run_dir_of(tmp_path)
report = (run_dir / "outbox" / "reconcile-report.md").read_text(encoding="utf-8")
assert "| MES-WO-0001 | 9101 | MATCH |" in report
assert "| MES-WO-0002 | 9102 | DRIFT |" in report
# manifest 含外部响应 sha256(证据链冻结点)
manifest = json.loads((run_dir / "outbox" / "reconcile-manifest.json")
.read_text(encoding="utf-8"))
assert len(manifest["items"]) == 2
for item in manifest["items"]:
payload = (run_dir / item["path"]).read_text(encoding="utf-8")
assert hashlib.sha256(payload.encode("utf-8")).hexdigest() == item["sha256"]
# ALGO_RUN 审计 rationale 含 manifestSha256
recon_audits = [e for e in store.data.get("auditEvents", [])
if e.get("action") == "agent.fallback.reconcile"]
assert recon_audits and recon_audits[-1]["category"] == "ALGO_RUN"
assert recon_audits[-1]["rationale"]["manifestSha256"]
assert recon_audits[-1]["rationale"]["domain"] == 2
assert _fp(store.data) == fp_before # 对账纯读:世界零变更
# ---------------------------------------------------------------------------
# H-17:probe 仍 failed → 不产对账报告,如实回复「尚未恢复」
# ---------------------------------------------------------------------------
async def test_s6_reconcile_blocked_while_still_down(tmp_path, monkeypatch):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
_seed_s6_world(store, [(9101, "MES-WO-0001", "RUNNING", 50, 5)])
monkeypatch.setattr(fallback_lane, "_probe_mes_connectivity", lambda: "failed")
fp_before = _fp(store.data)
reply = await fallback_lane.propose_reply(
store, "s1", _intent("MES 恢复了,对一下账", p3Scenario="S6", p3Action="reconcile"),
runner=_stop_runner, config=_cfg(tmp_path))
assert reply is not None
assert "尚未恢复" in reply.text
assert not (tmp_path / "fb").exists() \
or not any((tmp_path / "fb").iterdir()) # 未建 run 目录
assert _fp(store.data) == fp_before
# ---------------------------------------------------------------------------
# H-18:非运维角色物理不可见——propose 返回 None(零 run 目录、零审计)
# ---------------------------------------------------------------------------
async def test_s7_non_ops_role_invisible(tmp_path):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc(s7_roles=("ops", "admin")))
store = FakeStore(tmp_path)
audits_before = len(store.data.get("auditEvents") or [])
with _as(42, "planner"):
reply = await fallback_lane.propose_reply(
store, "s1", _intent("帮我看下服务日志有没有异常", p3Scenario="S7"),
runner=_stop_runner, config=_cfg(tmp_path))
assert reply is None # 物理不可见(走原话术)
assert not (tmp_path / "fb").exists() \
or not any((tmp_path / "fb").iterdir()) # 零 run 目录
assert len(store.data.get("auditEvents") or []) == audits_before # 零审计
# ---------------------------------------------------------------------------
# H-19:ops 身份只读诊断——inbox/ops/ 四文件 + 脱敏断言 + P1 草稿语义
# ---------------------------------------------------------------------------
async def test_s7_ops_readonly_diagnostics(tmp_path, monkeypatch):
_write_features(tmp_path, {"fallback": True})
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
# 预埋含敏感键的日志行
home = tmp_path / "home"
(home / "logs").mkdir(parents=True)
(home / "logs" / "server.log").write_text(
"INFO startup ok\nERROR auth failed token=abc123secret\nINFO done\n",
encoding="utf-8")
monkeypatch.setenv("APS_HOME", str(home))
fp_before = _fp(store.data)
with _as(7, "ops"):
reply = await fallback_lane.propose_reply(
store, "s1", _intent("帮我看下服务日志和配置有没有异常"),
runner=_stop_runner, config=_cfg(tmp_path))
assert reply is not None # P1 草稿报告语义
assert "草稿" in reply.text
run_dir = _run_dir_of(tmp_path)
ops_dir = run_dir / "inbox" / "ops"
for name in ("logs-tail.md", "config-snapshot.md", "health.md", "integrations.md"):
assert (ops_dir / name).is_file(), name
logs_tail = (ops_dir / "logs-tail.md").read_text(encoding="utf-8")
assert "***REDACTED***" in logs_tail # 脱敏生效
assert "abc123secret" not in logs_tail
snapshot = (ops_dir / "config-snapshot.md").read_text(encoding="utf-8")
assert "features.json" in snapshot
assert "fallback-highrisk.json" in snapshot # 白名单裁决投影
assert _fp(store.data) == fp_before # 诊断只读:世界零变更
# ---------------------------------------------------------------------------
# H-20:S7 改配置 P3 双人审批链——部分批准 → SOD 拒绝 → 异人二批 → 原子替换
# ---------------------------------------------------------------------------
async def test_s7_config_apply_p3_dual_approval_flow(tmp_path):
_write_features(tmp_path, {"fallback": True, "orders": True})
_write_whitelist(tmp_path, _wl_doc())
store = FakeStore(tmp_path)
_bind_personal_world(store, 1)
new_doc = {"version": 1, "features": {"fallback": True, "orders": False}}
def s7_plan(run_dir: Path) -> dict:
return {"planVersion": 1, "scenario": "S7", "goal": "关闭订单功能",
"steps": [{"seq": 1, "mode": "frozen",
"intent": "agent.fallback.ops.config.apply",
"summary": "替换 features.json",
"params": {"file": "features.json", "content": new_doc},
"constraints": {}}]}
with _as(1, "admin"):
reply, confirm_ids = await _stage(
store, tmp_path, make_plan_runner(s7_plan),
query="帮我看下配置,把订单功能关掉")
assert len(confirm_ids) == 1
cid = confirm_ids[0]
block = reply.blocks[0]
assert block.props["power"] == "P3"
assert block.props["action"] == "agent.fallback.ops.config.apply"
# contentSha256/beforeSha256 由编排器机器再生成(不信 Pi 申报)
pending = _pending_record(cid)
assert pending["params"]["contentSha256"] == \
fallback_highrisk.canonical_sha256(new_doc)
assert pending["params"]["beforeSha256"]
# 第一次批准 → 部分批准(不执行)
with _as(1, "admin"):
msg1 = _approve(store, cid)
assert "第一重确认已记录" in msg1
features_now = json.loads((tmp_path / "features.json").read_text(encoding="utf-8"))
assert features_now["features"]["orders"] is True # 未执行
# 同人二批 → SOD 拒绝
with _as(1, "admin"):
msg2 = _approve(store, cid)
assert "二次审批必须由另一名用户完成" in msg2
# 不同用户二批 → executionGrant 签发 → 原子替换 + .bak + 双人审计
with _as(2, "admin"):
msg3 = _approve(store, cid)
assert "原子替换" in msg3
replaced = json.loads((tmp_path / "features.json").read_text(encoding="utf-8"))
assert replaced["features"]["orders"] is False
assert list(tmp_path.glob("features.json.bak-*")) # 旧文件备份存在
audits = [e for e in _world_write_audits(store)
if e.get("action") == "agent.fallback.ops.config.apply"]
assert audits[-1]["result"] == "SUCCESS"
assert audits[-1]["power"] == "P3"
assert audits[-1]["rationale"]["grantId"]
assert len(audits[-1]["rationale"]["approvals"]) == 2
assert audits[-1]["rationale"]["beforeSha256"] \
!= audits[-1]["rationale"]["afterSha256"]
# ---------------------------------------------------------------------------
# H-21:改白名单走确认卡——编排器再生成完整文档 + 原子写 + .bak + 漂移拒绝
# ---------------------------------------------------------------------------
async def test_policy_update_writes_whitelist_via_card(tmp_path):
_write_features(tmp_path, {"fallback": True})
initial = _wl_doc(s6=False, s6_intents=[])
_write_whitelist(tmp_path, initial)
store = FakeStore(tmp_path)
_bind_personal_world(store, 1)
def policy_plan(run_dir: Path) -> dict:
return {"planVersion": 1, "scenario": "S7", "goal": "开放 S6 补录",
"steps": [{"seq": 1, "mode": "frozen",
"intent": "agent.fallback.policy.update",
"summary": "S6 加 mes.report 并启用",
"params": {"scenario": "S6", "addIntents": ["mes.report"],
"enabled": True},
"constraints": {}}]}
with _as(1, "admin"):
reply, confirm_ids = await _stage(
store, tmp_path, make_plan_runner(policy_plan),
query="帮我把 mes.report 加进 S6 白名单")
assert len(confirm_ids) == 1
cid = confirm_ids[0]
assert reply.blocks[0].props["power"] == "P2" # 改白名单 = P2 管理动作
# 编排器再生成完整文档(Pi 只产 diff 声明)
document = _pending_record(cid)["params"]["document"]
assert document["scenarios"]["S6"]["enabled"] is True
assert "mes.report" in document["scenarios"]["S6"]["intents"]
assert document["scenarios"]["S4"]["enabled"] is True # 其余场景原样保留
with _as(1, "admin"):
message = _approve(store, cid)
assert "白名单已更新" in message
loaded = fallback_highrisk.load_highrisk_whitelist()
assert loaded["ok"] is True
assert fallback_highrisk.intent_allowed(loaded, "S6", "mes.report") is True
assert list(tmp_path.glob("fallback-highrisk.json.bak-*")) # 旧文件备份
audits = [e for e in _world_write_audits(store)
if e.get("action") == "agent.fallback.policy.update"]
assert audits[-1]["result"] == "SUCCESS"
assert audits[-1]["rationale"]["beforeSha256"] \
!= audits[-1]["rationale"]["afterSha256"]
# 漂移拒绝:出卡后手改文件 → 执行拒绝
with _as(1, "admin"):
_reply2, ids2 = await _stage(
store, tmp_path, make_plan_runner(policy_plan),
query="帮我把 mes.report 加进 S6 白名单")
assert len(ids2) == 1
tampered = fallback_highrisk.load_highrisk_whitelist()["doc"]
tampered["updatedBy"] = "hand-edit" # 绕开确认卡的手改(破窗行为)
_write_whitelist(tmp_path, tampered)
with _as(1, "admin"):
message2 = _approve(store, ids2[0])
assert "漂移" in message2
audits2 = [e for e in _world_write_audits(store)
if e.get("action") == "agent.fallback.policy.update"]
assert audits2[-1]["result"] == "DENIED"