games-development-ai/cheap-worker/tests/test_reference_asset_service_wiring.py
lili 686aaaa421
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
feat(reference-assets): 签认《山海行纪》并闭合可信消费门
冻结地图1二十分钟纵切版的平衡、证据与金标登记。

新增 ReferenceAsset/2 清单、策略、release、受信快照及 CLI/Service/acceptance provenance /4 消费链;保持 survivor live、R1 签名与部署关闭。
2026-07-28 09:11:54 -07:00

406 lines
16 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.

"""参照资产 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.*")) == []