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:
committed by
Teknium
co-authored by
LeonSGP43
parent
e687292eb4
commit
dbbd1d4d05
@@ -4,6 +4,8 @@ import { useEffect } from 'react'
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
import { $sessions, setSessions } from '@/store/session'
|
import { $sessions, setSessions } from '@/store/session'
|
||||||
|
import { $connection } from '@/store/session'
|
||||||
|
import type { ComposerAttachment } from '@/store/composer'
|
||||||
import type { SessionInfo } from '@/types/hermes'
|
import type { SessionInfo } from '@/types/hermes'
|
||||||
|
|
||||||
import { usePromptActions } from './use-prompt-actions'
|
import { usePromptActions } from './use-prompt-actions'
|
||||||
@@ -42,7 +44,10 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
|||||||
|
|
||||||
interface HarnessHandle {
|
interface HarnessHandle {
|
||||||
steerPrompt: (text: string) => Promise<boolean>
|
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({
|
function Harness({
|
||||||
@@ -314,3 +319,92 @@ describe('usePromptActions steerPrompt', () => {
|
|||||||
expect(requestGateway).not.toHaveBeenCalled()
|
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 {
|
import type {
|
||||||
ClientSessionState,
|
ClientSessionState,
|
||||||
|
FileAttachResponse,
|
||||||
ImageAttachResponse,
|
ImageAttachResponse,
|
||||||
SessionSteerResponse,
|
SessionSteerResponse,
|
||||||
SessionTitleResponse,
|
SessionTitleResponse,
|
||||||
@@ -103,6 +104,20 @@ async function readImageForRemoteAttach(
|
|||||||
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
|
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 {
|
interface PromptActionsOptions {
|
||||||
activeSessionId: string | null
|
activeSessionId: string | null
|
||||||
activeSessionIdRef: MutableRefObject<string | null>
|
activeSessionIdRef: MutableRefObject<string | null>
|
||||||
@@ -212,62 +227,114 @@ export function usePromptActions({
|
|||||||
[selectedStoredSessionIdRef, updateSessionState]
|
[selectedStoredSessionIdRef, updateSessionState]
|
||||||
)
|
)
|
||||||
|
|
||||||
const syncImageAttachmentsForSubmit = useCallback(
|
const syncAttachmentsForSubmit = useCallback(
|
||||||
async (
|
async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
attachments: ComposerAttachment[],
|
attachments: ComposerAttachment[],
|
||||||
options: { updateComposerAttachments?: boolean } = {}
|
options: { updateComposerAttachments?: boolean } = {}
|
||||||
) => {
|
): Promise<ComposerAttachment[]> => {
|
||||||
const updateComposerAttachments = options.updateComposerAttachments ?? true
|
const updateComposerAttachments = options.updateComposerAttachments ?? true
|
||||||
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
|
|
||||||
const remote = $connection.get()?.mode === 'remote'
|
const remote = $connection.get()?.mode === 'remote'
|
||||||
|
const synced: ComposerAttachment[] = []
|
||||||
|
|
||||||
for (const attachment of images) {
|
for (const attachment of attachments) {
|
||||||
if (attachment.attachedSessionId === sessionId) {
|
// Already-synced or pathless refs (terminal, url, etc.) pass through.
|
||||||
|
if (!attachment.path || attachment.attachedSessionId === sessionId) {
|
||||||
|
synced.push(attachment)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: ImageAttachResponse
|
if (attachment.kind === 'image') {
|
||||||
|
let result: ImageAttachResponse
|
||||||
|
|
||||||
if (remote) {
|
if (remote) {
|
||||||
// The gateway is on another machine — it can't read attachment.path
|
// The gateway is on another machine — it can't read attachment.path
|
||||||
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
|
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
|
||||||
const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
|
const payload = await readImageForRemoteAttach(attachment.path)
|
||||||
|
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
const label = attachment.label || pathLabel(attachment.path)
|
||||||
throw new Error(`Could not read ${label}`)
|
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', {
|
if (!result.attached) {
|
||||||
session_id: sessionId,
|
const label = attachment.label || pathLabel(attachment.path)
|
||||||
content_base64: payload.contentBase64,
|
throw new Error(result.message || `Could not attach ${label}`)
|
||||||
filename: payload.filename
|
}
|
||||||
})
|
|
||||||
} else {
|
|
||||||
result = await requestGateway<ImageAttachResponse>('image.attach', {
|
|
||||||
session_id: sessionId,
|
|
||||||
path: attachment.path
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.attached) {
|
const attachedPath = result.path || attachment.path
|
||||||
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
|
const nextAttachment: ComposerAttachment = {
|
||||||
throw new Error(result.message || `Could not attach ${label}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachedPath = result.path || attachment.path
|
|
||||||
|
|
||||||
if (updateComposerAttachments) {
|
|
||||||
addComposerAttachment({
|
|
||||||
...attachment,
|
...attachment,
|
||||||
id: attachment.id,
|
id: attachment.id,
|
||||||
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
|
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
|
||||||
path: attachedPath,
|
path: attachedPath,
|
||||||
attachedSessionId: sessionId
|
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]
|
[requestGateway]
|
||||||
)
|
)
|
||||||
@@ -278,35 +345,42 @@ export function usePromptActions({
|
|||||||
const usingComposerAttachments = !options?.attachments
|
const usingComposerAttachments = !options?.attachments
|
||||||
const attachments = options?.attachments ?? $composerAttachments.get()
|
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 terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
|
||||||
const hasImage = attachments.some(a => a.kind === 'image')
|
const hasImage = attachments.some(a => a.kind === 'image')
|
||||||
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
|
|
||||||
|
|
||||||
const text =
|
// Refs are recomputed after sync (file.attach rewrites @file: refs to
|
||||||
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
|
// workspace-relative paths the remote gateway can resolve). Seed the
|
||||||
(hasImage ? 'What do you see in this image?' : '')
|
// 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
|
// 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
|
// 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
|
// 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.
|
// 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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
|
||||||
const userMessage: ChatMessage = {
|
const buildUserMessage = (): ChatMessage => ({
|
||||||
id: optimisticId,
|
id: optimisticId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
|
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
|
||||||
attachmentRefs
|
attachmentRefs
|
||||||
}
|
})
|
||||||
|
|
||||||
const releaseBusy = () => {
|
const releaseBusy = () => {
|
||||||
setMutableRef(busyRef, false)
|
setMutableRef(busyRef, false)
|
||||||
@@ -323,7 +397,7 @@ export function usePromptActions({
|
|||||||
...state,
|
...state,
|
||||||
messages: state.messages.some(m => m.id === optimisticId)
|
messages: state.messages.some(m => m.id === optimisticId)
|
||||||
? state.messages
|
? state.messages
|
||||||
: [...state.messages, userMessage],
|
: [...state.messages, buildUserMessage()],
|
||||||
busy: true,
|
busy: true,
|
||||||
awaitingResponse: true,
|
awaitingResponse: true,
|
||||||
pendingBranchGroup: null,
|
pendingBranchGroup: null,
|
||||||
@@ -336,6 +410,18 @@ export function usePromptActions({
|
|||||||
selectedStoredSessionIdRef.current
|
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) => {
|
const dropOptimistic = (sid: null | string) => {
|
||||||
if (!sid) {
|
if (!sid) {
|
||||||
setMessages(current => current.filter(m => m.id !== optimisticId))
|
setMessages(current => current.filter(m => m.id !== optimisticId))
|
||||||
@@ -366,7 +452,7 @@ export function usePromptActions({
|
|||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
seedOptimistic(sessionId)
|
seedOptimistic(sessionId)
|
||||||
} else {
|
} else {
|
||||||
setMessages(current => [...current, userMessage])
|
setMessages(current => [...current, buildUserMessage()])
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
@@ -392,9 +478,14 @@ export function usePromptActions({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await syncImageAttachmentsForSubmit(sessionId, attachments, {
|
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
|
||||||
updateComposerAttachments: usingComposerAttachments
|
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 })
|
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||||
|
|
||||||
if (usingComposerAttachments) {
|
if (usingComposerAttachments) {
|
||||||
@@ -442,7 +533,7 @@ export function usePromptActions({
|
|||||||
createBackendSessionForSend,
|
createBackendSessionForSend,
|
||||||
requestGateway,
|
requestGateway,
|
||||||
selectedStoredSessionIdRef,
|
selectedStoredSessionIdRef,
|
||||||
syncImageAttachmentsForSubmit,
|
syncAttachmentsForSubmit,
|
||||||
updateSessionState
|
updateSessionState
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -27,6 +27,20 @@ export interface ImageDetachResponse {
|
|||||||
count?: number
|
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 {
|
export interface SlashExecResponse {
|
||||||
output?: string
|
output?: string
|
||||||
warning?: string
|
warning?: string
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ function isUpdateToastSnoozed(): boolean {
|
|||||||
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
|
// 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
|
// against. The backend reports its own value in session runtime info; a lower
|
||||||
// value (or none — a pre-GUI checkout) means GUI<->backend skew.
|
// 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'
|
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
|
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):
|
def test_commands_catalog_surfaces_quick_commands(monkeypatch):
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
server,
|
server,
|
||||||
|
|||||||
+193
-1
@@ -1992,7 +1992,8 @@ def _current_profile_name() -> str:
|
|||||||
# backend reporting less than its required value (or none at all — a pre-GUI
|
# 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
|
# checkout), surfacing a one-click "update to align" prompt instead of failing
|
||||||
# cryptically downstream. Bump whenever the desktop's backend contract changes.
|
# 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:
|
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")
|
@method("image.detach")
|
||||||
def _(rid, params: dict) -> dict:
|
def _(rid, params: dict) -> dict:
|
||||||
session, err = _sess(params, rid)
|
session, err = _sess(params, rid)
|
||||||
|
|||||||
Reference in New Issue
Block a user