"""参照资产 v2 在 CLI/Service 生产入口的冻结快照接线回归。""" import asyncio import errno import json import shutil import sys from pathlib import Path import pytest # brief 的验证命令从仓根启动;显式加入 cheap-worker,保持与其它 Service 测试一致。 sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import cheap_run import cheap_service_app as A import cheap_service_driver as D import cheap_verify import reference_asset_gate def _fake_verified(): """构造最小只读验证结果;hash 均由真实 canonical 实现计算。""" files = { "assets/gold/guide.md": b"captured guide", "assets/gold/sub/rules.txt": b"captured rules", } snapshot_hash = reference_asset_gate.snapshot_hash(files) receipt = { "schemaVersion": "ReferenceAssetVerificationReceipt/1", "receiptId": "receipt-survivor-gold-v1-gac-shanhai-xingji-test", "recordId": "gac-shanhai-xingji", "finalSnapshotHash": snapshot_hash, } return reference_asset_gate.VerifiedReferenceAssets( constraint_records=({"recordId": "gac-shanhai-xingji", "role": "game_content_gold"},), files=files, receipts=(receipt,), snapshot_hash=snapshot_hash, reference_roots={"gac-shanhai-xingji": ("assets/gold",)}, ) def _cfg_path(tmp_path: Path, session_id: str) -> Path: return tmp_path / "_cheap-sessions" / f"{session_id}.json" def _snapshot_dir(cfg_path: Path) -> Path: return cfg_path.with_name(f"{cfg_path.stem}.reference-assets") def _tool_function(tools, name: str): """从 Service 工厂返回的真实 FunctionTool 列表取目标函数。""" for tool in tools: if tool.name == name: return tool._func raise AssertionError(f"Service Toolkit 缺少工具:{name}") def _publish(tmp_path, monkeypatch, session_id="session-reference"): """通过 driver 真实物化 sidecar 与独立 session snapshot。""" monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: _cfg_path(tmp_path, sid)) verified = _fake_verified() assert D._write_session_cfg( session_id, external_game_id="70012", reference_assets=verified, ) is True return _cfg_path(tmp_path, session_id), verified def test_service_factory_reads_only_driver_materialized_snapshot(tmp_path, monkeypatch): """工厂只读 session snapshot;活目录后续改写不得影响 read/list。""" cfg_path, _ = _publish(tmp_path, monkeypatch) live_calls = [] def live_read(path): live_calls.append(("read", path)) return {"ok": True, "content": "rewritten live asset", "truncated": False} def live_list(path): live_calls.append(("list", path)) return {"ok": True, "entries": ["rewritten.txt"]} monkeypatch.setattr(cheap_run, "read_file", live_read) monkeypatch.setattr(cheap_run, "list_dir", live_list) tools = asyncio.run(A._cheap_tools_factory("u", "a", "session-reference")) read = asyncio.run(_tool_function(tools, "read_file")(path="assets/gold/guide.md")) listed = asyncio.run(_tool_function(tools, "list_dir")(path="assets/gold")) assert read == "captured guide" assert listed == "guide.md\nsub/" assert live_calls == [] # sidecar 只允许稳定策略元数据与文件索引,不落 record/identity 自报 artifact hash。 cfg_text = cfg_path.read_text(encoding="utf-8") cfg = json.loads(cfg_text) policy = cfg["reference_asset_policy"] assert policy["policy_id"] == "survivor-gold-v1" assert policy["mode"] == "frozen_preflight" assert "artifactHash" not in cfg_text assert {item["path"] for item in policy["files"]} == { "assets/gold/guide.md", "assets/gold/sub/rules.txt"} @pytest.mark.parametrize("mutation", ["missing_policy", "bad_json", "non_object"]) def test_service_factory_rejects_existing_snapshot_without_valid_policy( tmp_path, monkeypatch, mutation): """已有冻结 snapshot 缺 policy 或配置损坏时必须失败,绝不回读活目录。""" cfg_path, _ = _publish(tmp_path, monkeypatch, session_id=f"cfg-{mutation}") if mutation == "missing_policy": cfg = json.loads(cfg_path.read_text(encoding="utf-8")) del cfg["reference_asset_policy"] cfg_path.write_text(json.dumps(cfg, ensure_ascii=False), encoding="utf-8") elif mutation == "bad_json": cfg_path.write_text("{broken", encoding="utf-8") else: cfg_path.write_text("[]", encoding="utf-8") live_calls = [] def live_read(path): live_calls.append(("read", path)) raise AssertionError("冻结 snapshot 配置异常时不得读取活目录") monkeypatch.setattr(cheap_run, "read_file", live_read) with pytest.raises(ValueError): asyncio.run(A._cheap_tools_factory("u", "a", f"cfg-{mutation}")) assert live_calls == [] def test_existing_snapshot_rejects_default_sidecar_overwrite_without_live_read( tmp_path, monkeypatch): """同 session 的默认写入不得覆盖冻结 policy,工厂仍只能读原 snapshot。""" cfg_path, _ = _publish(tmp_path, monkeypatch, session_id="reuse") assert D._write_session_cfg("reuse", external_game_id="70099") is False cfg = json.loads(cfg_path.read_text(encoding="utf-8")) assert cfg["external_game_id"] == "70012" assert "reference_asset_policy" in cfg live_calls = [] monkeypatch.setattr( cheap_run, "read_file", lambda path: live_calls.append(("read", path)) or "live") tools = asyncio.run(A._cheap_tools_factory("u", "a", "reuse")) assert asyncio.run(_tool_function(tools, "read_file")(path="assets/gold/guide.md")) == "captured guide" assert live_calls == [] def test_missing_snapshot_rejects_default_sidecar_overwrite_of_frozen_cfg( tmp_path, monkeypatch): """snapshot 被删除后,默认写入仍不得覆盖原冻结 cfg 或回落活目录。""" cfg_path, _ = _publish(tmp_path, monkeypatch, session_id="orphan-cfg") original_bytes = cfg_path.read_bytes() original_policy = json.loads(original_bytes)["reference_asset_policy"] shutil.rmtree(_snapshot_dir(cfg_path)) assert D._write_session_cfg( "orphan-cfg", external_game_id="70099", reference_assets=None) is False assert cfg_path.read_bytes() == original_bytes assert json.loads(cfg_path.read_bytes())["reference_asset_policy"] == original_policy def test_session_cfg_reader_rejects_orphan_snapshot_without_cfg(tmp_path, monkeypatch): """cfg 被删除但冻结 snapshot 孤立存在时,reader 必须拒绝活目录回落。""" cfg_path, _ = _publish(tmp_path, monkeypatch, session_id="orphan-snapshot") cfg_path.unlink() with pytest.raises(ValueError, match="session-cfg 缺失"): A._read_session_cfg("orphan-snapshot") @pytest.mark.parametrize( "mutation", ["missing_dir", "missing_file", "special_file", "index", "receipt", "snapshot_hash"], ) def test_service_factory_rejects_snapshot_sidecar_drift_before_tools( tmp_path, monkeypatch, mutation): """目录、文件、特殊文件、索引、receipt 或 snapshot 任一漂移都必须 fail-closed。""" cfg_path, _ = _publish(tmp_path, monkeypatch, session_id=f"tamper-{mutation}") snapshot_dir = _snapshot_dir(cfg_path) target = snapshot_dir / "assets/gold/guide.md" if mutation == "missing_dir": shutil.rmtree(snapshot_dir) elif mutation == "missing_file": target.unlink() elif mutation == "special_file": target.unlink() target.symlink_to(snapshot_dir / "assets/gold/sub/rules.txt") elif mutation == "receipt": receipt = snapshot_dir / ".reference-receipts.json" receipt.write_bytes(receipt.read_bytes() + b" ") else: cfg = json.loads(cfg_path.read_text(encoding="utf-8")) policy = cfg["reference_asset_policy"] if mutation == "index": policy["files"][0]["size"] += 1 else: policy["snapshot_hash"] = "0" * 64 cfg_path.write_text(json.dumps(cfg, ensure_ascii=False), encoding="utf-8") with pytest.raises(ValueError): asyncio.run(A._cheap_tools_factory("u", "a", f"tamper-{mutation}")) def test_default_sidecar_does_not_inject_reference_snapshot(tmp_path, monkeypatch): """未声明 policy 的既有 sidecar 保持活目录 read/list 默认语义。""" monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: _cfg_path(tmp_path, sid)) assert D._write_session_cfg("default", external_game_id="70013") is True monkeypatch.setattr(cheap_run, "read_file", lambda path: {"ok": True, "content": "live", "truncated": False}) tools = asyncio.run(A._cheap_tools_factory("u", "a", "default")) assert asyncio.run(_tool_function(tools, "read_file")(path="assets/gold/guide.md")) == "live" assert not _snapshot_dir(_cfg_path(tmp_path, "default")).exists() def test_session_cfg_reader_rejects_symlink_and_oversize(tmp_path, monkeypatch): """会话配置只接受固定上限内的普通文件,拒绝 symlink 与超限输入。""" monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: _cfg_path(tmp_path, sid)) target = tmp_path / "target.json" target.write_text("{}", encoding="utf-8") symlink = _cfg_path(tmp_path, "linked") symlink.parent.mkdir(parents=True) symlink.symlink_to(target) with pytest.raises(ValueError): A._read_session_cfg("linked") oversized = _cfg_path(tmp_path, "oversized") oversized.write_bytes(b" " * (A._SESSION_CFG_MAX_BYTES + 1)) with pytest.raises(ValueError): A._read_session_cfg("oversized") def test_session_cfg_reader_rejects_symlink_swap_after_lstat(tmp_path, monkeypatch): """lstat 后被换成 symlink 时,O_NOFOLLOW 拒绝不得降级成缺 sidecar。""" monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: _cfg_path(tmp_path, sid)) cfg = _cfg_path(tmp_path, "raced") cfg.parent.mkdir(parents=True) cfg.write_text("{}", encoding="utf-8") def raced_open(*args, **kwargs): raise OSError(errno.ELOOP, "symlink swap") monkeypatch.setattr(A.os, "open", raced_open) with pytest.raises(ValueError, match="拒绝"): A._read_session_cfg("raced") class _StopAtChat(BaseException): """测试仅用于在首个 /chat 观察点停止真实 driver。""" def _install_driver_setup(monkeypatch, tmp_path, events, *, sidecar_ok=True, real_sidecar=False): """隔离 Service 外部 I/O,仅保留 driver 内部发布顺序。""" import _bootstrap import cheap_otlp_sink import httpx monkeypatch.setenv("TIER2_GEN__ACCEPTANCE__MODE", "v3_shadow") monkeypatch.setattr(cheap_run, "archive_prior_run", lambda game_id: None) monkeypatch.setattr(cheap_run, "scaffold", lambda *args, **kwargs: {"ok": True, "output": ""}) monkeypatch.setattr(cheap_run, "clean_stale_evidence", lambda game_id: None) monkeypatch.setattr(cheap_run, "game_dir", lambda game_id: tmp_path / f"amgen-{game_id}") monkeypatch.setattr(_bootstrap, "ensure_api_key_env", lambda: None) monkeypatch.setattr(cheap_otlp_sink, "current_traceparent_carrier", lambda: {}) monkeypatch.setattr(D, "_cheap_credential_payload", lambda token=None: {"data": {}}) monkeypatch.setattr( cheap_verify, "preflight_reference_asset_policy", lambda policy_id, mode: _fake_verified(), ) def publish(*args, **kwargs): events.append("sidecar") return sidecar_ok if real_sidecar: monkeypatch.setattr(cheap_run, "session_cfg_path", lambda sid: _cfg_path(tmp_path, sid)) else: monkeypatch.setattr(D, "_write_session_cfg", publish) class Response: def __init__(self, value): self.value = value def json(self): return self.value class Http: def __init__(self, *args, **kwargs): pass async def __aenter__(self): return self async def __aexit__(self, *args): return False async def post(self, url, **kwargs): if url.endswith("/credential/"): return Response({"credential_id": "c1"}) if url.endswith("/agent/"): return Response({"agent_id": "a1"}) if url.endswith("/sessions/"): return Response({"session_id": "s1"}) if url.endswith("/chat/"): events.append("chat") raise _StopAtChat() return Response({}) async def patch(self, *args, **kwargs): events.append("patch") return Response({}) monkeypatch.setattr(httpx, "AsyncClient", Http) def test_driver_publishes_reference_sidecar_before_chat(tmp_path, monkeypatch): """显式 policy 必须在首个 Writer /chat 前发布 sidecar。""" events = [] _install_driver_setup(monkeypatch, tmp_path, events) with pytest.raises(_StopAtChat): asyncio.run(D.drive_cheap_generation({ "gameId": "70014", "traceId": "trace-reference", "brief": "解谜点击", "referenceAssetPolicyId": "survivor-gold-v1", })) assert events.index("sidecar") < events.index("chat") def test_driver_does_not_chat_when_reference_sidecar_publish_fails(tmp_path, monkeypatch): """显式 policy 的 sidecar 原子发布失败后不得向 Writer 发送 /chat。""" events = [] _install_driver_setup(monkeypatch, tmp_path, events, sidecar_ok=False) summary, _ = asyncio.run(D.drive_cheap_generation({ "gameId": "70015", "traceId": "trace-reference-fail", "brief": "解谜点击", "referenceAssetPolicyId": "survivor-gold-v1", })) assert summary["ok"] is False assert "session snapshot" in summary["stoppedReason"] assert "chat" not in events def test_driver_stops_before_chat_when_frozen_cfg_is_unconfirmable( tmp_path, monkeypatch): """默认 sidecar 无法确认既有冻结 cfg 时,driver 必须停在首个 /chat 前。""" events = [] _install_driver_setup(monkeypatch, tmp_path, events, real_sidecar=True) cfg_path, _ = _publish(tmp_path, monkeypatch, session_id="s1") original_bytes = cfg_path.read_bytes() shutil.rmtree(_snapshot_dir(cfg_path)) summary, _ = asyncio.run(D.drive_cheap_generation({ "gameId": "70017", "traceId": "trace-reference-orphan-cfg", "brief": "解谜点击", })) assert summary["ok"] is False assert "reference asset" in summary["stoppedReason"] assert "chat" not in events assert cfg_path.read_bytes() == original_bytes def test_driver_cleans_real_snapshot_when_cfg_replace_fails(tmp_path, monkeypatch): """真实 sidecar 第二次 os.replace 失败时必须清理 snapshot/临时文件且不 /chat。""" events = [] _install_driver_setup(monkeypatch, tmp_path, events, real_sidecar=True) real_replace = D.os.replace replace_calls = [] def fail_cfg_replace(source, destination): replace_calls.append((source, destination)) if len(replace_calls) == 2: raise OSError("injected cfg publish failure") return real_replace(source, destination) monkeypatch.setattr(D.os, "replace", fail_cfg_replace) summary, _ = asyncio.run(D.drive_cheap_generation({ "gameId": "70016", "traceId": "trace-reference-real-fail", "brief": "解谜点击", "referenceAssetPolicyId": "survivor-gold-v1", })) cfg_path = _cfg_path(tmp_path, "s1") assert len(replace_calls) == 2 assert summary["ok"] is False assert "session snapshot" in summary["stoppedReason"] assert "chat" not in events assert not _snapshot_dir(cfg_path).exists() assert not cfg_path.exists() assert list(cfg_path.parent.glob(f".{cfg_path.name}.*")) == [] assert list(cfg_path.parent.glob(f".{cfg_path.stem}.reference-assets.*")) == []