Desktop/dashboard file attachment handling for remote backends

NousResearch/hermes-agent · cluster 38078 · agent

Data as of

4 open items. Fix merged in #41336. Check it covers your case.

The short answer

1open issue
3open PRs
18items grouped
Known?
Yes. 18 related items grouped here, 8 issues already closed.
Fix event
A fix was merged to main in PR #41336. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #46658. That is separate from the fix event.
Still open
1 issue and 3 PRs.

These items concern bugs, fixes, and features around attaching images and files in the desktop app and dashboard, particularly the problem of local file paths being sent instead of actual file data when a remote gateway or backend is involved. Work spans drag-and-drop, paste, multimodal routing, and remote upload relay.

Open issues1

  • Issue#50913[Feature]: File attachments in Hermes Dashboard

Open PRs3

  • PR#38244fix(desktop): upload pasted images to remote gateway
  • PR#41564fix(image-routing): stop exposing full file paths in image attachment hints
  • PR#62869feat: accept non-image file drops and pastes in dashboard chat

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #41336 feat(desktop+gateway): remote media relay — attach images/PDFs & display gateway images over the network

    merged to main

    files: apps/desktop/src/app/session/hooks/use-prompt-actions.ts, apps/desktop/src/app/types.ts, apps/desktop/src/components/assistant-ui/directive-text.tsx, apps/desktop/src/components/assistant-ui/markdown-text.tsx, apps/desktop/src/lib/media.remote.test.ts, apps/desktop/src/lib/media.ts, hermes_cli/web_server.py, scripts/release.py, tests/hermes_cli/test_web_server.py, tests/test_tui_gateway_server.py, tui_gateway/server.py

    Show the diff
    • apps/desktop/src/app/session/hooks/use-prompt-actions.ts
      @@ -34,6 +34,7 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
       import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
       import {
         $busy,
      +  $connection,
         $messages,
         $yoloActive,
         setAwaitingResponse,
      @@ -80,6 +81,28 @@ function inlineErrorMessage(error: unknown, fallback: string): string {
         return (raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw).replace(/^Error:\s*/, '').trim()
       }
       
      +function base64FromDataUrl(dataUrl: string): string {
      +  const comma = dataUrl.indexOf(',')
      +
      +  return comma >= 0 ? dataUrl.slice(comma + 1) : ''
      +}
      +
      +function imageFilenameFromPath(filePath: string): string {
      +  return filePath.split(/[\\/]/).filter(Boolean).pop() || 'image.png'
      +}
      +
      +// Remote gateway: the local composer-image file lives on THIS machine's disk,
      +// not the gateway's, so read the bytes here and upload them via
      +// image.attach_bytes. Returns null when the file can't be read.
      +async function readImageForRemoteAttach(
      +  filePath: string
      +): Promise<{ contentBase64: string; filename: string } | null> {
      +  const dataUrl = await window.hermesDesktop?.readFileDataUrl(filePath)
      +  const contentBase64 = dataUrl ? base64FromDataUrl(dataUrl) : ''
      +
      +  return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
      +}
      +
       interface PromptActionsOptions {
         activeSessionId: string | null
         activeSessionIdRef: MutableRefObject<string | null>
      @@ -197,16 +220,36 @@ export function usePromptActions({
           ) => {
             const updateComposerAttachments = options.updateComposerAttachments ?? true
             const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
      +      const remote = $connection.get()?.mode === 'remote'
       
             for (const attachment of images) {
               if (attachment.attachedSessionId === sessionId) {
                 continue
               }
       
      -        const result = await requestGateway<ImageAttachResponse>('image.attach', {
      -          session_id: sessionId,
      -          path: attachment.path
      -        })
      +        let result: ImageAttachResponse
      +
      +        if (remote) {
      +          // The gateway is on another machine — it can't read attachment.path
      +          // (a path on THIS disk). Upload the bytes via image.attach_bytes.
      +          const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
      +
      +          if (!payload) {
      +            const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
      +            throw new Error(`Could not read ${label}`)
      +          }
      +
      +          result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
      +            session_id: sessionId,
      +            content_base64: payload.contentBase64,
      +            filename: payload.filename
      +          })
      +        } else {
      +          result = await requestGateway<ImageAttachResponse>('image.attach', {
      +            session_id: sessionId,
      +            path: attachment.path
      +          })
      +        }
       
               if (!result.attached) {
                 const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
    • apps/desktop/src/app/types.ts
      @@ -13,6 +13,13 @@ export interface ImageAttachResponse {
         path?: string
         text?: string
         message?: string
      +  // Returned by the byte-upload variant (image.attach_bytes) used in remote mode.
      +  count?: number
      +  bytes?: number
      +  name?: string
      +  width?: number
      +  height?: number
      +  token_estimate?: number
       }
       
       export interface ImageDetachResponse {
    • apps/desktop/src/components/assistant-ui/directive-text.tsx
      @@ -7,6 +7,7 @@ import { Fragment, useEffect, useMemo, useState } from 'react'
       
       import { ZoomableImage } from '@/components/chat/zoomable-image'
       import { extractEmbeddedImages } from '@/lib/embedded-images'
      +import { gatewayMediaDataUrl, isRemoteGateway } from '@/lib/media'
       
       const HERMES_REF_TYPES = ['file', 'folder', 'url', 'image', 'tool', 'line', 'terminal', 'session'] as const
       type HermesRefType = (typeof HERMES_REF_TYPES)[number]
      @@ -327,25 +328,32 @@ export const DirectiveText: TextMessagePartComponent = ({ text }: TextMessagePar
        * messages render after the backend embeds the data URL, so the UX is stable
        * across initial send and refresh. */
       const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
      -  const remote = /^(?:https?|data):/i.test(id)
      -  const [src, setSrc] = useState<string | null>(remote ? id : null)
      +  const isUrl = /^(?:https?|data):/i.test(id)
      +  const [src, setSrc] = useState<string | null>(isUrl ? id : null)
         const [failed, setFailed] = useState(false)
       
         useEffect(() => {
      -    if (remote || !id) {
      +    if (isUrl || !id) {
             return
           }
       
           let alive = true
      -    void window.hermesDesktop
      -      ?.readFileDataUrl(id)
      -      .then(url => alive && setSrc(url))
      +
      +    // Remote gateway: the image lives on the gateway's disk, not ours — fetch
      +    // it over the authenticated API. Local: read it straight off this disk.
      +    const load =
      +      window.hermesDesktop && isRemoteGateway()
      +        ? gatewayMediaDataUrl(id)
      +        : window.hermesDesktop?.readFileDataUrl(id)
      +
      +    void Promise.resolve(load)
      +      .then(url => alive && url && setSrc(url))
             .catch(() => alive && setFailed(true))
       
           return () => {
             alive = false
           }
      -  }, [id, remote])
      +  }, [id, isUrl])
       
         if (failed) {
           return <DirectiveChip id={id} label={label} type="image" />
    • apps/desktop/src/components/assistant-ui/markdown-text.tsx
      @@ -17,6 +17,8 @@ import { createMemoizedMathPlugin } from '@/lib/katex-memo'
       import { preprocessMarkdown } from '@/lib/markdown-preprocess'
       import {
         filePathFromMediaPath,
      +  gatewayMediaDataUrl,
      +  isRemoteGateway,
         mediaExternalUrl,
         mediaKind,
         mediaName,
      @@ -51,6 +53,12 @@ async function mediaSrc(path: string): Promise<string> {
           return mediaStreamUrl(path)
         }
       
      +  // Remote gateway: the image lives on the gateway machine, so read it over the
      +  // authenticated API rather than this machine's disk.
      +  if (window.hermesDesktop && isRemoteGateway()) {
      +    return gatewayMediaDataUrl(path)
      +  }
      +
         if (!window.hermesDesktop?.readFileDataUrl) {
           return mediaExternalUrl(path)
         }
    • apps/desktop/src/lib/media.remote.test.ts
      @@ -0,0 +1,58 @@
      +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
      +
      +import { $connection } from '@/store/session'
      +
      +import { filePathFromMediaPath, gatewayMediaDataUrl, isRemoteGateway } from './media'
      +
      +describe('isRemoteGateway', () => {
      +  afterEach(() => {
      +    $connection.set(null)
      +  })
      +
      +  it('is false with no connection', () => {
      +    $connection.set(null)
      +    expect(isRemoteGateway()).toBe(false)
      +  })
      +
      +  it('is false in local mode', () => {
      +    $connection.set({ mode: 'local' } as never)
      +    expect(isRemoteGateway()).toBe(false)
      +  })
      +
      +  it('is true in remote mode', () => {
      +    $connection.set({ mode: 'remote' } as never)
      +    expect(isRemoteGateway()).toBe(true)
      +  })
      +})
      +
      +describe('filePathFromMediaPath', () => {
      +  it('passes through a plain path', () => {
      +    expect(filePathFromMediaPath('/home/u/.hermes/images/a.png')).toBe('/home/u/.hermes/images/a.png')
      +  })
      +
      +  it('decodes a file:// URL with encoded characters', () => {
      +    expect(filePathFromMediaPath('file:///tmp/a%20b.png')).toBe('/tmp/a b.png')
      +  })
      +})
      +
      +describe('gatewayMediaDataUrl', () => {
      +  const api = vi.fn(async () => ({ data_url: 'data:image/png;base64,ZHVtbXk=' }))
      +
      +  beforeEach(() => {
      +    api.mockClear()
      +    vi.stubGlobal('window', { hermesDesktop: { api } })
      +  })
      +
      +  afterEach(() => {
      +    vi.unstubAllGlobals()
      +  })
      +
      +  it('requests the encoded gateway path and returns the data URL', async () => {
      +    const url = await gatewayMediaDataUrl('/home/u/.hermes/images/a b.png')
      +
      +    expect(url).toBe('data:image/png;base64,ZHVtbXk=')
      +    expect(api).toHaveBeenCalledWith({
      +      path: '/api/media?path=%2Fhome%2Fu%2F.hermes%2Fimages%2Fa%20b.png'
      +    })
      +  })
      +})
    • apps/desktop/src/lib/media.ts
      @@ -1,3 +1,5 @@
      +import { $connection } from '@/store/session'
      +
       export type MediaKind = 'audio' | 'image' | 'video' | 'file'
       
       interface MediaInfo {
      @@ -89,6 +91,26 @@ export function filePathFromMediaPath(path: string): string {
         }
       }
       
      +// True when this desktop shell is wired to a remote gateway. Local media paths
      +// then live on the gateway machine, not this disk, so we fetch them over the API.
      +export function isRemoteGateway(): boolean {
      +  return $connection.get()?.mode === 'remote'
      +}
      +
      +// Fetch a gateway-local image as a data URL via the authenticated REST bridge.
      +// Used in remote mode where readFileDataUrl (which reads THIS machine's disk)
      +// can't see files the agent wrote on the gateway. Requires the gateway to
      +// expose GET /api/media (hermes_cli/web_server.py).
      +export async function gatewayMediaDataUrl(path: string): Promise<string> {
      +  const file = filePathFromMediaPath(path)
      +
      +  const result = await window.hermesDesktop!.api<{ data_url: string }>({
      +    path: `/api/media?path=${encodeURIComponent(file)}`
      +  })
      +
      +  return result.data_url
      +}
      +
       export function mediaDisplayLabel(path: string): string {
         const escaped = mediaName(path).replace(/[[\]\\]/g, '\\$&')
         const kind = mediaKind(path)
    • hermes_cli/web_server.py
      @@ -792,6 +792,74 @@ def _probe_gateway_health() -> tuple[bool, dict | None]:
           return False, None
       
       
      +# Image MIME types this endpoint will serve. Extension-allowlisted so an
      +# authenticated caller can't pull non-image files through it.
      +_MEDIA_CONTENT_TYPES = {
      +    ".png": "image/png",
      +    ".jpg": "image/jpeg",
      +    ".jpeg": "image/jpeg",
      +    ".gif": "image/gif",
      +    ".webp": "image/webp",
      +    ".svg": "image/svg+xml",
      +    ".bmp": "image/bmp",
      +    ".ico": "image/x-icon",
      +}
      +_MEDIA_MAX_BYTES = 25 * 1024 * 1024
      +
      +
      +def _media_serve_roots() -> list[Path]:
      +    """Directories ``GET /api/media`` is allowed to read from.
      +
      +    Confined to where the agent and attach pipeline actually write media on the
      +    gateway host — its images dir and cache subtree. This stops an authenticated
      +    client from reading image-extension files anywhere on disk (e.g. a renamed
      +    key or a screenshot outside the cache) merely because the suffix passes the
      +    allowlist.
      +    """
      +    home = get_hermes_home()
      +    roots = [home / "images", home / "screenshots", home / "cache"]
      +    out: list[Path] = []
      +    for root in roots:
      +        try:
      +            out.append(root.resolve())
      +        except (OSError, RuntimeError):
      +            continue
      +    return out
      +
      +
      [redacted:email]("/api/media")
      +async def get_media(path: str):
      +    """Return a gateway-local image file as a base64 data URL.
      +
      +    Lets remote clients (the desktop app over the network, or the web dashboard
      +    in a browser) display images the agent wrote to *this* machine's filesystem
      +    — they can't read the gateway's local disk directly.
      +
      +    Auth-gated by the session token like every other /api route. Restricted to
      +    an image-extension allowlist, a size cap, AND the gateway's own media roots
      +    (resolved, symlink-safe) so it can't be used to read arbitrary files.
      +    """
      +    try:
      +        target = Path(path).expanduser().resolve()
      +    except (OSError, RuntimeError):
      +        raise HTTPException(status_code=400, detail="Invalid path")
      +
      +    if target.suffix.lower() not in _MEDIA_CONTENT_TYPES:
      +        raise HTTPException(status_code=415, detail="Unsupported media type")
      +
      +    roots = _media_serve_roots()
      +    if not any(target == root or root in target.parents for root in roots):
      +        raise HTTPException(status_code=403, detail="Path outside media roots")
      +
      +    if not target.is_file():
      +        raise HTTPException(status_code=404, detail="File not found")
      +    if target.stat().st_size > _MEDIA_MAX_BYTES:
      +        raise HTTPException(status_code=413, detail="File too large")
      +
      +    encoded = base64.b64encode(target.read_bytes()).decode("ascii")
      +    return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"}
      +
      +
       @app.get("/api/status")
       async def get_status():
           current_ver, latest_ver = check_config_version()
    • scripts/release.py
      @@ -47,6 +47,9 @@
       AUTHOR_MAP = {
           "[redacted:email]": "Dusk1e",
           "[redacted:email]": "LaPhilosophie",
      +    "[redacted:email]": "maxtrigify",
      +    "[redacted:email]": "ccook1963",
      +    "[redacted:email]": "RyTsYdUp",
           "[redacted:email]": "bmoore210",
           "[redacted:email]": "manishbyatroy",
           "[redacted:email]": "TheGardenGallery",
    • tests/hermes_cli/test_web_server.py
      @@ -243,6 +243,57 @@ def test_get_status(self):
               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):
    • tests/test_tui_gateway_server.py
      @@ -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"
    • tui_gateway/server.py
      @@ -5097,6 +5097,274 @@ def _(rid, params: dict) -> dict:
               return _err(rid, 5027, str(e))
       
       
      +# Byte-upload attach caps. 25 MB matches Anthropic's per-image limit; 50 MB / 25
      +# pages bounds a single PDF drop so it can't blow the context budget.
      +_ATTACH_BYTES_MAX_BYTES = 25 * 1024 * 1024
      +_PDF_ATTACH_MAX_BYTES = 50 * 1024 * 1024
      +_PDF_ATTACH_MAX_PAGES = 25
      +
      +# Leading magic bytes → file extension, for filename-less uploads.
      +_IMAGE_MAGIC: tuple[tuple[bytes, str], ...] = (
      +    (b"\x89PNG\r\n\x1a\n", ".png"),
      +    (b"\xff\xd8\xff", ".jpg"),
      +    (b"GIF87a", ".gif"),
      +    (b"GIF89a", ".gif"),
      +    (b"BM", ".bmp"),
      +)
      +
      +
      +def _decode_attach_base64(raw: str, *, mime_prefix: str) -> bytes | None:
      +    """Decode a base64 (optionally data-URL-wrapped) payload.
      +
      +    Accepts ``data:<mime_prefix>...;base64,<b64>`` plus embedded whitespace.
      +    Returns the decoded bytes, or ``None`` when the input isn't valid base64.
      +    """
      +    import base64 as _base64
      +    import re as _re
      +
      +    cleaned = raw.strip()
      +    m = _re.match(
      +        rf"^data:{_re.escape(mime_prefix)}[a-zA-Z0-9.+-]*;base64,(.*)$",
      +        cleaned,
      +        _re.DOTALL,
      +    )
      +    if m:
      +        cleaned = m.group(1)
      +    cleaned = _re.sub(r"\s+", "", cleaned)
      +    try:
      +        return _base64.b64decode(cleaned, validate=True)
      +    except Exception:
      +        return None
      +
      +
      +def _sniff_image_ext(img_bytes: bytes, filename: str = "") -> str:
      +    """Resolve an image extension from a filename hint, else magic bytes.
      +
      +    Falls back to ``.png``. WebP needs the RIFF/WEBP container check, handled
      +    before the generic table.
      +    """
      +    if filename:
      +        suffix = Path(filename).suffix.lower()
      +        if suffix:
      +            return suffix
      +    head = img_bytes[:16]
      +    if head.startswith(b"RIFF") and head[8:12] == b"WEBP":
      +        return ".webp"
      +    for sig, ext in _IMAGE_MAGIC:
      +        if head.startswith(sig):
      +            return ext
      +    return ".png"
      +
      +
      +def _allowed_image_extensions() -> frozenset[str]:
      +    try:
      +        from cli import _IMAGE_EXTENSIONS
      +
      +        return frozenset(_IMAGE_EXTENSIONS)
      +    except Exception:
      +        return frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"})
      +
      +
      +def _queue_attached_image(session: dict, img_bytes: bytes, ext: str, *, prefix: str) -> Path:
      +    """Write image bytes into the gateway's images dir and queue them.
      +
      +    Mirrors what ``image.attach`` does for a local path: appends to
      +    ``session["attached_images"]`` so the next ``prompt.submit`` picks it up via
      +    the existing native-image-attach pipeline. Returns the written path.
      +    """
      +    session["image_counter"] = session.get("image_counter", 0) + 1
      +    img_dir = _hermes_home / "images"
      +    img_dir.mkdir(parents=True, exist_ok=True)
      +    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
      +    img_path = img_dir / f"{prefix}_{ts}_{session['image_counter']}{ext}"
      +    try:
      +        img_path.write_bytes(img_bytes)
      +    except Exception:
      +        session["image_counter"] = max(0, session["image_counter"] - 1)
      +        raise
      +    session.setdefault("attached_images", []).append(str(img_path))
      +    return img_path
      +
      +
      +@method("image.attach_bytes")
      +def _(rid, params: dict) -> dict:
      +    """Attach an image to the session from base64 bytes (remote-client path).
      +
      +    A desktop app or web dashboard running on a DIFFERENT machine than the
      +    gateway can't hand us a local path — that file only exists on the client's
      +    disk. So it uploads the raw image bytes (base64) and we write them into the
      +    gateway's own images dir. The response shape mirrors ``image.attach`` so the
      +    client treats both identically.
      +
      +    Params:
      +      content_base64 / data (str, required): base64 image bytes. Accepts a
      +        ``data:image/...;base64,`` prefix and embedded whitespace. ``data`` is
      +        an accepted alias for older desktop builds.
      +      filename / ext (str, optional): extension hint. Without it, magic bytes
      +        identify PNG/JPEG/GIF/WebP/BMP, falling back to ``.png``.
      +    """
      +    session, err = _sess(params, rid)
      +    if err:
      +        return err
      +
      +    raw_b64 = str(params.get("content_base64") or params.get("data") or "").strip()
      +    if not raw_b64:
      +        return _err(rid, 4015, "content_base64 required")
      +
      +    img_bytes = _decode_attach_base64(raw_b64, mime_prefix="image/")
      +    if img_bytes is None:
      +        return _err(rid, 4017, "data is not valid base64")
      +    if not img_bytes:
      +        return _err(rid, 4017, "image is empty")
      +    if len(img_bytes) > _ATTACH_BYTES_MAX_BYTES:
      +        mb = _ATTACH_BYTES_MAX_BYTES // (1024 * 1024)
      +        return _err(rid, 4018, f"image too large ({len(img_bytes)} bytes; cap is {mb} MB)")
      +
      +    filename = str(params.get("filename", "") or "")
      +    ext_hint = str(params.get("ext", "") or "").strip().lower()
      +    if ext_hint and not ext_hint.startswith("."):
      +        ext_hint = "." + ext_hint
      +    ext = _sniff_image_ext(img_bytes, filename or (f"x{ext_hint}" if ext_hint else ""))
      +    if ext not in _allowed_image_extensions():
      +        return _err(rid, 4016, f"unsupported image extension: {ext}")
      +
      +    try:
      +        img_path = _queue_attached_image(session, img_bytes, ext, prefix="upload")
      +    except Exception as e:
      +        return _err(rid, 5027, f"write failed: {e}")
      +
      +    return _ok(
      +        rid,
      +        {
      +            "attached": True,
      +            "path": str(img_path),
      +            "count": len(session["attached_images"]),
      +            "remainder": "",
      +            "text": f"[User attached image: {img_path.name}]",
      +            "bytes": len(img_bytes),
      +            **_image_meta(img_path),
      +        },
      +    )
      +
      +
      +@method("pdf.attach")
      +def _(rid, params: dict) -> dict:
      +    """Attach a PDF by rendering each page to PNG and queuing the pages.
      +
      +    Anthropic's vision pipeline accepts images, not PDFs, so this runs
      +    ``pdftoppm`` (poppler-utils) at 150 DPI per page and queues each rendered
      +    page as an attached image. Accepts either a host ``path`` (local mode) or
      +    base64 ``content_base64`` (remote upload). Caps at 50 MB / 25 pages per call.
      +
      +    Requires ``pdftoppm`` on $PATH (``apt install poppler-utils``); returns 5028
      +    if missing.
      +    """
      +    import shutil
      +    import subprocess
      +    import tempfile
      +
      +    session, err = _sess(params, rid)
      +    if err:
      +        return err
      +
      +    if shutil.which("pdftoppm") is None:
      +        return _err(rid, 5028, "pdftoppm not installed (poppler-utils package required)")
      +
      +    raw_path = str(params.get("path", "") or "").strip()
      +    raw_b64 = str(params.get("content_base64") or params.get("data") or "").strip()
      +    if not raw_path and not raw_b64:
      +        return _err(rid, 4015, "path or content_base64 required")
      +
      +    with tempfile.TemporaryDirectory(prefix="pdf_attach_") as td:
      +        td_path = Path(td)
      +        if raw_b64:
      +            pdf_bytes = _decode_attach_base64(raw_b64, mime_prefix="application/pdf")
      +            if pdf_bytes is None:
      +                return _err(rid, 4017, "data is not valid base64")
      +            if not pdf_bytes:
      +                return _err(rid, 4017, "decoded PDF is empty")
      +            if len(pdf_bytes) > _PDF_ATTACH_MAX_BYTES:
      +                mb = _PDF_ATTACH_MAX_BYTES // (1024 * 1024)
      +                return _err(rid, 4018, f"PDF too large ({len(pdf_bytes)} bytes; cap is {mb} MB)")
      +            if pdf_bytes[:5] != b"%PDF-":
      +                return _err(rid, 4017, "payload is not a PDF (missing %PDF- magic bytes)")
      +            pdf_path = td_path / "input.pdf"
      +            pdf_path.write_bytes(pdf_bytes)
      +            display_name = str(params.get("filename", "") or "uploaded.pdf")
      +        else:
      +            try:
      +                from cli import _resolve_attachment_path
      +
      +                resolved = _resolve_attachment_path(raw_path)
      +            except Exception:
      +                resolved = None
      +            if resolved is None or not Path(resolved).is_file():
      +                return _err(rid, 4016, f"PDF not found: {raw_path}")
      +            if Path(resolved).suffix.lower() != ".pdf":
      +                return _err(rid, 4016, f"not a PDF: {Path(resolved).name}")
      +            if Path(resolved).stat().st_size > _PDF_ATTACH_MAX_BYTES:
      +                mb = _PDF_ATTACH_MAX_BYTES // (1024 * 1024)
      +                return _err(rid, 4018, f"PDF too large; cap is {mb} MB")
      +            pdf_path = Path(resolved)
      +            display_name = pdf_path.name
      +
      +        try:
      +            first_page = int(params.get("first_page") or 1)
      +            last_page_param = params.get("last_page")
      +            last_page = int(last_page_param) if last_page_param is not None else None
      +        except (TypeError, ValueError):
      +            return _err(rid, 4015, "first_page/last_page must be integers")
      +
      +        if first_page < 1:
      +            return _err(rid, 4015, "first_page must be >= 1")
      +        if last_page is None:
      +            last_page = first_page + _PDF_ATTACH_MAX_PAGES - 1
      +        if last_page < first_page:
      +            return _err(rid, 4015, "last_page must be >= first_page")
      +        if last_page - first_page + 1 > _PDF_ATTACH_MAX_PAGES:
      +            return _err(rid, 4019, f"page range exceeds cap of {_PDF_ATTACH_MAX_PAGES} pages per attach call")
      +
      +        out_prefix = td_path / "page"
      +        argv = [
      +            "pdftoppm", "-png", "-r", "150",
      +            "-f", str(first_page), "-l", str(last_page),
      +            str(pdf_path), str(out_prefix),
      +        ]
      +        try:
      +            res = subprocess.run(argv, capture_output=True, text=True, timeout=120)
      +        except subprocess.TimeoutExpired:
      +            return _err(rid, 5028, "pdftoppm timed out (>120s)")
      +        if res.returncode != 0:
      +            tail = (res.stderr or res.stdout or "").strip().splitlines()[-3:]
      +            return _err(rid, 5028, "pdftoppm failed: " + " | ".join(tail))
      +
      +        rendered = sorted(td_path.glob("page-*.png"))
      +        if not rendered:
      +            return _err(rid, 5028, "pdftoppm produced no pages (corrupt PDF?)")
      +
      +        attached_pages = []
      +        for src in rendered:
      +            page_num = src.stem.split("-", 1)[-1]
      +            try:
      +                page_int = int(page_num)
      +            except ValueError:
      +                page_int = first_page + len(attached_pages)
      +            dst = _queue_attached_image(session, src.read_bytes(), ".png", prefix=f"pdf_p{page_num}")
      +            attached_pages.append({"path": str(dst), "page": page_int, **_image_meta(dst)})
      +
      +        return _ok(
      +            rid,
      +            {
      +                "attached": True,
      +                "filename": display_name,
      +                "pages_attached": len(attached_pages),
      +                "pages": attached_pages,
      +                "count": len(session["attached_images"]),
      +                "text": f"[User attached PDF: {display_name} ({len(attached_pages)} page(s))]",
      +            },
      +        )
      +
      +
       @method("image.detach")
       def _(rid, params: dict) -> dict:
           session, err = _sess(params, rid)
  • #42634 feat(desktop+gateway): remote-gateway file attachments via file.attach

    merged to main

    files: apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx, apps/desktop/src/app/session/hooks/use-prompt-actions.ts, apps/desktop/src/app/types.ts, apps/desktop/src/store/updates.ts, tests/test_tui_gateway_server.py, tui_gateway/server.py

    Show the diff
    • apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
      @@ -4,6 +4,8 @@ import { useEffect } from 'react'
       import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
       
       import { $sessions, setSessions } from '@/store/session'
      +import { $connection } from '@/store/session'
      +import type { ComposerAttachment } from '@/store/composer'
       import type { SessionInfo } from '@/types/hermes'
       
       import { usePromptActions } from './use-prompt-actions'
      @@ -42,7 +44,10 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
       
       interface HarnessHandle {
         steerPrompt: (text: string) => Promise<boolean>
      -  submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
      +  submitText: (
      +    text: string,
      +    options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
      +  ) => Promise<boolean>
       }
       
       function Harness({
      @@ -314,3 +319,92 @@ describe('usePromptActions steerPrompt', () => {
           expect(requestGateway).not.toHaveBeenCalled()
         })
       })
      +
      +describe('usePromptActions file attachment sync', () => {
      +  afterEach(() => {
      +    cleanup()
      +    $connection.set(null)
      +    vi.restoreAllMocks()
      +  })
      +
      +  function fileAttachment(): ComposerAttachment {
      +    return {
      +      id: 'file:report.txt',
      +      kind: 'file',
      +      label: 'report.txt',
      +      path: '/Users/alice/Downloads/report.txt',
      +      refText: '@file:`/Users/alice/Downloads/report.txt`'
      +    }
      +  }
      +
      +  it('uploads file bytes via file.attach on a remote gateway and submits the rewritten ref', async () => {
      +    // Remote gateway can't read the client-disk path, so the desktop must upload
      +    // the bytes and submit the workspace-relative ref the gateway hands back —
      +    // not the original /Users/... path (which would dead-end as "outside the
      +    // allowed workspace").
      +    $connection.set({ mode: 'remote' } as never)
      +    Object.defineProperty(window, 'hermesDesktop', {
      +      configurable: true,
      +      value: { readFileDataUrl: vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') }
      +    })
      +
      +    const calls: { method: string; params?: Record<string, unknown> }[] = []
      +    const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
      +      calls.push({ method, params })
      +      if (method === 'file.attach') {
      +        return {
      +          attached: true,
      +          path: '/remote/work/.hermes/desktop-attachments/report.txt',
      +          ref_text: '@file:.hermes/desktop-attachments/report.txt',
      +          uploaded: true
      +        } as never
      +      }
      +      return {} as never
      +    })
      +
      +    let handle: HarnessHandle | null = null
      +    render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
      +
      +    const ok = await handle!.submitText('convert this to epub', { attachments: [fileAttachment()] })
      +
      +    expect(ok).toBe(true)
      +    expect(calls.map(c => c.method)).toEqual(['file.attach', 'prompt.submit'])
      +    expect(calls[0]?.params).toMatchObject({
      +      session_id: RUNTIME_SESSION_ID,
      +      path: '/Users/alice/Downloads/report.txt',
      +      name: 'report.txt',
      +      data_url: 'data:text/plain;base64,aGVsbG8='
      +    })
      +    expect(calls[1]?.params).toEqual({
      +      session_id: RUNTIME_SESSION_ID,
      +      text: '@file:.hermes/desktop-attachments/report.txt\n\nconvert this to epub'
      +    })
      +  })
      +
      +  it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
      +    $connection.set({ mode: 'local' } as never)
      +
      +    const calls: { method: string; params?: Record<string, unknown> }[] = []
      +    const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
      +      calls.push({ method, params })
      +      if (method === 'file.attach') {
      +        return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never
      +      }
      +      return {} as never
      +    })
      +
      +    let handle: HarnessHandle | null = null
      +    render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
      +
      +    const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
      +
      +    expect(ok).toBe(true)
      +    expect(calls[0]?.method).toBe('file.attach')
      +    // Local mode sends no data_url — the gateway shares this disk.
      +    expect(calls[0]?.params).not.toHaveProperty('data_url')
      +    expect(calls[1]).toEqual({
      +      method: 'prompt.submit',
      +      params: { session_id: RUNTIME_SESSION_ID, text: '@file:data/report.txt\n\nsummarize' }
      +    })
      +  })
      +})
    • apps/desktop/src/app/session/hooks/use-prompt-actions.ts
      @@ -47,6 +47,7 @@ import {
       
       import type {
         ClientSessionState,
      +  FileAttachResponse,
         ImageAttachResponse,
         SessionSteerResponse,
         SessionTitleResponse,
      @@ -103,6 +104,20 @@ async function readImageForRemoteAttach(
         return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
       }
       
      +// Read a non-image file as a data URL for upload via file.attach. Returns null
      +// when the desktop bridge can't read the file (e.g. it was moved/deleted).
      +async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
      +  const reader = window.hermesDesktop?.readFileDataUrl
      +
      +  if (!reader) {
      +    return null
      +  }
      +
      +  const dataUrl = await reader(filePath)
      +
      +  return dataUrl || null
      +}
      +
       interface PromptActionsOptions {
         activeSessionId: string | null
         activeSessionIdRef: MutableRefObject<string | null>
      @@ -212,62 +227,114 @@ export function usePromptActions({
           [selectedStoredSessionIdRef, updateSessionState]
         )
       
      -  const syncImageAttachmentsForSubmit = useCallback(
      +  const syncAttachmentsForSubmit = useCallback(
           async (
             sessionId: string,
             attachments: ComposerAttachment[],
             options: { updateComposerAttachments?: boolean } = {}
      -    ) => {
      +    ): Promise<ComposerAttachment[]> => {
             const updateComposerAttachments = options.updateComposerAttachments ?? true
      -      const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
             const remote = $connection.get()?.mode === 'remote'
      +      const synced: ComposerAttachment[] = []
       
      -      for (const attachment of images) {
      -        if (attachment.attachedSessionId === sessionId) {
      +      for (const attachment of attachments) {
      +        // Already-synced or pathless refs (terminal, url, etc.) pass through.
      +        if (!attachment.path || attachment.attachedSessionId === sessionId) {
      +          synced.push(attachment)
                 continue
               }
       
      -        let result: ImageAttachResponse
      +        if (attachment.kind === 'image') {
      +          let result: ImageAttachResponse
      +
      +          if (remote) {
      +            // The gateway is on another machine — it can't read attachment.path
      +            // (a path on THIS disk). Upload the bytes via image.attach_bytes.
      +            const payload = await readImageForRemoteAttach(attachment.path)
      +
      +            if (!payload) {
      +              const label = attachment.label || pathLabel(attachment.path)
      +              throw new Error(`Could not read ${label}`)
      +            }
      +
      +            result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
      +              session_id: sessionId,
      +              content_base64: payload.contentBase64,
      +              filename: payload.filename
      +            })
      +          } else {
      +            result = await requestGateway<ImageAttachResponse>('image.attach', {
      +              session_id: sessionId,
      +              path: attachment.path
      +            })
      +          }
      +
      +          if (!result.attached) {
      +            const label = attachment.label || pathLabel(attachment.path)
      +            throw new Error(result.message || `Could not attach ${label}`)
      +          }
      +
      +          const attachedPath = result.path || attachment.path
      +          const nextAttachment: ComposerAttachment = {
      +            ...attachment,
      +            id: attachment.id,
      +            label: attachedPath ? pathLabel(attachedPath) : attachment.label,
      +            path: attachedPath,
      +            attachedSessionId: sessionId
      +          }
      +
      +          if (updateComposerAttachments) {
      +            addComposerAttachment(nextAttachment)
      +          }
      +
      +          synced.push(nextAttachment)
      +          continue
      +        }
       
      -        if (remote) {
      -          // The gateway is on another machine — it can't read attachment.path
      -          // (a path on THIS disk). Upload the bytes via image.attach_bytes.
      -          const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
      +        if (attachment.kind === 'file') {
      +          // Non-image file refs are @file: paths the gateway reads with its file
      +          // tools. On a remote gateway the desktop path doesn't exist there, so
      +          // upload the bytes; the gateway stages them into the session workspace
      +          // and hands back a workspace-relative ref that actually resolves.
      +          // Local mode can pass the path directly (gateway shares this disk).
      +          const dataUrl = remote ? await readFileDataUrlForAttach(attachment.path) : null
       
      -          if (!payload) {
      -            const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
      +          if (remote && !dataUrl) {
      +            const label = attachment.label || pathLabel(attachment.path)
                   throw new Error(`Could not read ${label}`)
                 }
       
      -          result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
      +          const result = await requestGateway<FileAttachResponse>('file.attach', {
                   session_id: sessionId,
      -            content_base64: payload.contentBase64,
      -            filename: payload.filename
      +            path: attachment.path,
      +            name: attachment.label || pathLabel(attachment.path),
      +            ...(dataUrl ? { data_url: dataUrl } : {})
                 })
      -        } else {
      -          result = await requestGateway<ImageAttachResponse>('image.attach', {
      -            session_id: sessionId,
      -            path: attachment.path
      -          })
      -        }
      -
      -        if (!result.attached) {
      -          const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
      -          throw new Error(result.message || `Could not attach ${label}`)
      -        }
       
      -        const attachedPath = result.path || attachment.path
      +          if (!result.attached || !result.ref_text) {
      +            const label = attachment.label || pathLabel(attachment.path)
      +            throw new Error(result.message || `Could not attach ${label}`)
      +          }
       
      -        if (updateComposerAttachments) {
      -          addComposerAttachment({
      +          const nextAttachment: ComposerAttachment = {
                   ...attachment,
                   id: attachment.id,
      -            label: attachedPath ? pathLabel(attachedPath) : attachment.label,
      -            path: attachedPath,
      +            refText: result.ref_text,
                   attachedSessionId: sessionId
      -          })
      +          }
      +
      +          if (updateComposerAttachments) {
      +            addComposerAttachment(nextAttachment)
      +          }
      +
      +          synced.push(nextAttachment)
      +          continue
               }
      +
      +        synced.push(attachment)
             }
      +
      +      return synced
           },
           [requestGateway]
         )
      @@ -278,35 +345,42 @@ export function usePromptActions({
             const usingComposerAttachments = !options?.attachments
             const attachments = options?.attachments ?? $composerAttachments.get()
       
      -      const contextRefs = attachments
      -        .map(a => a.refText)
      -        .filter(Boolean)
      -        .join('\n')
      -
             const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
             const hasImage = attachments.some(a => a.kind === 'image')
      -      const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
       
      -      const text =
      -        [contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
      -        (hasImage ? 'What do you see in this image?' : '')
      +      // Refs are recomputed after sync (file.attach rewrites @file: refs to
      +      // workspace-relative paths the remote gateway can resolve). Seed the
      +      // optimistic message with the pre-sync refs, then rewrite once synced.
      +      let attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
      +      const buildContextText = (atts: ComposerAttachment[]): string => {
      +        const contextRefs = atts
      +          .map(a => a.refText)
      +          .filter(Boolean)
      +          .join('\n')
      +
      +        return (
      +          [contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
      +          (atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
      +        )
      +      }
       
             // Queue drains fire on the busy→false settle edge, where busyRef (synced
             // from $busy by a separate effect) may still read true — honoring it would
             // bounce the drained send. The drain lock serializes them; the user path
             // keeps the guard so a stray Enter mid-turn can't double-submit.
      -      if (!text || (!options?.fromQueue && busyRef.current)) {
      +      const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
      +      if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
               return false
             }
       
             const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
       
      -      const userMessage: ChatMessage = {
      +      const buildUserMessage = (): ChatMessage => ({
               id: optimisticId,
               role: 'user',
               parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
               attachmentRefs
      -      }
      +      })
       
             const releaseBusy = () => {
               setMutableRef(busyRef, false)
      @@ -323,7 +397,7 @@ export function usePromptActions({
                   ...state,
                   messages: state.messages.some(m => m.id === optimisticId)
                     ? state.messages
      -              : [...state.messages, userMessage],
      +              : [...state.messages, buildUserMessage()],
                   busy: true,
                   awaitingResponse: true,
                   pendingBranchGroup: null,
      @@ -336,6 +410,18 @@ export function usePromptActions({
                 selectedStoredSessionIdRef.current
               )
       
      +      // After sync rewrites refs, refresh the optimistic message in place so the
      +      // transcript shows the resolved @file: ref rather than the local path.
      +      const rewriteOptimistic = (sid: string) =>
      +        updateSessionState(
      +          sid,
      +          state => ({
      +            ...state,
      +            messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))
      +          }),
      +          selectedStoredSessionIdRef.current
      +        )
      +
             const dropOptimistic = (sid: null | string) => {
               if (!sid) {
                 setMessages(current => current.filter(m => m.id !== optimisticId))
      @@ -366,7 +452,7 @@ export function usePromptActions({
             if (sessionId) {
               seedOptimistic(sessionId)
             } else {
      -        setMessages(current => [...current, userMessage])
      +        setMessages(current => [...current, buildUserMessage()])
             }
       
             if (!sessionId) {
      @@ -392,9 +478,14 @@ export function usePromptActions({
             }
       
             try {
      -        await syncImageAttachmentsForSubmit(sessionId, attachments, {
      +        const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
                 updateComposerAttachments: usingComposerAttachments
               })
      +        // Rewrite the optimistic message + prompt text with the synced refs so
      +        // the gateway receives @file: paths that resolve in its workspace.
      +        attachmentRefs = syncedAttachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
      +        rewriteOptimistic(sessionId)
      +        const text = buildContextText(syncedAttachments)
               await requestGateway('prompt.submit', { session_id: sessionId, text })
       
               if (usingComposerAttachments) {
      @@ -442,7 +533,7 @@ export function usePromptActions({
             createBackendSessionForSend,
             requestGateway,
             selectedStoredSessionIdRef,
      -      syncImageAttachmentsForSubmit,
      +      syncAttachmentsForSubmit,
             updateSessionState
           ]
         )
    • apps/desktop/src/app/types.ts
      @@ -27,6 +27,20 @@ export interface ImageDetachResponse {
         count?: number
       }
       
      +export interface FileAttachResponse {
      +  attached?: boolean
      +  message?: string
      +  // Gateway-side absolute path the file was staged to.
      +  path?: string
      +  // Workspace-relative path used to build ref_text.
      +  ref_path?: string
      +  // Rewritten @file: ref that resolves on the gateway (workspace-relative).
      +  ref_text?: string
      +  // True when bytes/host file were copied into the session workspace.
      +  uploaded?: boolean
      +  name?: string
      +}
      +
       export interface SlashExecResponse {
         output?: string
         warning?: string
    • apps/desktop/src/store/updates.ts
      @@ -88,7 +88,8 @@ function isUpdateToastSnoozed(): boolean {
       // Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
       // against. The backend reports its own value in session runtime info; a lower
       // value (or none — a pre-GUI checkout) means GUI<->backend skew.
      -const REQUIRED_BACKEND_CONTRACT = 1
      +// v2: requires the file.attach RPC (remote-gateway non-image file upload).
      +const REQUIRED_BACKEND_CONTRACT = 2
       const SKEW_TOAST_ID = 'backend-contract-skew'
       
       /**
    • tests/test_tui_gateway_server.py
      @@ -2914,6 +2914,164 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch):
           assert len(server._sessions["sid"]["attached_images"]) == 1
       
       
      +def test_file_attach_uploads_remote_file_into_session_workspace(monkeypatch, tmp_path):
      +    """Remote case: client path doesn't exist on gateway → decode data_url bytes."""
      +    workspace = tmp_path / "workspace"
      +    workspace.mkdir()
      +    fake_cli = types.ModuleType("cli")
      +    fake_cli._detect_file_drop = lambda raw: None
      +    fake_cli._split_path_input = lambda raw: (raw, "")
      +    fake_cli._resolve_attachment_path = lambda raw: None
      +
      +    server._sessions["sid"] = _session(cwd=str(workspace))
      +    monkeypatch.setitem(sys.modules, "cli", fake_cli)
      +
      +    try:
      +        resp = server.handle_request(
      +            {
      +                "id": "1",
      +                "method": "file.attach",
      +                "params": {
      +                    "session_id": "sid",
      +                    "path": "/Users/alice/Downloads/report.txt",
      +                    "name": "report.txt",
      +                    "data_url": "data:text/plain;base64,aGVsbG8gd29ybGQ=",
      +                },
      +            }
      +        )
      +
      +        stored = workspace / ".hermes" / "desktop-attachments" / "report.txt"
      +        assert resp["result"]["attached"] is True
      +        assert resp["result"]["uploaded"] is True
      +        assert resp["result"]["path"] == str(stored)
      +        assert resp["result"]["ref_text"] == "@file:.hermes/desktop-attachments/report.txt"
      +        assert stored.read_text(encoding="utf-8") == "hello world"
      +    finally:
      +        server._sessions.pop("sid", None)
      +
      +
      +def test_file_attach_copies_gateway_visible_file_outside_workspace(monkeypatch, tmp_path):
      +    """Local case: gateway can see the file but it's outside the workspace → copy in."""
      +    workspace = tmp_path / "workspace"
      +    workspace.mkdir()
      +    source = tmp_path / "outside.txt"
      +    source.write_text("outside workspace", encoding="utf-8")
      +    fake_cli = types.ModuleType("cli")
      +    fake_cli._detect_file_drop = lambda raw: None
      +    fake_cli._split_path_input = lambda raw: (raw, "")
      +    fake_cli._resolve_attachment_path = lambda raw: source
      +
      +    server._sessions["sid"] = _session(cwd=str(workspace))
      +    monkeypatch.setitem(sys.modules, "cli", fake_cli)
      +
      +    try:
      +        resp = server.handle_request(
      +            {
      +                "id": "1",
      +                "method": "file.attach",
      +                "params": {"session_id": "sid", "path": str(source)},
      +            }
      +        )
      +
      +        stored = workspace / ".hermes" / "desktop-attachments" / "outside.txt"
      +        assert resp["result"]["attached"] is True
      +        assert resp["result"]["uploaded"] is True
      +        assert resp["result"]["ref_text"] == "@file:.hermes/desktop-attachments/outside.txt"
      +        assert stored.read_text(encoding="utf-8") == "outside workspace"
      +    finally:
      +        server._sessions.pop("sid", None)
      +
      +
      +def test_file_attach_uses_in_workspace_file_without_copying(monkeypatch, tmp_path):
      +    """Local case: file already inside the workspace → ref it directly, no copy."""
      +    workspace = tmp_path / "workspace"
      +    (workspace / "data").mkdir(parents=True)
      +    source = workspace / "data" / "exam.csv"
      +    source.write_text("a,b,c\n1,2,3\n", encoding="utf-8")
      +    fake_cli = types.ModuleType("cli")
      +    fake_cli._detect_file_drop = lambda raw: None
      +    fake_cli._split_path_input = lambda raw: (raw, "")
      +    fake_cli._resolve_attachment_path = lambda raw: source
      +
      +    server._sessions["sid"] = _session(cwd=str(workspace))
      +    monkeypatch.setitem(sys.modules, "cli", fake_cli)
      +
      +    try:
      +        resp = server.handle_request(
      +            {
      +                "id": "1",
      +                "method": "file.attach",
      +                "params": {"session_id": "sid", "path": str(source)},
      +            }
      +        )
      +
      +        assert resp["result"]["attached"] is True
      +        assert resp["result"]["uploaded"] is False
      +        assert resp["result"]["ref_text"] == "@file:data/exam.csv"
      +        # No copy: nothing staged under desktop-attachments.
      +        assert not (workspace / ".hermes" / "desktop-attachments").exists()
      +    finally:
      +        server._sessions.pop("sid", None)
      +
      +
      +def test_file_attach_errors_when_unresolvable_and_no_bytes(monkeypatch, tmp_path):
      +    """Remote path not on gateway and no data_url → actionable error, not a stage."""
      +    workspace = tmp_path / "workspace"
      +    workspace.mkdir()
      +    fake_cli = types.ModuleType("cli")
      +    fake_cli._detect_file_drop = lambda raw: None
      +    fake_cli._split_path_input = lambda raw: (raw, "")
      +    fake_cli._resolve_attachment_path = lambda raw: None
      +
      +    server._sessions["sid"] = _session(cwd=str(workspace))
      +    monkeypatch.setitem(sys.modules, "cli", fake_cli)
      +
      +    try:
      +        resp = server.handle_request(
      +            {
      +                "id": "1",
      +                "method": "file.attach",
      +                "params": {"session_id": "sid", "path": "/Users/alice/missing.txt"},
      +            }
      +        )
      +
      +        assert "error" in resp
      +        assert "no data_url" in resp["error"]["message"]
      +    finally:
      +        server._sessions.pop("sid", None)
      +
      +
      +def test_file_attach_quotes_ref_with_spaces(monkeypatch, tmp_path):
      +    """Staged names with spaces must be backtick-quoted so the @file: ref parses."""
      +    workspace = tmp_path / "workspace"
      +    workspace.mkdir()
      +    fake_cli = types.ModuleType("cli")
      +    fake_cli._detect_file_drop = lambda raw: None
      +    fake_cli._split_path_input = lambda raw: (raw, "")
      +    fake_cli._resolve_attachment_path = lambda raw: None
      +
      +    server._sessions["sid"] = _session(cwd=str(workspace))
      +    monkeypatch.setitem(sys.modules, "cli", fake_cli)
      +
      +    try:
      +        resp = server.handle_request(
      +            {
      +                "id": "1",
      +                "method": "file.attach",
      +                "params": {
      +                    "session_id": "sid",
      +                    "name": "my exam schedule.csv",
      +                    "data_url": "data:text/csv;base64,YSxiCg==",
      +                },
      +            }
      +        )
      +
      +        assert resp["result"]["attached"] is True
      +        assert resp["result"]["ref_text"] == "@file:`.hermes/desktop-attachments/my exam schedule.csv`"
      +    finally:
      +        server._sessions.pop("sid", None)
      +
      +
       def test_commands_catalog_surfaces_quick_commands(monkeypatch):
           monkeypatch.setattr(
               server,
    • tui_gateway/server.py
      @@ -1992,7 +1992,8 @@ def _current_profile_name() -> str:
       # backend reporting less than its required value (or none at all — a pre-GUI
       # checkout), surfacing a one-click "update to align" prompt instead of failing
       # cryptically downstream. Bump whenever the desktop's backend contract changes.
      -DESKTOP_BACKEND_CONTRACT = 1
      +# v2: adds the file.attach RPC (remote-gateway non-image file upload).
      +DESKTOP_BACKEND_CONTRACT = 2
       
       
       def _session_info(agent, session: dict | None = None) -> dict:
      @@ -5631,6 +5632,197 @@ def _(rid, params: dict) -> dict:
               )
       
       
      +_ATTACHMENT_REF_NEEDS_QUOTING_RE = None
      +
      +
      +def _format_ref_value(value: str) -> str:
      +    """Quote a context-ref value when it contains whitespace or bracket chars.
      +
      +    Mirrors the desktop ``formatRefValue`` so the staged ``@file:`` ref round-trips
      +    through ``agent.context_references`` cleanly.
      +    """
      +    import re as _re
      +
      +    global _ATTACHMENT_REF_NEEDS_QUOTING_RE
      +    if _ATTACHMENT_REF_NEEDS_QUOTING_RE is None:
      +        _ATTACHMENT_REF_NEEDS_QUOTING_RE = _re.compile(r"""[\s()\[\]{}<>"'`]""")
      +    if not value or not _ATTACHMENT_REF_NEEDS_QUOTING_RE.search(value):
      +        return value
      +    if "`" not in value:
      +        return f"`{value}`"
      +    if '"' not in value:
      +        return f'"{value}"'
      +    if "'" not in value:
      +        return f"'{value}'"
      +    return value
      +
      +
      +def _attachment_ref_path(session: dict, target: Path) -> str:
      +    """Workspace-relative path for an attachment, or the absolute path if outside."""
      +    workspace = Path(_session_cwd(session)).resolve()
      +    try:
      +        rel = target.resolve().relative_to(workspace)
      +        return str(rel).replace(os.sep, "/")
      +    except ValueError:
      +        return str(target.resolve())
      +
      +
      +def _desktop_attachment_dir(session: dict) -> Path:
      +    root = Path(_session_cwd(session)).resolve() / ".hermes" / "desktop-attachments"
      +    root.mkdir(parents=True, exist_ok=True)
      +    return root
      +
      +
      +def _sanitize_attachment_name(name: str) -> str:
      +    import re as _re
      +
      +    candidate = Path(str(name or "").strip()).name
      +    candidate = _re.sub(r"[\x00-\x1f]+", "_", candidate)
      +    candidate = candidate.strip().strip(".")
      +    return candidate or "attachment"
      +
      +
      +def _unique_attachment_path(root: Path, filename: str) -> Path:
      +    candidate = root / filename
      +    if not candidate.exists():
      +        return candidate
      +    stem = Path(filename).stem or "attachment"
      +    suffix = Path(filename).suffix
      +    counter = 2
      +    while True:
      +        next_candidate = root / f"{stem}-{counter}{suffix}"
      +        if not next_candidate.exists():
      +            return next_candidate
      +        counter += 1
      +
      +
      +def _resolve_gateway_attachment_path(raw: str) -> Path | None:
      +    """Resolve a raw path token to a gateway-visible file, or None."""
      +    if not raw:
      +        return None
      +    try:
      +        from cli import _detect_file_drop, _resolve_attachment_path, _split_path_input
      +    except Exception:
      +        return None
      +
      +    dropped = _detect_file_drop(raw)
      +    if dropped:
      +        return Path(dropped["path"]).resolve()
      +    path_token, _remainder = _split_path_input(raw)
      +    resolved = _resolve_attachment_path(path_token)
      +    return Path(resolved).resolve() if resolved is not None else None
      +
      +
      +def _decode_attachment_data_url(data_url: str) -> bytes:
      +    """Decode a ``data:<any-mime>;base64,<b64>`` payload to bytes.
      +
      +    Unlike ``_decode_attach_base64`` (image-mime-specific), this accepts any
      +    media type — text/csv, application/pdf, etc. — so non-image file uploads
      +    round-trip. Also tolerates a bare base64 string with no data-URL prefix.
      +    """
      +    import base64 as _base64
      +    import binascii as _binascii
      +    import re as _re
      +
      +    cleaned = (data_url or "").strip()
      +    m = _re.match(r"^data:[^;,]*(?:;[^;,=]+=[^;,]+)*;base64,(.*)$", cleaned, _re.DOTALL | _re.I)
      +    if m:
      +        cleaned = m.group(1)
      +    cleaned = _re.sub(r"\s+", "", cleaned)
      +    try:
      +        return _base64.b64decode(cleaned, validate=True)
      +    except (ValueError, _binascii.Error) as exc:
      +        raise ValueError("invalid data_url payload") from exc
      +
      +
      +def _stage_session_file_attachment(
      +    session: dict,
      +    *,
      +    raw_path: str,
      +    data_url: str,
      +    name: str,
      +) -> tuple[Path, bool]:
      +    """Make a desktop file attachment available to the remote gateway agent.
      +
      +    Three cases:
      +      1. The path resolves to a file already INSIDE the session workspace — use
      +         it as-is (no copy, ``uploaded=False``).
      +      2. The path resolves to a gateway-visible file OUTSIDE the workspace — copy
      +         it into ``.hermes/desktop-attachments/`` so the ``@file:`` ref resolves.
      +      3. The path doesn't exist on the gateway (the common remote case: it's a
      +         path on the CLIENT's disk) — decode the uploaded ``data_url`` bytes and
      +         write them into ``.hermes/desktop-attachments/``.
      +
      +    Returns ``(stored_path, uploaded)``.
      +    """
      +    workspace = Path(_session_cwd(session)).resolve()
      +    resolved = _resolve_gateway_attachment_path(raw_path)
      +    if resolved is not None:
      +        try:
      +            resolved.relative_to(workspace)
      +            return resolved, False
      +        except ValueError:
      +            payload = resolved.read_bytes()
      +            filename = resolved.name
      +    else:
      +        if not data_url:
      +            raise ValueError("file not found on gateway and no data_url provided")
      +        payload = _decode_attachment_data_url(data_url)
      +        filename = _sanitize_attachment_name(name or Path(str(raw_path or "")).name)
      +
      +    upload_dir = _desktop_attachment_dir(session)
      +    target = _unique_attachment_path(upload_dir, _sanitize_attachment_name(filename))
      +    target.write_bytes(payload)
      +    return target.resolve(), True
      +
      +
      +@method("file.attach")
      +def _(rid, params: dict) -> dict:
      +    """Stage a non-image file attachment into the session workspace.
      +
      +    The image/PDF path renders to vision tiles; this one keeps the file as a
      +    readable artifact and returns a workspace-relative ``@file:`` ref so the
      +    agent's file tools (and ``agent.context_references``) can read it. Solves the
      +    remote-gateway case where the desktop passes a path that only exists on the
      +    CLIENT's disk: the client uploads ``data_url`` bytes and we materialize the
      +    file on the gateway.
      +
      +    Params:
      +      session_id (str, required)
      +      path (str): client/host path of the file (used for naming + local-mode
      +        gateway-visible resolution).
      +      data_url (str): ``data:<mime>;base64,<b64>`` upload of the file bytes,
      +        required when the path isn't visible to the gateway.
      +      name (str, optional): preferred filename.
      +    """
      +    session, err = _sess(params, rid)
      +    if err:
      +        return err
      +    raw = str(params.get("path", "") or "").strip()
      +    data_url = str(params.get("data_url", "") or "").strip()
      +    name = str(params.get("name", "") or "").strip()
      +    if not raw and not data_url:
      +        return _err(rid, 4015, "path or data_url required")
      +    try:
      +        stored_path, uploaded = _stage_session_file_attachment(
      +            session, raw_path=raw, data_url=data_url, name=name
      +        )
      +        ref_path = _attachment_ref_path(session, stored_path)
      +        return _ok(
      +            rid,
      +            {
      +                "attached": True,
      +                "name": stored_path.name,
      +                "path": str(stored_path),
      +                "ref_path": ref_path,
      +                "ref_text": f"@file:{_format_ref_value(ref_path)}",
      +                "uploaded": uploaded,
      +            },
      +        )
      +    except Exception as e:
      +        return _err(rid, 5028, str(e))
      +
      +
       @method("image.detach")
       def _(rid, params: dict) -> dict:
           session, err = _sess(params, rid)
  • #46658 fix(desktop): sync $connection on profile switch so remote profiles upload image bytes

    merged to main

    files: apps/desktop/src/store/profile.test.ts, apps/desktop/src/store/profile.ts

    Show the diff
    • apps/desktop/src/store/profile.test.ts
      @@ -0,0 +1,89 @@
      +import { atom } from 'nanostores'
      +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
      +
      +import type { HermesConnection } from '@/global'
      +
      +// Keep profile.ts's side-effecting imports inert: the gateway socket layer and
      +// the REST query client must not run for real in a unit test.
      +const ensureGatewayForProfile = vi.fn(async () => undefined)
      +const $gateway = atom<unknown>({ id: 'live-socket' })
      +
      +vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForProfile }))
      +vi.mock('@/hermes', () => ({
      +  getProfiles: vi.fn(async () => ({ profiles: [] })),
      +  setApiRequestProfile: vi.fn()
      +}))
      +vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
      +
      +const { $activeGatewayProfile, ensureGatewayProfile } = await import('./profile')
      +const { $connection } = await import('./session')
      +
      +const remoteConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
      +  ({ baseUrl: 'https://hermes-roy.tail.ts.net', mode: 'remote', profile: 'vps-remote', ...over }) as HermesConnection
      +
      +const localConn = (over: Partial<HermesConnection> = {}): HermesConnection =>
      +  ({ baseUrl: '', mode: 'local', profile: 'default', ...over }) as HermesConnection
      +
      +const getConnection = vi.fn<(profile?: string | null) => Promise<HermesConnection>>()
      +
      +beforeEach(() => {
      +  getConnection.mockReset()
      +  ensureGatewayForProfile.mockClear()
      +  $gateway.set({ id: 'live-socket' })
      +  $activeGatewayProfile.set('default')
      +  $connection.set(localConn())
      +  vi.stubGlobal('window', { hermesDesktop: { getConnection } })
      +})
      +
      +afterEach(() => {
      +  vi.unstubAllGlobals()
      +  $connection.set(null)
      +})
      +
      +describe('ensureGatewayProfile → $connection sync (#46651)', () => {
      +  it('refreshes $connection to the remote descriptor when activating a remote pool profile', async () => {
      +    // Regression: the primary window backend is local, so $connection.mode is
      +    // "local". Activating the remote profile must flip it to "remote" — without
      +    // this, image attach uses path-based image.attach against the remote
      +    // gateway ("image not found: C:\\…") instead of image.attach_bytes.
      +    getConnection.mockResolvedValue(remoteConn())
      +
      +    await ensureGatewayProfile('vps-remote')
      +
      +    expect(ensureGatewayForProfile).toHaveBeenCalledWith('vps-remote')
      +    expect(getConnection).toHaveBeenCalledWith('vps-remote')
      +    expect($connection.get()?.mode).toBe('remote')
      +    expect($connection.get()?.profile).toBe('vps-remote')
      +  })
      +
      +  it('resyncs $connection back to local when returning to the default profile', async () => {
      +    $activeGatewayProfile.set('vps-remote')
      +    $connection.set(remoteConn())
      +    getConnection.mockResolvedValue(localConn())
      +
      +    await ensureGatewayProfile('default')
      +
      +    expect(getConnection).toHaveBeenCalledWith('default')
      +    expect($connection.get()?.mode).toBe('local')
      +  })
      +
      +  it('leaves the prior connection intact when the descriptor fetch fails', async () => {
      +    getConnection.mockRejectedValue(new Error('backend unreachable'))
      +
      +    await ensureGatewayProfile('vps-remote')
      +
      +    // Best-effort: boot/reconnect resyncs later; we must not null it out here.
      +    expect($connection.get()?.mode).toBe('local')
      +  })
      +
      +  it('does not churn $connection when the target is already the active profile', async () => {
      +    $activeGatewayProfile.set('vps-remote')
      +    $connection.set(remoteConn())
      +
      +    await ensureGatewayProfile('vps-remote')
      +
      +    expect(getConnection).not.toHaveBeenCalled()
      +    expect(ensureGatewayForProfile).not.toHaveBeenCalled()
      +    expect($connection.get()?.mode).toBe('remote')
      +  })
      +})
    • apps/desktop/src/store/profile.ts
      @@ -12,6 +12,7 @@ import {
         storedStringRecord
       } from '@/lib/storage'
       import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
      +import { setConnection } from '@/store/session'
       import type { ProfileInfo } from '@/types/hermes'
       
       // Canonical key for a profile: trimmed, empty → "default". Used everywhere we
      @@ -178,6 +179,32 @@ export const $gatewaySwapTarget = atom<string | null>(null)
       
       let gatewaySwitch: Promise<void> | null = null
       
      +// Keep the renderer's $connection (mode / baseUrl / profile) in lockstep with
      +// the profile the live gateway is now on. $connection seeds from the PRIMARY
      +// (window) backend at boot and otherwise only refreshes on a sleep/wake
      +// reconnect — so activating a *background* profile left $connection describing
      +// the primary, with the wrong `mode` for everything that branches on
      +// local-vs-remote. Headline symptom: with a local primary and a remote pool
      +// profile active, image attachments went out via the path-based `image.attach`
      +// instead of `image.attach_bytes`, handing the remote gateway a client-only
      +// path it can't resolve ("image not found: C:\…"), while the /api/fs/* file
      +// browser and /api/media fetches targeted the wrong machine (#46651).
      +// Best-effort: a failed descriptor fetch leaves the prior connection intact for
      +// boot/reconnect to resync.
      +async function syncConnectionToActiveProfile(profile: string): Promise<void> {
      +  const getConnection = window.hermesDesktop?.getConnection
      +
      +  if (!getConnection) {
      +    return
      +  }
      +
      +  try {
      +    setConnection(await getConnection(profile))
      +  } catch {
      +    // Leave the prior connection in place; boot/reconnect resyncs it later.
      +  }
      +}
      +
       // Make `profile`'s backend the active gateway, lazily opening its socket if it
       // isn't live yet. Unlike the old single-socket swap, background profiles keep
       // their sockets — so their sessions keep streaming concurrently. A null/empty
      @@ -218,6 +245,9 @@ export async function ensureGatewayProfile(profile: string | null | undefined):
           // the active gateway at it — without closing the profile you came from.
           await ensureGatewayForProfile(target)
           $activeGatewayProfile.set(target)
      +    // The active backend just changed; resync $connection so remote-aware
      +    // paths (image.attach_bytes vs image.attach, /api/fs/*, /api/media) follow.
      +    await syncConnectionToActiveProfile(target)
         })()
       
         try {

3 PRs closed without merging

From the close comments: merged via another change 2 · done elsewhere 1

  • #38615 fix(desktop): sync file attachments to remote workspace · merged via another change → #42634, #1
  • #39264 fix(desktop): inline pasted images for remote gateway connections · done elsewhere → 16786f3, #38078
  • #40317 fix(desktop+dashboard): upload composer attachments to the remote backend instead of passing local paths · merged via another change → 16786f3, #41336
Closed issues · 8
  • Closed#38078Desktop pasted images fail with remote gateway because local paths are sent to image.attach
  • Closed#38464[Bug]: Desktop drag-drop file uploads don't sync to remote gateway workspace
  • Closed#39447[Bug] Desktop drag-and-drop images fail with remote gateway (local path sent instead of uploading)
  • Closed#40316Remote desktop: composer image attachments are sent as local paths, unreadable by the remote backend
  • Closed#41556Image paste/attach in Desktop app sends file path instead of image data to model
  • Closed#41669[Bug]: Unable to attach screen shots or files via the desktop app in Gateway mode
  • Closed#46651[Bug] Desktop (a376ca0): image upload fails on remote profile — "image not found: <local_path>"
  • Closed#65297Desktop image paste broken — session ID drift between image.attach and prompt.submit
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.09: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#41336, #42634, #46658
Closed issues
8
Attempts
3 PRs closed without merging
Fix link checked
15 Jun 2026

For your agent

The same facts, typed.