冻结地图1二十分钟纵切版的平衡、证据与金标登记。 新增 ReferenceAsset/2 清单、策略、release、受信快照及 CLI/Service/acceptance provenance /4 消费链;保持 survivor live、R1 签名与部署关闭。
406 lines
16 KiB
Python
406 lines
16 KiB
Python
"""参照资产 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.*")) == []
|