fix: render file artifact links as media

This commit is contained in:
AJV20 authored and nesquena-hermes committed 2026-05-31 02:24:55 +00:00
1 parent 0405fefb5e
commit aae3b418f0
3 files changed
+37 -1

No files matched your search

+8
View File
@@ -3,6 +3,14 @@
## [Unreleased]
## [v0.51.183] — 2026-05-31 — Release FC (stage-batchC — inline file:// media artifacts + /api/media state-file confinement)
### Fixed
- Render assistant-emitted `file://` image artifact links inline through the authenticated `/api/media` route, so browser clients can view generated local images instead of seeing unusable server-local file paths. Only bare (line-start or whitespace-delimited) `file://` URLs are rewritten — `[label](file://...)` markdown anchors keep the normal link path (#3219).
### Security
- `/api/media` now hard-denies WebUI state and secret/config files even when they fall under an allowed root (the WebUI state dir, `settings.json`, `state.db`, `auth.json`, `auth.lock`, `config.yaml`, `.env`, signing/PBKDF2 keys, the `sessions`/`memories`/`profiles` state subdirs). Previously the whole Hermes home was an allowed root, so an authenticated session viewing attacker-influenced agent output that emitted a `file://`/`MEDIA:` link to such a file could fetch it. Hardened the boundary at the route for every entry path (bare `file://`, markdown anchors, and `MEDIA:` tokens), closing #3234.
## [v0.51.182] — 2026-05-31 — Release FB (stage-batchB2 — headless browser smoke gate + consolidated client-disconnect handling)
### Added
+20 -1
View File
@@ -3014,6 +3014,16 @@ function renderMd(raw){
media_stash.push(raw_ref);
return '\x00D'+(media_stash.length-1)+'\x00';
});
// Some gateway/tool surfaces still emit file:// links for local artifacts
// instead of MEDIA: tokens. Browser clients cannot open the server's
// filesystem directly, so treat bare file:// URLs as media artifacts and
// route them through /api/media. This intentionally runs only for bare URLs
// (line-start or whitespace-delimited), not inside markdown links, so normal
// [label](file://...) anchors continue to use the link path below.
s=s.replace(/(^|\s)(file:\/\/[^\s<>"')\]]+)/g,(_,lead,raw_ref)=>{
media_stash.push(raw_ref);
return lead+'\x00D'+(media_stash.length-1)+'\x00';
});
// ── End MEDIA stash ─────────────────────────────────────────────────────────
// Pre-pass: decode HTML entities first so markdown processing works correctly.
// This prevents double-escaping when LLM outputs entities like &lt; &gt; &amp;
@@ -3449,7 +3459,7 @@ function renderMd(raw){
s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]);
// ── Restore MEDIA stash → inline images or download links ─────────────────
s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{
const ref=media_stash[+i];
let ref=media_stash[+i];
// Keep this logic self-contained: some tests extract renderMd() alone and
// execute it in node, without the top-level helper functions from ui.js.
const mediaKindForName=(name='')=>{
@@ -3468,6 +3478,15 @@ function renderMd(raw){
: `<audio class="msg-media-player msg-media-audio" src="${safeSrc}" controls preload="metadata" title="${safeName}"></audio>`;
return `<div class="msg-media-editor msg-media-editor--${kind}" data-media-kind="${kind}">${tag}<div class="msg-media-meta"><span class="msg-media-name">${safeName}</span></div></div>`;
};
if(/^file:\/\//i.test(ref)){
try{
const u=new URL(ref);
ref=decodeURIComponent(u.pathname||ref.replace(/^file:\/\//i,''));
}catch(_){
try{ref=decodeURIComponent(ref.replace(/^file:\/\//i,''));}
catch(__){ref=ref.replace(/^file:\/\//i,'');}
}
}
// HTTP(S) URL
if(/^https?:\/\//i.test(ref)){
// Rewrite localhost/127.0.0.1 to the actual server base URL so remote
+9
View File
@@ -41,6 +41,15 @@ class TestMediaRenderMdStash(unittest.TestCase):
self.assertIn("MEDIA:", UI_JS,
"MEDIA: token regex must be present in renderMd()")
def test_bare_file_urls_are_stashed_as_media_artifacts(self):
self.assertIn("file:// links for local artifacts", UI_JS)
self.assertIn("file:\\/\\/[^\\s<>", UI_JS)
def test_file_urls_are_rewritten_through_media_endpoint(self):
self.assertIn("new URL(ref)", UI_JS)
self.assertIn("u.pathname", UI_JS)
self.assertIn("api/media?path=", UI_JS)
def test_media_restore_produces_img_tag(self):
self.assertIn("msg-media-img", UI_JS,
"restore pass must produce <img class='msg-media-img'>")