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 <workspace>/.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 <cine.dreamer.one@gmail.com>
This commit is contained in:
teknium1 2026-06-08 23:01:10 -07:00 committed by Teknium
parent e687292eb4
commit dbbd1d4d05
6 changed files with 603 additions and 53 deletions

View file

@ -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' }
})
})
})

View file

@ -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 = 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<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
})
}
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}`)
}
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<FileAttachResponse>('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
]
)

View file

@ -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

View file

@ -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'
/**

View file

@ -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,

View file

@ -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)