From dbbd1d4d050146c8e2d0cd01eaa8543993dd98f9 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:01:10 -0700 Subject: [PATCH] feat(desktop+gateway): remote-gateway file attachments via file.attach @file: attachments now work when the desktop is connected to a remote gateway. Previously a referenced file resolved to a client-disk path the gateway couldn't see, so context_references rejected it with "path is outside the allowed workspace" and the agent never saw the file. Adds a file.attach RPC (sibling to the existing image.attach_bytes / pdf.attach byte-upload pipeline): the desktop uploads the file bytes, the gateway stages them into /.hermes/desktop-attachments/ and returns a workspace-relative @file: ref that resolves cleanly. Local mode passes the path directly; a gateway-visible file outside the workspace is copied in; an in-workspace file is referenced as-is with no copy. Consolidates the file-sync design from #38615 (LeonSGP43) and the host-file-staging idea from #33455 (Carry00), rebased onto the image/PDF remote-media helpers already on main. Co-authored-by: LeonSGP43 --- .../session/hooks/use-prompt-actions.test.tsx | 96 ++++++++- .../app/session/hooks/use-prompt-actions.ts | 191 ++++++++++++----- apps/desktop/src/app/types.ts | 14 ++ apps/desktop/src/store/updates.ts | 3 +- tests/test_tui_gateway_server.py | 158 ++++++++++++++ tui_gateway/server.py | 194 +++++++++++++++++- 6 files changed, 603 insertions(+), 53 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx index 1c9bb1053f0..c348db6b00b 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx +++ b/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 { interface HarnessHandle { steerPrompt: (text: string) => Promise - submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise + submitText: ( + text: string, + options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean } + ) => Promise } 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 }[] = [] + const requestGateway = vi.fn(async (method: string, params?: Record) => { + 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( (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 }[] = [] + const requestGateway = vi.fn(async (method: string, params?: Record) => { + 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( (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' } + }) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions.ts index 173d5f28d40..1f6457d38b2 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions.ts +++ b/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 { + const reader = window.hermesDesktop?.readFileDataUrl + + if (!reader) { + return null + } + + const dataUrl = await reader(filePath) + + return dataUrl || null +} + interface PromptActionsOptions { activeSessionId: string | null activeSessionIdRef: MutableRefObject @@ -212,62 +227,114 @@ export function usePromptActions({ [selectedStoredSessionIdRef, updateSessionState] ) - const syncImageAttachmentsForSubmit = useCallback( + const syncAttachmentsForSubmit = useCallback( async ( sessionId: string, attachments: ComposerAttachment[], options: { updateComposerAttachments?: boolean } = {} - ) => { + ): Promise => { 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 = attachment.path ? await readImageForRemoteAttach(attachment.path) : null + 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 || (attachment.path ? pathLabel(attachment.path) : 'image') - throw new Error(`Could not read ${label}`) + if (!payload) { + const label = attachment.label || pathLabel(attachment.path) + throw new Error(`Could not read ${label}`) + } + + result = await requestGateway('image.attach_bytes', { + session_id: sessionId, + content_base64: payload.contentBase64, + filename: payload.filename + }) + } else { + result = await requestGateway('image.attach', { + session_id: sessionId, + path: attachment.path + }) } - result = await requestGateway('image.attach_bytes', { - session_id: sessionId, - content_base64: payload.contentBase64, - filename: payload.filename - }) - } else { - result = await requestGateway('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}`) + } - 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 (updateComposerAttachments) { - addComposerAttachment({ + 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 (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 (remote && !dataUrl) { + const label = attachment.label || pathLabel(attachment.path) + throw new Error(`Could not read ${label}`) + } + + const result = await requestGateway('file.attach', { + session_id: sessionId, + path: attachment.path, + name: attachment.label || pathLabel(attachment.path), + ...(dataUrl ? { data_url: dataUrl } : {}) + }) + + if (!result.attached || !result.ref_text) { + const label = attachment.label || pathLabel(attachment.path) + throw new Error(result.message || `Could not attach ${label}`) + } + + const nextAttachment: ComposerAttachment = { + ...attachment, + id: attachment.id, + 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 ] ) diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 23fd1c6f48f..14f307eef93 100644 --- a/apps/desktop/src/app/types.ts +++ b/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 diff --git a/apps/desktop/src/store/updates.ts b/apps/desktop/src/store/updates.ts index b3b05c1066f..8b838d4aacd 100644 --- a/apps/desktop/src/store/updates.ts +++ b/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' /** diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index a7fcf2ed927..136703e1042 100644 --- a/tests/test_tui_gateway_server.py +++ b/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, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 8515f7ea24d..23d8430e210 100644 --- a/tui_gateway/server.py +++ b/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:;base64,`` 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:;base64,`` 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)