test_artifacts.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. from __future__ import annotations
  2. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  3. from io import BytesIO
  4. import tempfile
  5. import threading
  6. import unittest
  7. from pathlib import Path
  8. from urllib.request import urlopen
  9. from PIL import Image
  10. from production_build_agents.contracts.models import (
  11. Artifact,
  12. ToolArtifactMapping,
  13. ToolCallRecord,
  14. )
  15. from production_build_agents.run.artifacts import (
  16. ArtifactIntegrityError,
  17. content_identity_for_bytes,
  18. content_identity_for_path,
  19. resolve_candidate_artifact_path,
  20. seal_shared_visual_anchor,
  21. verify_artifact,
  22. verify_shared_visual_anchor,
  23. )
  24. from production_build_agents.tools.media import probe_media
  25. def _png_bytes(color: str) -> bytes:
  26. output = BytesIO()
  27. Image.new("RGB", (2, 2), color=color).save(output, format="PNG")
  28. return output.getvalue()
  29. class _MutableImageHandler(BaseHTTPRequestHandler):
  30. body = _png_bytes("red")
  31. def do_GET(self) -> None:
  32. self.send_response(200)
  33. self.send_header("Content-Type", "image/png")
  34. self.send_header("Content-Length", str(len(self.body)))
  35. self.end_headers()
  36. self.wfile.write(self.body)
  37. def log_message(self, format: str, *args: object) -> None:
  38. del format, args
  39. class ArtifactIntegrityTest(unittest.TestCase):
  40. def test_ass_uses_stable_document_mime(self) -> None:
  41. self.assertEqual(
  42. content_identity_for_bytes(
  43. b"[Script Info]\n",
  44. filename="subtitles.ass",
  45. )["mime_type"],
  46. "text/x-ass",
  47. )
  48. with tempfile.TemporaryDirectory() as temp_dir:
  49. path = Path(temp_dir) / "subtitles.ass"
  50. path.write_text("[Script Info]\n", encoding="utf-8")
  51. self.assertEqual(
  52. content_identity_for_path(path)["mime_type"],
  53. "text/x-ass",
  54. )
  55. def test_remote_candidate_resolves_to_single_local_copy(self) -> None:
  56. with tempfile.TemporaryDirectory() as temp_dir:
  57. local = Path(temp_dir) / "sealed.png"
  58. local.write_bytes(b"sealed")
  59. remote = "https://example.test/mutable.png"
  60. record = ToolCallRecord(
  61. tool_call_id="view-1",
  62. tool_name="view_images",
  63. success=True,
  64. artifact_mappings=[
  65. ToolArtifactMapping(
  66. source=remote,
  67. local_path=str(local),
  68. )
  69. ],
  70. )
  71. self.assertEqual(
  72. resolve_candidate_artifact_path(remote, [record]),
  73. local.resolve(),
  74. )
  75. def test_formal_remote_uri_is_rejected(self) -> None:
  76. with tempfile.TemporaryDirectory() as temp_dir:
  77. cached = Path(temp_dir) / "old.png"
  78. cached.write_bytes(b"old-sealed-content")
  79. remote = "https://example.test/mutable.png"
  80. artifact = Artifact(
  81. artifact_id="Task1-v1-artifact-1",
  82. artifact_type="image",
  83. uri=remote,
  84. source_uri=remote,
  85. description="不得作为正式远程 URI",
  86. **content_identity_for_path(cached),
  87. )
  88. with self.assertRaisesRegex(
  89. ArtifactIntegrityError,
  90. "必须是本地封印路径",
  91. ):
  92. verify_artifact(artifact)
  93. def test_shared_visual_anchor_rejects_local_content_replacement(self) -> None:
  94. with tempfile.TemporaryDirectory() as temp_dir:
  95. path = Path(temp_dir) / "anchor.png"
  96. path.write_bytes(_png_bytes("red"))
  97. anchor = seal_shared_visual_anchor(path)
  98. path.write_bytes(_png_bytes("blue"))
  99. with self.assertRaisesRegex(
  100. ArtifactIntegrityError,
  101. "视觉锚点内容已变化",
  102. ):
  103. verify_shared_visual_anchor(anchor)
  104. def test_localized_artifact_survives_remote_url_content_replacement(
  105. self,
  106. ) -> None:
  107. server = ThreadingHTTPServer(("127.0.0.1", 0), _MutableImageHandler)
  108. thread = threading.Thread(target=server.serve_forever, daemon=True)
  109. thread.start()
  110. try:
  111. with tempfile.TemporaryDirectory() as temp_dir:
  112. remote = (
  113. f"http://127.0.0.1:{server.server_port}/mutable.png"
  114. )
  115. original_remote = _png_bytes("red")
  116. replacement_remote = _png_bytes("blue")
  117. _MutableImageHandler.body = original_remote
  118. result = probe_media(
  119. remote,
  120. output_dir=Path(temp_dir),
  121. )
  122. localized = Path(result["local_path"])
  123. artifact = Artifact(
  124. artifact_id="Task1-v1-artifact-1",
  125. artifact_type="image",
  126. uri=str(localized),
  127. source_uri=remote,
  128. description="正式产物使用首次本地化并封印的内容",
  129. **content_identity_for_path(localized),
  130. )
  131. _MutableImageHandler.body = replacement_remote
  132. with urlopen(remote, timeout=2) as response:
  133. self.assertEqual(response.read(), replacement_remote)
  134. self.assertEqual(localized.read_bytes(), original_remote)
  135. verify_artifact(artifact)
  136. finally:
  137. server.shutdown()
  138. server.server_close()
  139. thread.join(timeout=2)
  140. if __name__ == "__main__":
  141. unittest.main()