mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
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:
parent
e687292eb4
commit
dbbd1d4d05
6 changed files with 603 additions and 53 deletions
|
|
@ -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' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue