test_agentpack.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. """
  2. Tests for the AgentPack feature (M2 Phase D).
  3. Covers:
  4. - lambdagent.agentpack: manifest validation (kernel, pure)
  5. - agentpaas.engine.agentpack_store: safe install / list / uninstall
  6. - zip-slip defense
  7. - SR-003 shell refusal for third-party packs
  8. Spec: docs/AGENTPACK_SPEC.md.
  9. """
  10. from __future__ import annotations
  11. import os
  12. os.environ.setdefault("AGENTPAAS_DATABASE_URL", "sqlite:///:memory:")
  13. os.environ.setdefault("AGENTPAAS_TESTING", "1")
  14. import io
  15. import zipfile
  16. import pytest
  17. import yaml
  18. from lambdagent.agentpack import (
  19. AgentPackManifest,
  20. PackPermissions,
  21. AgentPackError,
  22. load_manifest,
  23. validate_manifest_dict,
  24. )
  25. from agentpaas.engine.agentpack_store import (
  26. install_from_zip,
  27. list_installed,
  28. get_installed,
  29. uninstall,
  30. AgentPackInstallError,
  31. )
  32. # ─────────────────────────────────────────────────────────────────────────────
  33. # Kernel: manifest validation
  34. # ─────────────────────────────────────────────────────────────────────────────
  35. _VALID = {
  36. "id": "research.top-journal-reviewer",
  37. "name": "Top Journal Reviewer",
  38. "version": "0.1.0",
  39. "domain": "research",
  40. "entrypoint": "agents/reviewer.yml",
  41. "permissions": {"network": False, "shell": False, "file_write": "workspace"},
  42. "audience": ["professor", "phd_student"],
  43. "model": {"recommended": ["claude-code/sonnet"]},
  44. }
  45. def test_manifest_valid():
  46. m = validate_manifest_dict(_VALID)
  47. assert m.id == "research.top-journal-reviewer"
  48. assert m.version == "0.1.0"
  49. assert m.permissions.file_write == "workspace"
  50. assert m.permissions.shell is False
  51. assert "professor" in m.audience
  52. assert m.model_recommended == ["claude-code/sonnet"]
  53. @pytest.mark.parametrize("missing", ["id", "name", "version", "domain", "entrypoint", "permissions"])
  54. def test_manifest_missing_required_field(missing):
  55. bad = dict(_VALID)
  56. bad.pop(missing)
  57. with pytest.raises(AgentPackError):
  58. validate_manifest_dict(bad)
  59. def test_manifest_bad_id_rejected():
  60. bad = dict(_VALID, id="Bad ID With Spaces!")
  61. with pytest.raises(AgentPackError):
  62. validate_manifest_dict(bad)
  63. def test_manifest_bad_version_rejected():
  64. bad = dict(_VALID, version="v1")
  65. with pytest.raises(AgentPackError):
  66. validate_manifest_dict(bad)
  67. def test_manifest_entrypoint_escape_rejected():
  68. bad = dict(_VALID, entrypoint="../../etc/passwd")
  69. with pytest.raises(AgentPackError):
  70. validate_manifest_dict(bad)
  71. def test_manifest_bad_file_write_scope_rejected():
  72. bad = dict(_VALID, permissions={"file_write": "everywhere"})
  73. with pytest.raises(AgentPackError):
  74. validate_manifest_dict(bad)
  75. def test_permissions_deny_by_default():
  76. # Empty permissions block → safe defaults.
  77. m = validate_manifest_dict(dict(_VALID, permissions={}))
  78. assert m.permissions.network is False
  79. assert m.permissions.shell is False
  80. assert m.permissions.file_write == "workspace"
  81. def test_permission_summary_zh():
  82. m = validate_manifest_dict(_VALID)
  83. s = m.permissions.summary_zh()
  84. assert "读取本地知识库" in s
  85. assert "不执行 shell" in s
  86. assert "不访问网络" in s
  87. # ─────────────────────────────────────────────────────────────────────────────
  88. # Helpers to build pack zips
  89. # ─────────────────────────────────────────────────────────────────────────────
  90. def _make_pack_zip(tmp_path, manifest_dict, *, entrypoint_body="type: react\nname: x\nsystemPrompt: hi\n", nested=False, extra_members=None):
  91. """Build a .zip containing manifest.yml + the entrypoint config.
  92. If nested=True, wraps everything in a top-level dir."""
  93. prefix = "mypack/" if nested else ""
  94. zpath = tmp_path / "pack.zip"
  95. with zipfile.ZipFile(zpath, "w") as zf:
  96. zf.writestr(prefix + "manifest.yml", yaml.safe_dump(manifest_dict, allow_unicode=True))
  97. zf.writestr(prefix + manifest_dict["entrypoint"], entrypoint_body)
  98. for name, body in (extra_members or {}).items():
  99. zf.writestr(prefix + name, body)
  100. return str(zpath)
  101. # ─────────────────────────────────────────────────────────────────────────────
  102. # Store: install / list / get / uninstall
  103. # ─────────────────────────────────────────────────────────────────────────────
  104. def test_install_list_get_uninstall_roundtrip(tmp_path):
  105. packs_dir = tmp_path / "agentpacks"
  106. packs_dir.mkdir()
  107. zp = _make_pack_zip(tmp_path, _VALID)
  108. pack = install_from_zip(zp, str(packs_dir))
  109. assert pack.id == "research.top-journal-reviewer"
  110. assert pack.version == "0.1.0"
  111. assert os.path.isfile(os.path.join(pack.path, "manifest.yml"))
  112. assert os.path.isfile(os.path.join(pack.path, "agents", "reviewer.yml"))
  113. listed = list_installed(str(packs_dir))
  114. assert len(listed) == 1
  115. assert listed[0].id == pack.id
  116. got = get_installed(str(packs_dir), pack.id)
  117. assert got is not None and got.version == "0.1.0"
  118. n = uninstall(str(packs_dir), pack.id)
  119. assert n == 1
  120. assert list_installed(str(packs_dir)) == []
  121. def test_install_nested_pack_zip(tmp_path):
  122. """A zip with everything under a single top-level dir still installs."""
  123. packs_dir = tmp_path / "agentpacks"
  124. packs_dir.mkdir()
  125. zp = _make_pack_zip(tmp_path, _VALID, nested=True)
  126. pack = install_from_zip(zp, str(packs_dir))
  127. assert pack.id == "research.top-journal-reviewer"
  128. def test_install_rejects_missing_entrypoint(tmp_path):
  129. """Manifest references agents/reviewer.yml but the zip doesn't contain it."""
  130. packs_dir = tmp_path / "agentpacks"
  131. packs_dir.mkdir()
  132. zpath = tmp_path / "pack.zip"
  133. with zipfile.ZipFile(zpath, "w") as zf:
  134. zf.writestr("manifest.yml", yaml.safe_dump(_VALID))
  135. # entrypoint deliberately absent
  136. with pytest.raises((AgentPackInstallError, AgentPackError)):
  137. install_from_zip(str(zpath), str(packs_dir))
  138. def test_install_refuses_third_party_shell(tmp_path):
  139. """SR-003: a pack declaring shell:true is refused unless allow_shell."""
  140. packs_dir = tmp_path / "agentpacks"
  141. packs_dir.mkdir()
  142. shell_manifest = dict(_VALID, id="research.shelly",
  143. permissions={"shell": True, "file_write": "workspace"})
  144. zp = _make_pack_zip(tmp_path, shell_manifest)
  145. with pytest.raises(AgentPackInstallError) as exc:
  146. install_from_zip(zp, str(packs_dir))
  147. assert "shell" in str(exc.value).lower()
  148. # With allow_shell it goes through.
  149. pack = install_from_zip(zp, str(packs_dir), allow_shell=True)
  150. assert pack.manifest.permissions.shell is True
  151. def test_install_rejects_zip_slip(tmp_path):
  152. """A malicious member with ../ must be rejected (SPEC §6 zip-slip)."""
  153. packs_dir = tmp_path / "agentpacks"
  154. packs_dir.mkdir()
  155. zpath = tmp_path / "evil.zip"
  156. with zipfile.ZipFile(zpath, "w") as zf:
  157. zf.writestr("manifest.yml", yaml.safe_dump(_VALID))
  158. zf.writestr("agents/reviewer.yml", "type: react\n")
  159. zf.writestr("../../escape.txt", "pwned")
  160. with pytest.raises(AgentPackInstallError) as exc:
  161. install_from_zip(str(zpath), str(packs_dir))
  162. assert "escape" in str(exc.value).lower() or "unsafe" in str(exc.value).lower()
  163. def test_uninstall_nonexistent_returns_zero(tmp_path):
  164. packs_dir = tmp_path / "agentpacks"
  165. packs_dir.mkdir()
  166. assert uninstall(str(packs_dir), "does.not.exist") == 0
  167. # ─────────────────────────────────────────────────────────────────────────────
  168. # Built-in packs (FR-008) — regression guard: the 3 bundled research packs
  169. # must always load, validate, and compile.
  170. # ─────────────────────────────────────────────────────────────────────────────
  171. _BUNDLED_IDS = {
  172. "research.top-journal-reviewer",
  173. "research.literature-mapper",
  174. "research.grant-planner",
  175. }
  176. def _bundled_pack_dirs():
  177. import glob
  178. repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
  179. return sorted(glob.glob(os.path.join(repo_root, "agentexample", "agentpacks", "*", "manifest.yml")))
  180. def test_bundled_packs_present():
  181. found = {load_manifest(os.path.dirname(p)).id for p in _bundled_pack_dirs()}
  182. assert _BUNDLED_IDS.issubset(found), f"missing bundled packs: {_BUNDLED_IDS - found}"
  183. @pytest.mark.parametrize("manifest_path", _bundled_pack_dirs())
  184. def test_bundled_pack_valid_and_compiles(manifest_path):
  185. """Each bundled pack: manifest validates, entrypoint exists, and the
  186. entrypoint config compiles via from_config (it's a real runnable agent)."""
  187. from lambdagent import from_config
  188. pack_dir = os.path.dirname(manifest_path)
  189. m = load_manifest(pack_dir)
  190. assert os.path.isfile(m.entrypoint_path()), m.entrypoint
  191. # Built-in research packs are local-first: no shell, no network.
  192. assert m.permissions.shell is False
  193. assert m.permissions.network is False
  194. # The entrypoint must compile to a runnable term.
  195. term = from_config(m.entrypoint_path())
  196. assert term is not None
  197. # ─────────────────────────────────────────────────────────────────────────────
  198. # Phase G: agentpack → agent creation endpoint integration
  199. # ─────────────────────────────────────────────────────────────────────────────
  200. def test_create_agent_from_pack_endpoint(tmp_path, monkeypatch):
  201. """End-to-end: install a synthetic pack, POST /agentpacks/{id}/create-agent,
  202. verify the new agent row carries pack.path as agent_dir and pack.id as
  203. agent_template."""
  204. import json
  205. import secrets
  206. from fastapi.testclient import TestClient
  207. from agentpaas.api.app import app
  208. from agentpaas.api.middleware.auth import hash_key
  209. from agentpaas.config import settings
  210. from agentpaas.db.models import Database, gen_id, now_utc
  211. import agentpaas.db.session as _session_mod
  212. # Redirect settings.agentpacks_dir into tmp so we don't touch ~/...
  213. packs_dir = tmp_path / "agentpacks"
  214. packs_dir.mkdir()
  215. monkeypatch.setattr(settings, "agentpacks_dir", str(packs_dir))
  216. # Install a pack via the public function.
  217. pack_zip = _make_pack_zip(tmp_path, _VALID)
  218. pack = install_from_zip(pack_zip, str(packs_dir))
  219. assert pack.id == "research.top-journal-reviewer"
  220. # Fresh in-memory DB + a real tenant + key.
  221. prev_db = _session_mod._db
  222. _session_mod._db = Database("sqlite:///:memory:")
  223. db = _session_mod._db
  224. try:
  225. tid = gen_id("tn_")
  226. uid = gen_id("usr_")
  227. raw_key = f"ap_{secrets.token_hex(16)}"
  228. now = now_utc()
  229. db.execute(
  230. "INSERT INTO tenants (id, name, plan, status, created_at) "
  231. "VALUES (?, 'test', 'free', 'active', ?)", (tid, now),
  232. )
  233. db.execute(
  234. "INSERT INTO users (id, tenant_id, email, role, created_at) "
  235. "VALUES (?, ?, '', 'admin', ?)", (uid, tid, now),
  236. )
  237. db.execute(
  238. "INSERT INTO api_keys "
  239. "(id, tenant_id, user_id, key_hash, key_prefix, name, scopes, "
  240. " rate_limit, status, created_at) "
  241. "VALUES (?, ?, ?, ?, ?, 'test', ?, 600, 'active', ?)",
  242. (gen_id("key_"), tid, uid, hash_key(raw_key), raw_key[:8],
  243. json.dumps(["agents:*", "keys:*"]), now),
  244. )
  245. db.commit()
  246. with TestClient(app) as client:
  247. r = client.post(
  248. f"/api/v1/agentpacks/{pack.id}/create-agent",
  249. json={"name": "My Reviewer", "description": "from a pack"},
  250. headers={"Authorization": f"Bearer {raw_key}"},
  251. )
  252. assert r.status_code == 201, r.text[:300]
  253. body = r.json()
  254. assert body["ok"] is True
  255. agent_id = body["agent_id"]
  256. assert body["pack"]["id"] == pack.id
  257. assert body["pack"]["version"] == pack.version
  258. # Verify DB state: agent_dir = pack.path, agent_template = pack.id.
  259. row = db.fetchone(
  260. "SELECT agent_dir, agent_template, name FROM agents WHERE id = ?",
  261. (agent_id,),
  262. )
  263. assert row["agent_dir"] == pack.path
  264. assert row["agent_template"] == pack.id
  265. assert row["name"] == "My Reviewer"
  266. # The initial version's config carries _config_dir = pack.path so
  267. # any sub-agent ref inside the pack resolves correctly.
  268. ver = db.fetchone(
  269. "SELECT config FROM agent_versions WHERE agent_id = ? AND version = 1",
  270. (agent_id,),
  271. )
  272. cfg = json.loads(ver["config"])
  273. assert cfg.get("_config_dir") == pack.path
  274. finally:
  275. _session_mod._db = prev_db
  276. def test_create_agent_from_unknown_pack_returns_404(tmp_path, monkeypatch):
  277. """POST against a pack_id that isn't installed → 404."""
  278. from fastapi.testclient import TestClient
  279. from agentpaas.api.app import app
  280. from agentpaas.config import settings
  281. monkeypatch.setattr(settings, "agentpacks_dir", str(tmp_path / "empty"))
  282. (tmp_path / "empty").mkdir()
  283. with TestClient(app) as client:
  284. # No auth — gate-blocking 401 is the first dependency that fires,
  285. # which is fine; the assertion we care about is "no 5xx, no
  286. # accidental success". 401 covers both paths cleanly.
  287. r = client.post(
  288. "/api/v1/agentpacks/does.not.exist/create-agent",
  289. json={"name": "x"},
  290. )
  291. assert r.status_code in (401, 404)