# ============================================================ # 智能兜底 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"