feat(desktop+gateway): remote media relay — attach images/PDFs and display gateway images over the network
Desktop connected to a remote gateway can now attach images and PDFs and
display agent-written images. Previously the desktop passed a LOCAL file path
to image.attach; on a remote gateway that path doesn't exist, so the image was
silently dropped ("skipped unreadable path") and the vision model never saw it.
The reverse direction was also broken — images the agent wrote on the gateway
rendered as dead links in the remote client.
Gateway (tui_gateway/server.py):
- image.attach_bytes: base64 byte upload written into the gateway's own images
dir and queued via the existing native-image-attach pipeline. Magic-byte
extension sniffing, data-URL prefix + whitespace tolerance, 25 MB cap,
structured error codes. Accepts content_base64/filename (canonical) and
data/ext (older-desktop aliases).
- pdf.attach: renders each page to PNG via pdftoppm (poppler-utils) at 150 DPI
and queues the pages as images; 50 MB / 25-page caps. Accepts host path or
base64 upload.
- Shared helpers (_decode_attach_base64, _sniff_image_ext, _queue_attached_image)
so the two methods and the existing image.attach don't duplicate logic.
Gateway (hermes_cli/web_server.py):
- GET /api/media: returns a gateway-local image as a base64 data URL so remote
clients can display it. Auth-gated like every /api route, extension
allowlist + size cap, AND confined to the gateway's own media roots
(images/screenshots/cache, resolved symlink-safe) so an authed caller can't
read image-extension files anywhere on disk.
Desktop (apps/desktop):
- syncImageAttachmentsForSubmit uploads bytes via image.attach_bytes when the
connection mode is 'remote'; the local fast path is unchanged.
- media.ts gains isRemoteGateway() + gatewayMediaDataUrl(); directive-text and
markdown-text fetch images over /api/media in remote mode.
Consolidates the competing remote-media PRs (#38876, #40317, #21908, #39437)
into one coherent implementation, taking the strongest parts of each and adding
shared-helper cleanup plus the /api/media root-confinement hardening on top.
The per-profile gateway switching from #38876 is intentionally left out as a
separable feature. TUI file uploads (#40492) remain a separate surface.
Tested: 11 new tui_gateway tests + 5 /api/media endpoint tests + desktop
media.remote unit tests; full tui_gateway + web_server suites green (472
passed); tsc -b clean; E2E verified the full attach→disk→queue and
gateway-path→data-URL display round-trip plus the out-of-root security block.
Co-authored-by: Max Mitcham <maxmitcham@mac.home>
Co-authored-by: Justlrnal4 <Justlrnal4@users.noreply.github.com>
Co-authored-by: Chris Cook <ccook@nvms.com>
Co-authored-by: Thomas Paquette <thomas.paquette@gmail.com>
This commit is contained in:
committed by
Teknium
co-authored by
Max Mitcham
Justlrnal4
Chris Cook
Thomas Paquette
parent
20fd0bde5d
commit
16786f3bb3
@@ -243,6 +243,57 @@ class TestWebServerEndpoints:
|
||||
assert "hermes_home" in data
|
||||
assert "active_sessions" in data
|
||||
|
||||
# ── GET /api/media (remote image display) ───────────────────────────
|
||||
|
||||
def test_get_media_serves_image_in_root(self):
|
||||
"""An image under the gateway's images dir is returned as a data URL."""
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
img_dir = get_hermes_home() / "images"
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
img = img_dir / "shot.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
|
||||
|
||||
resp = self.client.get("/api/media", params={"path": str(img)})
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data_url"].startswith("data:image/png;base64,")
|
||||
|
||||
def test_get_media_rejects_path_outside_roots(self, tmp_path):
|
||||
"""An image-extension file outside the media roots is forbidden."""
|
||||
outside = tmp_path / "secret.png"
|
||||
outside.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
resp = self.client.get("/api/media", params={"path": str(outside)})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_get_media_rejects_non_image_extension(self):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
img_dir = get_hermes_home() / "images"
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
env = img_dir / "leak.env"
|
||||
env.write_text("SECRET=1")
|
||||
|
||||
resp = self.client.get("/api/media", params={"path": str(env)})
|
||||
assert resp.status_code == 415
|
||||
|
||||
def test_get_media_404_for_missing_file(self):
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
missing = get_hermes_home() / "images" / "nope.png"
|
||||
resp = self.client.get("/api/media", params={"path": str(missing)})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_get_media_requires_auth(self):
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME
|
||||
|
||||
resp = self.client.get(
|
||||
"/api/media",
|
||||
params={"path": "/tmp/x.png"},
|
||||
headers={_SESSION_HEADER_NAME: "wrong-token"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
# ── Dashboard font override ─────────────────────────────────────────
|
||||
|
||||
def test_get_dashboard_font_defaults_to_theme(self):
|
||||
|
||||
@@ -5774,3 +5774,215 @@ def test_notification_event_dedup_key_keeps_completions_one_shot():
|
||||
assert server._notification_event_dedup_key(first) == server._notification_event_dedup_key(
|
||||
replay
|
||||
)
|
||||
|
||||
|
||||
# --- image.attach_bytes / pdf.attach (remote-client byte upload) -------------
|
||||
|
||||
# Smallest valid 1x1 PNG, base64-encoded.
|
||||
_PNG_1X1_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk"
|
||||
"+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
def _attach_bytes_cli(monkeypatch):
|
||||
fake_cli = types.ModuleType("cli")
|
||||
fake_cli._IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
|
||||
monkeypatch.setitem(sys.modules, "cli", fake_cli)
|
||||
|
||||
|
||||
def test_image_attach_bytes_writes_to_gateway_dir(monkeypatch, tmp_path):
|
||||
"""Remote client uploads base64 bytes; gateway writes them to its own disk."""
|
||||
_attach_bytes_cli(monkeypatch)
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
server._sessions["abx"] = _session()
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "image.attach_bytes",
|
||||
"params": {
|
||||
"session_id": "abx",
|
||||
"content_base64": _PNG_1X1_B64,
|
||||
"filename": "shot.png",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
res = resp["result"]
|
||||
assert res["attached"] is True
|
||||
written = Path(res["path"])
|
||||
assert written.is_file()
|
||||
assert written.parent == tmp_path / "images"
|
||||
assert written.read_bytes().startswith(b"\x89PNG")
|
||||
assert len(server._sessions["abx"]["attached_images"]) == 1
|
||||
assert res["bytes"] > 0
|
||||
|
||||
|
||||
def test_image_attach_bytes_accepts_data_url_prefix(monkeypatch, tmp_path):
|
||||
_attach_bytes_cli(monkeypatch)
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
server._sessions["abx2"] = _session()
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "image.attach_bytes",
|
||||
"params": {
|
||||
"session_id": "abx2",
|
||||
"content_base64": f"data:image/png;base64,{_PNG_1X1_B64}",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert resp["result"]["attached"] is True
|
||||
|
||||
|
||||
def test_image_attach_bytes_data_alias_and_magic_sniff(monkeypatch, tmp_path):
|
||||
"""Older desktop builds send `data` (not content_base64); ext sniffed from bytes."""
|
||||
_attach_bytes_cli(monkeypatch)
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
server._sessions["abx3"] = _session()
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "image.attach_bytes",
|
||||
"params": {"session_id": "abx3", "data": _PNG_1X1_B64},
|
||||
}
|
||||
)
|
||||
res = resp["result"]
|
||||
assert res["attached"] is True
|
||||
assert Path(res["path"]).suffix == ".png" # sniffed from magic bytes
|
||||
|
||||
|
||||
def test_image_attach_bytes_rejects_invalid_base64(monkeypatch, tmp_path):
|
||||
_attach_bytes_cli(monkeypatch)
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
server._sessions["abx4"] = _session()
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "image.attach_bytes",
|
||||
"params": {"session_id": "abx4", "content_base64": "!!!not base64!!!"},
|
||||
}
|
||||
)
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 4017
|
||||
|
||||
|
||||
def test_image_attach_bytes_rejects_oversize(monkeypatch, tmp_path):
|
||||
import base64 as _b64
|
||||
|
||||
_attach_bytes_cli(monkeypatch)
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr(server, "_ATTACH_BYTES_MAX_BYTES", 10)
|
||||
server._sessions["abx5"] = _session()
|
||||
|
||||
big = _b64.b64encode(b"\x89PNG\r\n\x1a\n" + b"0" * 100).decode("ascii")
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "image.attach_bytes",
|
||||
"params": {"session_id": "abx5", "content_base64": big},
|
||||
}
|
||||
)
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 4018
|
||||
|
||||
|
||||
def test_image_attach_bytes_rejects_unsupported_extension(monkeypatch, tmp_path):
|
||||
_attach_bytes_cli(monkeypatch)
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
server._sessions["abx6"] = _session()
|
||||
|
||||
# filename hint forces a non-image extension; magic sniff is bypassed by hint
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "image.attach_bytes",
|
||||
"params": {
|
||||
"session_id": "abx6",
|
||||
"content_base64": _PNG_1X1_B64,
|
||||
"filename": "evil.exe",
|
||||
},
|
||||
}
|
||||
)
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 4016
|
||||
|
||||
|
||||
def test_pdf_attach_requires_poppler(monkeypatch, tmp_path):
|
||||
"""Without pdftoppm on PATH, pdf.attach returns a clear 5028."""
|
||||
_attach_bytes_cli(monkeypatch)
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr("shutil.which", lambda _name: None)
|
||||
server._sessions["pdf1"] = _session()
|
||||
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "pdf.attach",
|
||||
"params": {"session_id": "pdf1", "content_base64": "JVBERi0xLjQK"},
|
||||
}
|
||||
)
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 5028
|
||||
|
||||
|
||||
def test_pdf_attach_rejects_non_pdf_bytes(monkeypatch, tmp_path):
|
||||
import base64 as _b64
|
||||
|
||||
_attach_bytes_cli(monkeypatch)
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/pdftoppm")
|
||||
server._sessions["pdf2"] = _session()
|
||||
|
||||
not_pdf = _b64.b64encode(b"this is not a pdf").decode("ascii")
|
||||
resp = server.handle_request(
|
||||
{
|
||||
"id": "1",
|
||||
"method": "pdf.attach",
|
||||
"params": {"session_id": "pdf2", "content_base64": not_pdf},
|
||||
}
|
||||
)
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 4017
|
||||
|
||||
|
||||
def test_pdf_attach_requires_path_or_bytes(monkeypatch, tmp_path):
|
||||
_attach_bytes_cli(monkeypatch)
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
monkeypatch.setattr("shutil.which", lambda _name: "/usr/bin/pdftoppm")
|
||||
server._sessions["pdf3"] = _session()
|
||||
|
||||
resp = server.handle_request(
|
||||
{"id": "1", "method": "pdf.attach", "params": {"session_id": "pdf3"}}
|
||||
)
|
||||
assert "error" in resp
|
||||
assert resp["error"]["code"] == 4015
|
||||
|
||||
|
||||
def test_decode_attach_base64_helper():
|
||||
import base64 as _b64
|
||||
|
||||
raw = _b64.b64encode(b"hello").decode("ascii")
|
||||
assert server._decode_attach_base64(raw, mime_prefix="image/") == b"hello"
|
||||
assert (
|
||||
server._decode_attach_base64(f"data:image/png;base64,{raw}", mime_prefix="image/")
|
||||
== b"hello"
|
||||
)
|
||||
# whitespace inside payload is tolerated
|
||||
assert server._decode_attach_base64(raw[:4] + "\n" + raw[4:], mime_prefix="image/") == b"hello"
|
||||
assert server._decode_attach_base64("@@@", mime_prefix="image/") is None
|
||||
|
||||
|
||||
def test_sniff_image_ext_magic_and_filename():
|
||||
assert server._sniff_image_ext(b"\x89PNG\r\n\x1a\n") == ".png"
|
||||
assert server._sniff_image_ext(b"\xff\xd8\xff\xe0") == ".jpg"
|
||||
assert server._sniff_image_ext(b"GIF89a....") == ".gif"
|
||||
assert server._sniff_image_ext(b"RIFF1234WEBPxxxx") == ".webp"
|
||||
assert server._sniff_image_ext(b"BM......") == ".bmp"
|
||||
assert server._sniff_image_ext(b"unknown") == ".png" # fallback
|
||||
# filename hint wins over magic bytes
|
||||
assert server._sniff_image_ext(b"\x89PNG", "photo.jpeg") == ".jpeg"
|
||||
|
||||
Reference in New Issue
Block a user