Add json and csv artifact kinds with interactive viewers

Gap 1 from planning/2026-09-02-artifact_kind_gaps_for_frequent_agent_output.md.
Chose new first-class kinds over riding on html_fragment, resolving that
doc's open design question in favor of filtering support, raw-source
preservation (like markdown's .md sidecar), and server-owned validation.

- json: validated with json.loads at post time (400 on invalid JSON),
  rendered with renderjson (collapsible tree), vendored locally.
- csv: parsed server-side with the stdlib csv module into columns/rows and
  handed to Grid.js as plain data - no client-side CSV-parsing plugin
  needed, keeping normalization server-owned. Vendored locally (MIT).
- Both preserve their original text as a sidecar file, generalizing what
  was previously markdown-only handling (storage.py's SIDECAR_SUFFIXES) so
  disk-size accounting, eviction cleanup, and ?raw=1 all cover json/csv too.
- Raw JSON is embedded in a <script type="application/json"> block with
  `</script` occurrences escaped (safe because JSON's \/ escape round-trips
  correctly) - verified with a live </script>-injection payload against the
  actual running server, not just unit tests.

New migration (0012) for the KIND_CHOICES change - SQLite doesn't enforce
choices at the schema level, but Django's migration state tracks it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-02 14:56:20 +01:00
parent af9ecf28a9
commit e5d2f192df
12 changed files with 476 additions and 18 deletions

View file

@ -1,3 +1,4 @@
import json
import shutil
import tempfile
from pathlib import Path
@ -369,3 +370,60 @@ class ArtifactAPITestCase(APITestCase):
self.assertIn('class="codehilite"', stored_html)
self.assertIn('<span class="k">def</span>', stored_html)
self.assertIn('href="http://testserver/static/themes/pygments.css"', stored_html)
# -- json kind ------------------------------------------------------------
def test_create_json_artifact(self):
response = self.create_artifact(kind="json", content='{"a": 1, "b": [1, 2, 3]}')
self.assertEqual(response.status_code, 201)
artifact = Artifact.objects.get(pk=response.data["id"])
stored_html = (self.artifacts_root / artifact.path).read_text(encoding="utf-8")
self.assertIn('renderjson/renderjson.js', stored_html)
self.assertIn('type="application/json"', stored_html)
# raw sidecar preserves the exact original JSON text
raw_path = (self.artifacts_root / artifact.path).with_suffix(".json")
self.assertEqual(raw_path.read_text(encoding="utf-8"), '{"a": 1, "b": [1, 2, 3]}')
def test_create_json_artifact_rejects_invalid_json(self):
response = self.create_artifact(kind="json", content="{not valid json")
self.assertEqual(response.status_code, 400)
def test_json_artifact_escapes_script_close_tag_safely(self):
payload = json.dumps({"value": "</script><script>alert(1)</script>"})
response = self.create_artifact(kind="json", content=payload)
self.assertEqual(response.status_code, 201)
artifact = Artifact.objects.get(pk=response.data["id"])
stored_html = (self.artifacts_root / artifact.path).read_text(encoding="utf-8")
# The page has exactly 3 real <script> elements (renderjson lib, the
# embedded data block, the init snippet). If the injected payload's
# "</script" wasn't escaped, it would add a 4th closing tag here.
self.assertEqual(stored_html.count("</script>"), 3)
# The escaped content must still round-trip to the exact original value.
start = stored_html.index('id="daybook-data">') + len('id="daybook-data">')
end = stored_html.index("</script>", start)
embedded = stored_html[start:end]
self.assertEqual(json.loads(embedded)["value"], "</script><script>alert(1)</script>")
# -- csv kind ---------------------------------------------------------------
def test_create_csv_artifact(self):
response = self.create_artifact(kind="csv", content="name,score\nalice,10\nbob,7")
self.assertEqual(response.status_code, 201)
artifact = Artifact.objects.get(pk=response.data["id"])
stored_html = (self.artifacts_root / artifact.path).read_text(encoding="utf-8")
self.assertIn("gridjs/gridjs.umd.js", stored_html)
self.assertIn('"columns": ["name", "score"]', stored_html)
self.assertIn('["alice", "10"]', stored_html)
raw_path = (self.artifacts_root / artifact.path).with_suffix(".csv")
self.assertEqual(raw_path.read_text(encoding="utf-8"), "name,score\nalice,10\nbob,7")
def test_create_csv_artifact_with_header_only(self):
response = self.create_artifact(kind="csv", content="name,score")
self.assertEqual(response.status_code, 201)