aps-agent/tests/golden/test_fallback_highrisk.py

914 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ============================================================
# 智能兜底 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") -> IntentResult:
return IntentResult(intent=name, params={"query": query},
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 恢复了,对一下账"),
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 恢复了,对一下账"),
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("帮我看下服务日志有没有异常"),
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"