Merge pull request #3240 from nesquena/release/stage-batchC

Release stage-batchC → v0.51.183 (#3219 inline file:// media + #3234 /api/media secret-file confinement)
This commit is contained in:
nesquena-hermes
2026-05-30 21:21:15 -07:00
committed by GitHub
6 changed files with 514 additions and 5 deletions
+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
+171
View File
@@ -8083,6 +8083,177 @@ def _handle_media(handler, parsed):
target,
_INLINE_IMAGE_TYPES,
)
# ── #3234: hard-deny Hermes's own state + secret/config files ────────────
# The allowlist above grants the whole Hermes home (and base ~/.hermes), so
# an authenticated session rendering attacker-influenced agent output that
# emits a file:// / MEDIA: link to a state/secret file could fetch it
# through /api/media. This guard runs BEFORE the allow/serve decision so it
# covers every entry path (bare file:// URLs, markdown anchors, MEDIA:
# tokens, and session-token grants).
#
# Model: the ACTIVE WORKSPACE is a legitimate-media carve-out — the user is
# entitled to their own workspace files (that is also how the workspace file
# browser reaches them), even when a workspace happens to live under a
# Hermes root. The deny rules target Hermes's OWN internal state, which lives
# OUTSIDE any workspace. So: if the target is inside the active workspace, it
# is never denied here; otherwise we deny known secret/config basenames and
# the internal state subdirectories across every Hermes root the allowlist
# accepts (active-profile HERMES_HOME, base ~/.hermes, the api.profiles
# default home, and STATE_DIR — which also defends sibling profiles).
_DENY_FILENAMES = {
"settings.json", "state.db", "state.db-wal", "state.db-shm",
"auth.json", "auth.lock", "config.yaml", "config.yml", ".env",
".signing_key", ".pbkdf2_key", ".sessions.json",
"google_token.json", "google_client_secret.json",
"gateway_state.json", "channel_directory.json", "jobs.json",
"passkeys.json", ".passkey_challenges.json", ".login_attempts.json",
}
# Internal state subdirs that are sensitive in their entirety. NOTE:
# `profiles` is intentionally NOT here — it is a container of profile roots,
# each of which has its own legitimate workspace/. We instead enumerate each
# named-profile root below and deny ITS state subdirs, so a sibling profile's
# secrets are blocked without 403-ing a named-profile workspace. (#3234.)
_DENY_SUBDIRS = (
"sessions", "memories", "cron", "logs",
"checkpoints", "backups",
)
_state_dir = None
try:
from api.config import STATE_DIR as _STATE_DIR
_state_dir = Path(_STATE_DIR).resolve()
except Exception:
_state_dir = None
_base_hermes_home = None
try:
from api.profiles import _DEFAULT_HERMES_HOME as _BASE_HH
_base_hermes_home = Path(_BASE_HH).resolve()
except Exception:
_base_hermes_home = None
_hermes_roots = []
for _r in (
_HERMES_HOME.resolve(),
(_HOME / ".hermes").resolve(),
_base_hermes_home,
_state_dir,
):
if _r is not None and _r not in _hermes_roots:
_hermes_roots.append(_r)
# Enumerate named-profile roots (<root>/profiles/<name>) and treat each as a
# Hermes root in its own right, so a sibling/other profile's sensitive subdirs
# + secret files are denied — WITHOUT denying the whole `profiles` container
# (which would block a legit named-profile workspace at
# <root>/profiles/<name>/workspace/). (Codex review #3234.)
_profile_roots = []
for _root in list(_hermes_roots):
_profiles_dir = (_root / "profiles")
try:
if _profiles_dir.is_dir():
for _pchild in _profiles_dir.iterdir():
if _pchild.is_dir():
_pr = _pchild.resolve()
if _pr not in _hermes_roots and _pr not in _profile_roots:
_profile_roots.append(_pr)
except OSError:
pass
_hermes_roots.extend(_profile_roots)
# Case-insensitive path helpers so STATE.DB / Sessions/ casing variants
# cannot bypass the deny on macOS/Windows filesystems (Codex review #3234).
def _norm(p):
return os.path.normcase(str(Path(p).resolve())).casefold()
def _within_ci(child, root):
try:
c, r = _norm(child), _norm(root)
return os.path.commonpath([c, r]) == r
except (ValueError, OSError):
return False
def _equal_ci(a, b):
try:
return _norm(a) == _norm(b)
except (ValueError, OSError):
return False
# State-subdir deny set: each DENY_SUBDIR directly under any Hermes root
# (which includes STATE_DIR — so STATE_DIR/sessions, STATE_DIR/memories,
# etc. are covered). These ALWAYS apply — even to a file under the active
# workspace — so a workspace pointed at (or overlapping) a state dir cannot
# expose sessions/memories/profiles/etc. We do NOT deny STATE_DIR itself
# wholesale: the default workspace lives at STATE_DIR/workspace, and that is
# legitimate user media — direct sensitive files there are still caught by
# the filename denies below. (Codex review #3234.)
_deny_dirs = []
for _root in _hermes_roots:
for _sub in _DENY_SUBDIRS:
_deny_dirs.append((_root / _sub).resolve())
# Per-profile WebUI state lives at <root>/webui_state (api/workspace.py),
# so its state subdirs (<root>/webui_state/sessions, etc.) must be denied
# too — they are NOT direct children of <root>. (Codex review #3234.)
_ws_state = (_root / "webui_state")
for _sub in _DENY_SUBDIRS:
_deny_dirs.append((_ws_state / _sub).resolve())
_deny_names_ci = {n.casefold() for n in _DENY_FILENAMES}
# Active-workspace carve-out: a file inside a genuine PROJECT workspace is
# the user's own content, so the secret/config FILENAME denies are relaxed
# for it. The carve-out is DISABLED when the workspace is a broad/internal
# location ($HOME, a Hermes root itself, an ANCESTOR of a Hermes root, a
# */profiles dir, a named-profile root, or a state subdir) — honoring those
# would re-open the disclosure. A workspace that is a proper DESCENDANT of a
# Hermes root (e.g. STATE_DIR/workspace) is still a legit project workspace
# and keeps the carve-out. The dir-based denies above are NOT relaxed.
_active_workspace = None
try:
from api.workspace import get_last_workspace
_aw = Path(get_last_workspace()).resolve()
if _aw.is_dir():
_active_workspace = _aw
except Exception:
_active_workspace = None
def _workspace_is_safe_carveout(ws):
if ws is None:
return False
if _equal_ci(ws, _HOME):
return False
for _root in _hermes_roots:
# ws IS a root, or ws is an ANCESTOR of a root → unsafe. (A proper
# descendant of a root is fine — that's a normal project workspace.)
if _equal_ci(ws, _root) or _within_ci(_root, ws):
return False
if ws.name == "profiles" or ws.parent.name == "profiles":
return False
if ws.name in _DENY_SUBDIRS:
return False
return True
_in_active_workspace = (
_active_workspace is not None
and _workspace_is_safe_carveout(_active_workspace)
and _within_ci(target, _active_workspace)
)
# Dir-based denies always fire (even inside the active workspace).
if any(_within_ci(target, d) for d in _deny_dirs):
return bad(handler, "Path not in allowed location", 403)
# Filename-based denies fire for files under a Hermes root, UNLESS the file
# is inside a genuine project workspace (carve-out).
if not _in_active_workspace:
_under_hermes_root = any(_within_ci(target, _root) for _root in _hermes_roots)
_name_cf = target.name.casefold()
# Exact secret/state basenames, plus atomic-write temp files for those
# (api/auth.py and api/passkeys.py write via a `tmp*.<name>.tmp` / `tmp*.tmp`
# sidecar then rename) — deny those suffixes too so a momentary temp file
# cannot be fetched. (Codex review #3234.)
_deny_tmp_suffixes = (".sessions.tmp", ".login_attempts.tmp",
".passkeys.tmp", ".passkey_challenges.tmp")
if _under_hermes_root and (
_name_cf in _deny_names_ci
or _name_cf.endswith(_deny_tmp_suffixes)
):
return bad(handler, "Path not in allowed location", 403)
# ── end #3234 deny ───────────────────────────────────────────────────────
if not within_allowed and not session_media_allowed:
return bad(handler, "Path not in allowed location", 403)
+21 -1
View File
@@ -3110,6 +3110,17 @@ function renderMd(raw){
// backticks for multiline code and break subsequent code-box rendering.
const rawPreStash=[];
s=s.replace(/(<pre\b[^>]*>[\s\S]*?<\/pre>)/gi,m=>{rawPreStash.push(m);return `\x00R${rawPreStash.length-1}\x00`;});
// Bare file:// artifact links → media. Some gateway/tool surfaces emit bare
// file:// links for local artifacts instead of MEDIA: tokens; browser clients
// cannot open the server filesystem directly, so route them through /api/media.
// Runs AFTER fenced-block (\x00P), inline-code (\x00F), AND raw-<pre> (\x00R)
// stashing so a file:// inside any code/preformatted region stays literal text
// (#3219/#3234). Only bare URLs (line-start or whitespace-delimited) match, so
// normal [label](file://...) markdown anchors keep 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';
});
s=s.replace(/<strong>([\s\S]*?)<\/strong>/gi,(_,t)=>'**'+t+'**');
s=s.replace(/<b>([\s\S]*?)<\/b>/gi,(_,t)=>'**'+t+'**');
s=s.replace(/<em>([\s\S]*?)<\/em>/gi,(_,t)=>'*'+t+'*');
@@ -3449,7 +3460,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 +3479,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
@@ -68,10 +68,13 @@ def test_html_media_open_full_uses_inline_new_tab_not_download():
def test_media_html_inline_keeps_csp_sandbox():
"""api/media may serve HTML inline only behind a CSP sandbox."""
# Slice widened to 5000 (was 4000) after PR #2044 added MEDIA_ALLOWED_ROOTS
# parsing earlier in _handle_media, which pushed the CSP block past the
# original window. The assertion is structural, not positional.
body = _slice_after(ROUTES_PY, "def _handle_media", 5000)
# Slice widened to 16000 (was 5000) after the #3234 security work landed a
# multi-profile-aware deny-list + named-profile-root enumeration +
# active-workspace carve-out + safety gate earlier in _handle_media, pushing
# the CSP block to ~12100 chars past the def. (Originally widened 4000→5000
# for PR #2044's MEDIA_ALLOWED_ROOTS parsing.) The assertion is structural,
# not positional — generous headroom avoids re-breaking on small future edits.
body = _slice_after(ROUTES_PY, "def _handle_media", 16000)
assert 'html_inline_ok = inline_preview and mime == "text/html"' in body
assert 'csp = "sandbox allow-scripts" if html_inline_ok else None' in body
assert "csp=csp" in body
+277
View File
@@ -19,6 +19,7 @@ import unittest
from types import SimpleNamespace
from unittest import mock
import urllib.error
import urllib.parse
import urllib.request
from tests._pytest_port import BASE, TEST_STATE_DIR
@@ -41,6 +42,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'>")
@@ -281,6 +291,200 @@ class TestMediaEndpointUnit(unittest.TestCase):
child.write_bytes(b"png")
self.assertTrue(routes._path_is_within_root(child.resolve(), root))
def test_active_workspace_carveout_gated_against_hermes_roots(self):
"""#3234: the active-workspace carve-out must NOT re-open the disclosure
when the active workspace is pathologically set to a broad/internal root
($HOME, ~/.hermes, a profile root, etc.). A state.db sitting under such a
workspace must still be denied (403), not served.
"""
from api import routes
class _Handler:
def __init__(self):
self.status = None
self._buf = []
def send_response(self, code):
self.status = code
def send_header(self, *a, **k):
pass
def end_headers(self):
pass
class _W:
def write(self_inner, b):
pass
wfile = _W()
with tempfile.TemporaryDirectory() as home:
hermes_home = pathlib.Path(home) / ".hermes"
hermes_home.mkdir(parents=True)
secret = hermes_home / "state.db"
secret.write_bytes(b"secret-state")
target = secret.resolve()
handler = _Handler()
parsed = SimpleNamespace(
query=f"path={urllib.parse.quote(str(target))}", path="/api/media"
)
with mock.patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \
mock.patch.object(routes, "get_last_workspace", lambda: str(hermes_home)), \
mock.patch("api.auth.is_auth_enabled", lambda: False):
routes._handle_media(handler, parsed)
self.assertEqual(
handler.status, 403,
"state.db must stay denied even when the active workspace IS the "
"Hermes home (carve-out must be gated against internal roots)",
)
def test_active_workspace_under_state_dir_serves_but_sessions_denied(self):
"""#3234: a workspace at STATE_DIR/workspace is legitimate user media —
STATE_DIR/workspace/shot.png must serve (not 403), while a sibling
STATE_DIR/sessions/<sid>.json (internal state) must stay denied (403).
Regression for the over-block where STATE_DIR was denied wholesale.
"""
from api import routes
class _Handler:
def __init__(self):
self.status = None
self.headers = {}
def send_response(self, code):
self.status = code
def send_header(self, *a, **k):
pass
def end_headers(self):
pass
class _W:
def write(self_inner, b):
pass
def flush(self_inner):
pass
wfile = _W()
png_bytes = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00'
b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
)
with tempfile.TemporaryDirectory() as home:
hermes_home = pathlib.Path(home) / ".hermes"
state_dir = hermes_home / "webui-state"
ws = state_dir / "workspace"
sessions = state_dir / "sessions"
ws.mkdir(parents=True)
sessions.mkdir(parents=True)
shot = ws / "shot.png"
shot.write_bytes(png_bytes)
sess_file = sessions / "abc.json"
sess_file.write_text('{"messages":[]}', encoding="utf-8")
env = {
"HERMES_HOME": str(hermes_home),
"HERMES_WEBUI_STATE_DIR": str(state_dir),
}
with mock.patch.dict(os.environ, env), \
mock.patch.object(routes, "get_last_workspace", lambda: str(ws)), \
mock.patch("api.auth.is_auth_enabled", lambda: False), \
mock.patch("api.config.STATE_DIR", state_dir):
# workspace media → not blocked by the #3234 deny
h1 = _Handler()
routes._handle_media(h1, SimpleNamespace(
query=f"path={urllib.parse.quote(str(shot.resolve()))}&inline=1",
path="/api/media"))
self.assertNotEqual(
h1.status, 403,
"STATE_DIR/workspace/shot.png must NOT be blocked (legit media)")
# sessions state → still denied
h2 = _Handler()
routes._handle_media(h2, SimpleNamespace(
query=f"path={urllib.parse.quote(str(sess_file.resolve()))}",
path="/api/media"))
self.assertEqual(
h2.status, 403,
"STATE_DIR/sessions/abc.json must stay denied (internal state)")
def test_named_profile_workspace_serves_but_profile_secrets_denied(self):
"""#3234: a named-profile workspace (<base>/profiles/p1/workspace) is
legitimate media and must serve, while that profile's secrets
(<base>/profiles/p1/auth.json) and a SIBLING profile's secrets
(<base>/profiles/other/auth.json) must stay denied (403).
Regression for the over-block where the whole `profiles` tree was denied.
"""
from api import routes
class _Handler:
def __init__(self):
self.status = None
self.headers = {}
def send_response(self, code):
self.status = code
def send_header(self, *a, **k):
pass
def end_headers(self):
pass
class _W:
def write(self_inner, b):
pass
def flush(self_inner):
pass
wfile = _W()
png_bytes = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00'
b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
)
with tempfile.TemporaryDirectory() as home:
base = pathlib.Path(home) / ".hermes"
p1_ws = base / "profiles" / "p1" / "workspace"
p1_ws.mkdir(parents=True)
(p1_ws / "shot.png").write_bytes(png_bytes)
p1_secret = base / "profiles" / "p1" / "auth.json"
p1_secret.write_text("{}", encoding="utf-8")
other_secret = base / "profiles" / "other" / "auth.json"
other_secret.parent.mkdir(parents=True)
other_secret.write_text("{}", encoding="utf-8")
active = base / "profiles" / "p1" # active profile HERMES_HOME
with mock.patch.dict(os.environ, {"HERMES_HOME": str(active)}), \
mock.patch.object(routes, "get_last_workspace", lambda: str(p1_ws)), \
mock.patch("api.auth.is_auth_enabled", lambda: False), \
mock.patch("api.profiles._DEFAULT_HERMES_HOME", base):
# named-profile workspace media → served
h1 = _Handler()
routes._handle_media(h1, SimpleNamespace(
query=f"path={urllib.parse.quote(str((p1_ws / 'shot.png').resolve()))}&inline=1",
path="/api/media"))
self.assertNotEqual(
h1.status, 403,
"named-profile workspace media must NOT be blocked")
# this profile's own secret → denied
h2 = _Handler()
routes._handle_media(h2, SimpleNamespace(
query=f"path={urllib.parse.quote(str(p1_secret.resolve()))}",
path="/api/media"))
self.assertEqual(h2.status, 403, "profile auth.json must be denied")
# sibling profile's secret → denied
h3 = _Handler()
routes._handle_media(h3, SimpleNamespace(
query=f"path={urllib.parse.quote(str(other_secret.resolve()))}",
path="/api/media"))
self.assertEqual(h3.status, 403, "sibling profile auth.json must be denied")
# per-profile webui_state/sessions → denied (not a direct child of root)
ws_sess = active / "webui_state" / "sessions"
ws_sess.mkdir(parents=True, exist_ok=True)
ws_sess_file = ws_sess / "s1.json"
ws_sess_file.write_text('{"messages":[]}', encoding="utf-8")
h4 = _Handler()
routes._handle_media(h4, SimpleNamespace(
query=f"path={urllib.parse.quote(str(ws_sess_file.resolve()))}",
path="/api/media"))
self.assertEqual(
h4.status, 403,
"profile webui_state/sessions/*.json must be denied")
def test_media_endpoints_advertise_byte_range_support(self):
routes_src = (REPO_ROOT / "api" / "routes.py").read_text(encoding="utf-8")
self.assertIn("Accept-Ranges", routes_src)
@@ -456,6 +660,79 @@ class TestMediaEndpointIntegration(unittest.TestCase):
)
self.assertIn(status, {403, 404})
def test_webui_state_secret_files_denied(self):
"""#3234: /api/media must hard-deny WebUI state/secret files even though
they live under an allowed root (the whole Hermes home is allowed).
An authenticated session rendering attacker-influenced agent output that
emits a file:// or MEDIA: link to settings.json / state.db / auth.json
must NOT be able to fetch it through /api/media.
"""
state_dir = pathlib.Path(TEST_STATE_DIR)
state_dir.mkdir(parents=True, exist_ok=True)
# settings.json by name (deny-by-filename)
settings = state_dir / "settings.json"
settings.write_text('{"secret":"value"}', encoding="utf-8")
try:
_, status, _ = self._get(
"/api/media?path=" + urllib.request.quote(str(settings.resolve()))
)
self.assertEqual(
status, 403,
f"settings.json under the state dir must be denied, got {status}",
)
finally:
settings.unlink(missing_ok=True)
# a file inside the sessions/ state subdir (deny-by-dir)
sess_dir = state_dir / "sessions"
sess_dir.mkdir(parents=True, exist_ok=True)
sess_file = sess_dir / "abc123.json"
sess_file.write_text('{"messages":[]}', encoding="utf-8")
try:
_, status, _ = self._get(
"/api/media?path=" + urllib.request.quote(str(sess_file.resolve()))
)
self.assertEqual(
status, 403,
f"files under the sessions/ state subdir must be denied, got {status}",
)
finally:
sess_file.unlink(missing_ok=True)
def test_deny_list_does_not_overblock_legitimate_media(self):
"""#3234 follow-up: the state/secret deny-list must NOT block ordinary
media that merely shares a sensitive basename but lives OUTSIDE any
Hermes state root (e.g. a user artifact in /tmp named settings.json).
The deny is scoped to files under a Hermes root; a /tmp PNG named
settings.png or even settings.json is the user's own content and
must still be served (200), not 403.
"""
png_bytes = (
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01'
b'\x08\x06\x00\x00\x00\x1f\x15\xc4\x89\x00\x00\x00\nIDATx\x9cc\x00'
b'\x01\x00\x00\x05\x00\x01\r\n-\xb4\x00\x00\x00\x00IEND\xaeB`\x82'
)
# A /tmp artifact whose stem collides with a denied basename — must serve
# because /tmp is not a Hermes state root.
with tempfile.NamedTemporaryFile(
suffix=".png", prefix="settings_artifact_", dir="/tmp", delete=False
) as f:
f.write(png_bytes)
tmp_path = f.name
try:
body, status, headers = self._get(
f"/api/media?path={urllib.request.quote(tmp_path)}"
)
self.assertEqual(
status, 200,
f"a /tmp PNG outside any Hermes root must serve, got {status}",
)
self.assertIn("image/png", headers.get("Content-Type", ""))
finally:
pathlib.Path(tmp_path).unlink(missing_ok=True)
def test_health_check_still_works(self):
"""Sanity: server is up and /health works."""
body, status, _ = self._get("/health")
+30
View File
@@ -702,3 +702,33 @@ class TestHeadingLevelsH1ThroughH6:
assert "<h4><strong>bold</strong> in h4</h4>" in out, (
f"inline markdown inside h4 must still render: {out!r}"
)
class TestBareFileUrlMediaRendering:
"""#3219/#3234: bare file:// artifact links render as media, but file://
inside fenced/inline code stays literal (the new pass runs AFTER code-stash)."""
def test_bare_file_url_becomes_media(self, driver_path):
out = _render(driver_path, "Here is the screenshot file:///tmp/shot.png done")
# Routed through /api/media as an inline image, not left as a raw path.
assert "api/media?path=" in out
assert "msg-media-img" in out or "<img" in out
def test_file_url_inside_fenced_code_stays_literal(self, driver_path):
out = _render(driver_path, "```\nfile:///tmp/shot.png\n```")
# Inside a code fence it must remain literal text, NOT become an <img>.
assert "file:///tmp/shot.png" in out
assert "<img" not in out
assert "api/media?path=" not in out
def test_file_url_inside_inline_code_stays_literal(self, driver_path):
out = _render(driver_path, "run `open file:///tmp/shot.png` now")
assert "file:///tmp/shot.png" in out
assert "<img" not in out
assert "api/media?path=" not in out
def test_markdown_anchor_file_link_uses_link_path_not_media(self, driver_path):
out = _render(driver_path, "[the file](file:///tmp/shot.png)")
# Labeled anchors keep the normal link path (routed to /api/media as a link,
# not auto-loaded as an <img>).
assert "<img" not in out