Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@

## [Unreleased]

### Fixed

- Local `MEDIA:` image tokens in chat history now include the current session id and can render exact image paths already present in that session transcript, so agent-generated artifacts outside the active workspace no longer show as broken thumbnails while arbitrary local paths remain blocked.

## [v0.51.152] — 2026-05-28 — Release DX (stage-batch34 — single-PR optional gateway-backed browser chat)

### Added
Expand Down
64 changes: 59 additions & 5 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -7540,6 +7540,55 @@ def _serve_inline_html_preview(handler, target: Path, cache_control: str, *, csp
return True


_MEDIA_TOKEN_RE = re.compile(r"MEDIA:([^\s\)\]]+)")


def _message_content_text(content) -> str:
if isinstance(content, list):
parts = []
for part in content:
if isinstance(part, dict):
parts.append(str(part.get("text") or ""))
else:
parts.append(str(part or ""))
return "\n".join(parts)
return str(content or "")


def _session_media_token_allows_image_path(sid: str, target: Path, image_mimes: set[str]) -> bool:
"""Allow exact MEDIA:image paths already present in the requested session."""
sid = str(sid or "").strip()
if not sid:
return False
mime = MIME_MAP.get(target.suffix.lower(), "application/octet-stream")
if mime not in image_mimes:
return False
try:
target_resolved = target.resolve()
except Exception:
return False
try:
session = get_session(sid)
except Exception:
return False

for message in getattr(session, "messages", []) or []:
if not isinstance(message, dict):
continue
text = _message_content_text(message.get("content"))
if "MEDIA:" not in text:
continue
for ref in _MEDIA_TOKEN_RE.findall(text):
if "://" in ref:
continue
try:
if Path(ref).expanduser().resolve() == target_resolved:
return True
except Exception:
continue
return False


def _handle_media(handler, parsed):
"""Serve a local file by absolute path for inline display in the chat.

Expand Down Expand Up @@ -7611,12 +7660,21 @@ def _handle_media(handler, parsed):
except Exception:
pass

_INLINE_IMAGE_TYPES = {
"image/png", "image/jpeg", "image/gif", "image/webp",
"image/x-icon", "image/bmp",
}
within_allowed = any(
_os.path.commonpath([str(target), str(root)]) == str(root)
for root in allowed_roots
if root.exists()
)
if not within_allowed:
session_media_allowed = _session_media_token_allows_image_path(
qs.get("session_id", [""])[0],
target,
_INLINE_IMAGE_TYPES,
)
if not within_allowed and not session_media_allowed:
return bad(handler, "Path not in allowed location", 403)

if not target.exists() or not target.is_file():
Expand All @@ -7629,10 +7687,6 @@ def _handle_media(handler, parsed):
# Only serve safe media/PDF types inline when explicitly requested. HTML is
# allowed inline only with a CSP sandbox so "open full page" can work without
# granting same-origin access to the WebUI. SVG is always a download (XSS risk).
_INLINE_IMAGE_TYPES = {
"image/png", "image/jpeg", "image/gif", "image/webp",
"image/x-icon", "image/bmp",
}
_INLINE_PREVIEW_TYPES = _INLINE_IMAGE_TYPES | {
"audio/mpeg", "audio/wav", "audio/x-wav", "audio/mp4", "audio/aac",
"audio/ogg", "audio/opus", "audio/flac",
Expand Down
3 changes: 2 additions & 1 deletion static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -3419,7 +3419,8 @@ function renderMd(raw){
return `<a href="${esc(src)}" target="_blank" rel="noopener">${esc(src)}</a>`;
}
// Local file path
const apiUrl='api/media?path='+encodeURIComponent(ref);
const mediaSessionId=(typeof S!=='undefined'&&S&&S.session&&S.session.session_id)?String(S.session.session_id):'';
const apiUrl='api/media?path='+encodeURIComponent(ref)+(mediaSessionId?'&session_id='+encodeURIComponent(mediaSessionId):'');
const localKind=mediaKindForName(ref);
if(localKind==='image'){
return `<img class="msg-media-img" src="${esc(apiUrl)}" alt="${esc(ref.split('/').pop())}" loading="lazy">`;
Expand Down
48 changes: 48 additions & 0 deletions tests/test_media_inline.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import pathlib
import tempfile
import unittest
from types import SimpleNamespace
from unittest import mock
import urllib.error
import urllib.request

Expand Down Expand Up @@ -51,6 +53,10 @@ def test_media_api_url_pattern(self):
self.assertIn("api/media?path=", UI_JS,
"renderMd must build api/media?path=... URL for local files")

def test_local_media_api_url_carries_session_id_when_available(self):
self.assertIn("session_id='+encodeURIComponent(mediaSessionId)", UI_JS,
"local MEDIA: image URLs must include session_id so the server can authorize session-referenced artifacts")

def test_local_audio_video_media_tokens_request_inline_streaming(self):
self.assertIn("apiUrl+'&inline=1'", UI_JS,
"MEDIA: audio/video local paths must request inline streaming")
Expand Down Expand Up @@ -255,6 +261,48 @@ def test_media_endpoints_advertise_byte_range_support(self):
self.assertIn("Content-Range", routes_src)
self.assertIn("206", routes_src)

def test_session_media_token_allows_exact_image_path(self):
from api import routes

with tempfile.TemporaryDirectory() as tmpd:
image = pathlib.Path(tmpd) / "card.png"
image.write_bytes(b"\x89PNG\r\n\x1a\n")
session = SimpleNamespace(messages=[{"role": "assistant", "content": f"MEDIA:{image}"}])
with mock.patch.object(routes, "get_session", return_value=session):
self.assertTrue(
routes._session_media_token_allows_image_path(
"s-media", image, {"image/png"}
)
)

def test_session_media_token_rejects_unmentioned_image_path(self):
from api import routes

with tempfile.TemporaryDirectory() as tmpd:
image = pathlib.Path(tmpd) / "card.png"
image.write_bytes(b"\x89PNG\r\n\x1a\n")
session = SimpleNamespace(messages=[{"role": "assistant", "content": "MEDIA:/tmp/other.png"}])
with mock.patch.object(routes, "get_session", return_value=session):
self.assertFalse(
routes._session_media_token_allows_image_path(
"s-media", image, {"image/png"}
)
)

def test_session_media_token_rejects_non_image_path(self):
from api import routes

with tempfile.TemporaryDirectory() as tmpd:
text_file = pathlib.Path(tmpd) / "notes.txt"
text_file.write_text("secret", encoding="utf-8")
session = SimpleNamespace(messages=[{"role": "assistant", "content": f"MEDIA:{text_file}"}])
with mock.patch.object(routes, "get_session", return_value=session):
self.assertFalse(
routes._session_media_token_allows_image_path(
"s-media", text_file, {"image/png"}
)
)


# ── Integration tests: live server on TEST_PORT ───────────────────────────────
# No collection-time skip guard — conftest.py starts the server via its
Expand Down
Loading