""" test_toolkit.py — U2 工具底座(read/list/write)+ 形状门三态。 形状门:define 单对象 FAIL / 干净源 PASS / drawButton 赋值改签名后 PASS(#5 回吐命中矩形)经 shell-out 现有 node tools.mjs check — 便宜档与 Node 侧跑同一份门代码,口径必然一致。真 shell-out node(确定性命令,非 LLM gen 循环,前台串行)。 跑:cheap-worker/.venv/bin/python cheap-worker/tests/test_toolkit.py """ import asyncio import subprocess import sys from pathlib import Path import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # → cheap-worker/ import cheap_run import cheap_toolkit from cheap_toolkit import CheapSession, build_toolkit _GAME_RUNTIME = cheap_run._GAME_RUNTIME _AMODEL_GEN = cheap_run._AMODEL_GEN GID = "cheap-tk-test" # gitignore: amgen-cheap-* / _wg1-gen/cheap-* def _scaffold(gid: str = GID) -> None: """shell-out node tools.mjs scaffold-saa 起一个干净工程(SAA 信封)。""" subprocess.run( ["node", str(_AMODEL_GEN / "tools.mjs"), "scaffold-saa", gid], cwd=str(_GAME_RUNTIME), check=True, capture_output=True, text=True, ) def _write_logic(src: str, gid: str = GID) -> None: r = cheap_run.write_file(gid, f"game-runtime/games/amgen-{gid}/src/game-logic.js", src) assert r["ok"], r # ── 形状门合成输入:define 触发目标 footgun;drawButton 改签名后赋值消费已合法(#5 回吐命中矩形),其余 check 门尽量满足以免掩盖 ── _DRAWBUTTON_RETURN_OK = """export function createGame({ plugins, bundle, viewport }) { const { hudUi } = plugins; return { init(boot) { this.ctx = boot.ctx; }, update(dt) { bundle.tick(dt); }, render(g) { const btn = hudUi.drawButton(g, { x: 0, y: 0, w: 10, h: 10 }, { label: 'x' }); if (btn) { this.x = 1; } }, destroy() {}, handleTap(x, y) { this.tapped = true; }, _forensicsView() { return { state() { return { phase: 'menu', score: 0 }; }, measures() { return {}; } }; }, }; } """ _DEFINE_BAD = """export function createGame({ plugins, bundle, viewport }) { const { sceneFsm } = plugins; return { init(boot) { this.ctx = boot.ctx; sceneFsm.define({ menu: {}, play: {} }); }, update(dt) { bundle.tick(dt); }, render(g) {}, destroy() {}, handleTap(x, y) { this.tapped = true; }, _forensicsView() { return { state() { return { phase: 'menu', score: 0 }; }, measures() { return {}; } }; }, }; } """ def test_read_file_ok(): r = cheap_run.read_file("game-runtime/games/_template/src/game-logic.js") assert r["ok"] and "createGame" in r["content"] def test_read_out_of_repo(): r = cheap_run.read_file("../../../etc/hosts") assert not r["ok"] and "越界" in r["error"] def test_list_dir(): r = cheap_run.list_dir("game-runtime/games/_template/src") assert r["ok"] and "game-logic.js" in r["entries"] def test_write_l1_rejected(): _scaffold() r = cheap_run.write_file(GID, f"game-runtime/games/amgen-{GID}/src/host-config.js", "x") assert not r["ok"] and "L1" in r["error"] def test_write_l3_ok(): _scaffold() r = cheap_run.write_file(GID, f"game-runtime/games/amgen-{GID}/src/game-logic.js", "// hi\n") assert r["ok"] and r["bytes"] > 0 def test_check_shape_drawbutton_ok(): # 阶段一B #5:drawButton 改返命中矩形后,const btn = drawButton(...); if (btn) 是合法直觉写法、形状门不再拦。 _scaffold() _write_logic(_DRAWBUTTON_RETURN_OK) r = cheap_run.check(GID) assert r["ok"], f"drawButton 赋值消费应通过(改签名后返命中矩形):{r.get('output', '')}" def test_check_shape_define_fail(): _scaffold() _write_logic(_DEFINE_BAD) r = cheap_run.check(GID) assert not r["ok"], "define 单对象批量应触发形状门②" assert "define" in r["output"] def test_check_and_build_clean_pass(): """干净源(_template 合法游戏改一行使 ≠模板)应 check+build 全 PASS——证形状门/各门对合法游戏不误伤。""" _scaffold() tmpl = (cheap_run._TEMPLATE_DIR / "src" / "game-logic.js").read_text(encoding="utf-8") _write_logic("// cheap-worker U2 clean-pass 测试:在 _template 合法游戏基础上改一行使 ≠模板\n" + tmpl) rc = cheap_run.check(GID) assert rc["ok"], "干净源应 check PASS,实际 FAIL:\n" + rc["output"] rb = cheap_run.build(GID) assert rb["ok"], "干净源应 build PASS,实际 FAIL:\n" + rb["output"] def _tool_function(session: CheapSession, name: str): """从真实 Toolkit 取出指定函数,测试仍通过 AgentScope 的生产组装路径。""" toolkit = build_toolkit(session) for group in toolkit.tool_groups: for tool in group.tools: if tool.name == name: return tool._func raise AssertionError(f"Toolkit 缺少工具:{name}") def _call_tool(session: CheapSession, name: str, **kwargs): """同步测试辅助:执行生产 Toolkit 中的 async 工具函数。""" return asyncio.run(_tool_function(session, name)(**kwargs)) def test_reference_snapshot_read_ignores_rewritten_disk(monkeypatch, tmp_path): """快照捕获后磁盘同路径改写,read_file 仍只能返回旧 bytes。""" disk_file = tmp_path / "assets" / "gold" / "guide.md" disk_file.parent.mkdir(parents=True) disk_file.write_text("captured", encoding="utf-8") session = CheapSession( game_id="snapshot-read", reference_files={"assets/gold/guide.md": b"captured"}, reference_roots={"gold": ("assets/gold",)}, ) disk_file.write_text("rewritten", encoding="utf-8") calls = [] def live_read(path): calls.append(path) return {"ok": True, "content": disk_file.read_text(encoding="utf-8"), "truncated": False} monkeypatch.setattr(cheap_toolkit.cheap_run, "read_file", live_read) assert _call_tool(session, "read_file", path="assets/gold/guide.md") == "captured" assert calls == [] def test_reference_snapshot_rejects_unsigned_read_without_io(monkeypatch): """受保护根内未列入快照的文件必须拒绝,且不得触发活目录读取。""" session = CheapSession( game_id="snapshot-read-deny", reference_files={"assets/gold/allowed.txt": b"allowed"}, reference_roots={"gold": ("assets/gold",)}, ) calls = [] def live_read(path): calls.append(path) return {"ok": True, "content": "unsigned", "truncated": False} monkeypatch.setattr(cheap_toolkit.cheap_run, "read_file", live_read) result = _call_tool(session, "read_file", path="assets/gold/unsigned.txt") assert result.startswith("ERROR:") assert "unsigned" not in result assert calls == [] def test_reference_snapshot_lists_only_virtual_children_without_io(monkeypatch): """受保护目录只投影快照中的直接子项和虚拟子目录,不读取磁盘新增项。""" session = CheapSession( game_id="snapshot-list", reference_files={ "assets/gold/allowed.txt": b"allowed", "assets/gold/sub/child.txt": b"child", }, reference_roots={"gold": ("assets/gold",)}, ) calls = [] def live_list(path): calls.append(path) return {"ok": True, "entries": ["unsigned.txt"]} monkeypatch.setattr(cheap_toolkit.cheap_run, "list_dir", live_list) assert _call_tool(session, "list_dir", path="assets/gold") == "allowed.txt\nsub/" assert _call_tool(session, "list_dir", path="assets/gold/sub") == "child.txt" assert calls == [] def test_reference_prefix_similar_path_keeps_live_read_semantics(monkeypatch): """路径段相似但不属于受保护根时,仍走既有 cheap_run 读取语义。""" session = CheapSession( game_id="snapshot-prefix", reference_files={"assets/gold/allowed.txt": b"snapshot"}, reference_roots={"gold": ("assets/gold",)}, ) calls = [] def live_read(path): calls.append(path) return {"ok": True, "content": "live-golden", "truncated": False} monkeypatch.setattr(cheap_toolkit.cheap_run, "read_file", live_read) assert _call_tool(session, "read_file", path="assets/golden/allowed.txt") == "live-golden" assert calls == ["assets/golden/allowed.txt"] def test_reference_path_escape_cannot_bypass_protected_root(monkeypatch): """回到仓根的路径别名也必须拒绝,不能绕过受保护根读取活目录。""" session = CheapSession( game_id="snapshot-escape", reference_files={"assets/gold/allowed.txt": b"snapshot"}, reference_roots={"gold": ("assets/gold",)}, ) calls = [] def live_read(path): calls.append(path) return {"ok": True, "content": "disk", "truncated": False} monkeypatch.setattr(cheap_toolkit.cheap_run, "read_file", live_read) result = _call_tool(session, "read_file", path="../games-development-ai/assets/gold/unsigned.txt") assert result == "ERROR: 路径必须是仓内相对路径" assert calls == [] def test_empty_reference_snapshot_preserves_existing_read_and_list(monkeypatch): """未提供快照时保持现有 read_file/list_dir 回落行为。""" session = CheapSession(game_id="snapshot-empty") read_calls = [] list_calls = [] def live_read(path): read_calls.append(path) return {"ok": True, "content": "live-content", "truncated": False} def live_list(path): list_calls.append(path) return {"ok": True, "entries": ["live.txt"]} monkeypatch.setattr(cheap_toolkit.cheap_run, "read_file", live_read) monkeypatch.setattr(cheap_toolkit.cheap_run, "list_dir", live_list) assert _call_tool(session, "read_file", path=".agents/skills/example.md") == "live-content" assert _call_tool(session, "list_dir", path=".agents/skills") == "live.txt" assert read_calls == [".agents/skills/example.md"] assert list_calls == [".agents/skills"] def test_reference_snapshot_read_keeps_200kb_truncation_marker(monkeypatch): """快照 bytes 仍按现有 200KB 规则解码并带截断标记。""" session = CheapSession( game_id="snapshot-truncate", reference_files={"assets/gold/large.txt": b"x" * (200 * 1024 + 1)}, reference_roots={"gold": ("assets/gold",)}, ) monkeypatch.setattr( cheap_toolkit.cheap_run, "read_file", lambda path: (_ for _ in ()).throw(AssertionError("受保护快照不应调用 cheap_run.read_file")), ) result = _call_tool(session, "read_file", path="assets/gold/large.txt") assert result.startswith("[内容已截断]\n") assert len(result) == 30000 def test_reference_session_snapshot_fields_are_read_only(): """Session 保存的快照映射和受保护根索引不能被调用方改写。""" session = CheapSession( game_id="snapshot-immutable", reference_files={"assets/gold/a.txt": b"a"}, reference_roots={"gold": ("assets/gold",)}, ) with pytest.raises(TypeError): session.reference_files["assets/gold/b.txt"] = b"b" with pytest.raises(TypeError): session.reference_roots["other"] = ("assets/other",) with pytest.raises(AttributeError): session.reference_files = {} assert session.reference_roots["gold"] == ("assets/gold",) def test_reference_snapshot_requires_roots_for_nonempty_files(): """非空快照没有有效受保护根时,构造必须 fail-closed。""" for roots in (None, {}, {"gold": ()}): with pytest.raises((TypeError, ValueError)) as exc_info: CheapSession( game_id="snapshot-missing-roots", reference_files={"assets/gold/guide.md": b"secret"}, reference_roots=roots, ) assert "secret" not in str(exc_info.value) assert "/" not in str(exc_info.value) @pytest.mark.parametrize( "root", ("", "../sensitive-root", "/absolute/sensitive-root"), ) def test_reference_roots_reject_invalid_paths_without_leaking_input(root): """受保护根含空、越界或绝对路径时,构造不得静默过滤或泄露输入。""" with pytest.raises((TypeError, ValueError)) as exc_info: CheapSession( game_id="snapshot-invalid-root", reference_roots={"gold": (root,)}, ) message = str(exc_info.value) assert "sensitive-root" not in message assert "/absolute" not in message def test_reference_snapshot_rejects_files_outside_all_roots(): """任一快照文件未被受保护根覆盖时,构造必须拒绝整个索引。""" with pytest.raises((TypeError, ValueError)) as exc_info: CheapSession( game_id="snapshot-uncovered-file", reference_files={ "assets/gold/guide.md": b"allowed", "assets/other/secret.md": b"secret", }, reference_roots={"gold": ("assets/gold",)}, ) assert "secret" not in str(exc_info.value) assert "assets/other" not in str(exc_info.value) def test_empty_reference_files_with_roots_deny_read_and_list_without_io(monkeypatch): """有根无快照表示根内全部拒绝,read/list 均不得回落磁盘。""" session = CheapSession( game_id="snapshot-empty-files", reference_files={}, reference_roots={"gold": ("assets/gold",)}, ) read_calls = [] list_calls = [] def live_read(path): read_calls.append(path) return {"ok": True, "content": "disk-secret", "truncated": False} def live_list(path): list_calls.append(path) return {"ok": True, "entries": ["disk-secret.txt"]} monkeypatch.setattr(cheap_toolkit.cheap_run, "read_file", live_read) monkeypatch.setattr(cheap_toolkit.cheap_run, "list_dir", live_list) assert _call_tool(session, "read_file", path="assets/gold/unknown.txt").startswith("ERROR:") assert _call_tool(session, "list_dir", path="assets/gold") == "" assert read_calls == [] assert list_calls == [] def test_reference_list_path_alias_stays_virtual_without_io(monkeypatch): """受保护根的规范化别名仍使用虚拟列表,不能触发活目录 I/O。""" session = CheapSession( game_id="snapshot-list-alias", reference_files={"assets/gold/guide.md": b"snapshot"}, reference_roots={"gold": ("assets/gold",)}, ) calls = [] def live_list(path): calls.append(path) return {"ok": True, "entries": ["disk-secret.txt"]} monkeypatch.setattr(cheap_toolkit.cheap_run, "list_dir", live_list) assert _call_tool(session, "list_dir", path="assets/gold/./nested/../") == "guide.md" assert calls == [] def test_reference_virtual_list_keeps_toolkit_result_cap(): """虚拟目录投影过大时仍限制在 30KB 工具结果上限内。""" session = CheapSession( game_id="snapshot-list-cap", reference_files={ f"assets/gold/file-{index:05d}.txt": b"" for index in range(5000) }, reference_roots={"gold": ("assets/gold",)}, ) result = _call_tool(session, "list_dir", path="assets/gold") assert len(result.encode("utf-8")) == 30_000 def test_cheap_session_keeps_legacy_positional_arguments(): """旧的四个 CheapSession 位置参数仍按原字段接收。""" check = {"ok": True} build = {"ok": True} finished = {"summary": "legacy"} session = CheapSession("legacy", check, build, finished) assert session.game_id == "legacy" assert session.last_check is check assert session.last_build is build assert session.finished is finished if __name__ == "__main__": _fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] _failed = 0 for _fn in _fns: try: _fn() print(f" PASS {_fn.__name__}") except Exception as e: # noqa: BLE001 _failed += 1 print(f" FAIL {_fn.__name__}: {type(e).__name__}: {e}") print(f"\n{len(_fns) - _failed}/{len(_fns)} passed") sys.exit(1 if _failed else 0)