Ver Fonte

修复聊天工具状态并统一服务端文件访问权限,升级 v0.4.9

Samien há 1 mês atrás
pai
commit
f502ef7d42

+ 23 - 0
docs/file-access.md

@@ -0,0 +1,23 @@
+# 服务端文件访问权限
+
+设置入口为「设置 → 文件访问」,仅管理员可修改。配置保存在服务端数据目录的 `file-access.json`,随现有数据备份一起保存;客户端不保存允许目录列表。
+
+默认配置:
+
+```json
+{ "mode": "folders", "folders": [] }
+```
+
+- 当前 Profile 的工作目录始终允许。绝对目录取自 Hermes 的 `terminal.cwd`;相对路径及默认值由 Hermes `/api/fs/default-cwd` 返回的实际工作目录确认,不使用 Web 进程或手机的目录。
+- `folders` 是额外目录,不替代 cwd。为空时只允许工作目录及其子目录。
+- 开启「全局授权」后保存 `mode: "all"`,无需逐个添加目录。Hermes 自身的认证、敏感文件和宿主文件权限仍然生效。
+- 文件请求携带 Profile,普通聊天、移动端和配对节点走同一服务端校验。指定目录模式会向拥有文件的 Hermes 核对真实目录和文件路径,拒绝越界及符号链接逃逸;回环地址也不被当作共享文件系统的证据。
+- 服务器文件路径统一通过 `/api/files/download?path=…&profile=…` 读取。HTML、SVG、XML 等主动内容不能作为同源可执行页面预览。已上传、归档的附件继续使用其原有文件接口和权限校验。
+- 修改配置后,后续服务端文件请求立即应用新规则,包括缓存命中。已下载到客户端的副本不会被远程删除。
+
+接口:
+
+- `GET /api/app/settings/file-access?profile=default`:返回配置、当前工作目录及目录读取错误。
+- `PUT /api/app/settings/file-access`:管理员通过现有 CSRF 保护保存 `mode`、`folders`。
+
+加载失败时显示文件名和服务端原因,并提供重试。目录未授权、文件已移动和连接失败分别处理,不再仅显示破损图片。

+ 4 - 0
docs/ordinary-chat-incremental-sync.md

@@ -19,6 +19,10 @@
 
 ## 验证
 
+工具生命周期同时覆盖实时事件、持久化投影、历史恢复与各端展示:无调用 ID 的 `tool.generating` 只是进度通知;真实调用按工具 ID 合并。回合结束后,缺少完成回执的工具显示结果未确认,失败结果(包括非零退出码)保留失败。历史快照通过用户消息身份替换已完成回合的临时投影,并保护仍在执行、其后已有排队输入的回合。
+
+真实受影响会话的副本回放中,13 条重复累计的工具显示恢复为 5 次实际调用,残留运行状态为 0。文件目录规则见 [服务端文件访问权限](file-access.md)。
+
 - 1,000 条已缓存消息,20 次历史分页读取不请求上游。
 - 新增回复只读取一页尾部;漏掉 130 条消息只补两页;原有消息不重新写入。
 - 明确强制刷新才覆盖旧编辑和删除;失败时保留旧记录;并发完成事件提交后再通知客户端。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "yaoyao",
-  "version": "0.4.8",
+  "version": "0.4.9",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "yaoyao",
-      "version": "0.4.8",
+      "version": "0.4.9",
       "license": "Apache-2.0",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "yaoyao",
-  "version": "0.4.8",
+  "version": "0.4.9",
   "private": true,
   "license": "Apache-2.0",
   "description": "夭夭 AI:聊天、多 Agent 团队、文件库与电脑工作空间",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.4.8",
-  "webVersion": "0.4.8",
-  "gitTag": "v0.4.8"
+  "releaseVersion": "0.4.9",
+  "webVersion": "0.4.9",
+  "gitTag": "v0.4.9"
 }

+ 57 - 0
src/client/components/app/FileAccessPanel.vue

@@ -0,0 +1,57 @@
+<script setup lang="ts">
+import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { apiRequest, apiUrl } from '@/api/client'
+
+const props = defineProps<{ profile?: string }>()
+const emit = defineEmits<{ 'dirty-change': [value: boolean] }>()
+const all = ref(false), folders = ref(''), cwd = ref(''), cwdError = ref('')
+const loading = ref(false), saving = ref(false), error = ref(''), saved = ref(false)
+let generation = 0
+async function load() {
+  const current = ++generation
+  loading.value = true; error.value = ''; cwd.value = ''; cwdError.value = ''
+  try {
+    const value = await apiRequest<{ mode: 'all' | 'folders'; folders: string[]; workingDirectory?: string; cwdError?: string }>(
+      apiUrl('/api/app/settings/file-access', { profile: props.profile || 'default' }))
+    if (current !== generation) return
+    all.value = value.mode === 'all'; folders.value = value.folders.join('\n')
+    cwd.value = value.workingDirectory || ''; cwdError.value = value.cwdError || ''
+    emit('dirty-change', false)
+  } catch (cause) { if (current === generation) error.value = cause instanceof Error ? cause.message : '无法读取文件访问设置' }
+  finally { if (current === generation) loading.value = false }
+}
+function changed() { saved.value = false; emit('dirty-change', true) }
+async function save() {
+  saving.value = true; error.value = ''; saved.value = false
+  try {
+    await apiRequest('/api/app/settings/file-access', { method: 'PUT', body: {
+      mode: all.value ? 'all' : 'folders', folders: folders.value.split('\n').map(value => value.trim()).filter(Boolean),
+    } })
+    saved.value = true; emit('dirty-change', false)
+  } catch (cause) { error.value = cause instanceof Error ? cause.message : '保存失败,请重试' }
+  finally { saving.value = false }
+}
+onMounted(load)
+onBeforeUnmount(() => { generation += 1 })
+watch(() => props.profile, load)
+</script>
+
+<template>
+  <section class="file-access" aria-label="文件访问权限">
+    <p>控制聊天中服务器文件的预览和下载。设置保存在服务端,Web、桌面版和移动端共用。</p>
+    <div class="cwd"><strong>工作目录 · 始终允许</strong><code>{{ cwd || '尚未取得工作目录' }}</code><small>当前 Profile:{{ profile || 'default' }} · 使用服务器配置的 terminal.cwd</small></div>
+    <p v-if="cwdError" role="alert" class="error">{{ cwdError }}</p>
+    <form @submit.prevent="save" @input="changed">
+      <label class="toggle"><input v-model="all" type="checkbox" :disabled="loading || saving" /><span><strong>全局授权:允许所有目录</strong><small>开启后无需逐个添加文件夹。仍遵守 Hermes 所在服务器的文件权限。</small></span></label>
+      <label class="folders">额外允许的文件夹<textarea v-model="folders" :disabled="loading || saving || all" rows="5" placeholder="每行一个服务器绝对路径,例如 /tmp" aria-describedby="folder-help" /></label>
+      <small id="folder-help">未开启全局授权且列表为空时,只允许工作目录;添加目录后也包含其子目录。</small>
+      <p v-if="error" class="error" role="alert">{{ error }}</p>
+      <p v-if="saved" role="status">已保存,后续服务端文件请求立即使用新权限。</p>
+      <div class="actions"><button type="button" :disabled="loading || saving" @click="load">重新读取</button><button type="submit" class="save" :disabled="loading || saving">{{ saving ? '保存中…' : loading ? '读取中…' : '保存设置' }}</button></div>
+    </form>
+  </section>
+</template>
+
+<style scoped>
+.file-access{display:grid;gap:18px;max-width:680px;color:var(--text-primary);font-size:13px;line-height:1.7}.file-access p{margin:0}.cwd{display:grid;gap:8px;padding:16px;background:var(--surface-soft);border:1px solid var(--line);border-radius:12px}.cwd code{overflow-wrap:anywhere;white-space:pre-wrap}.file-access small{display:block;color:var(--text-secondary);font-size:12px}.file-access form{display:grid;gap:14px}.toggle{display:flex;gap:12px;align-items:center;min-height:56px;cursor:pointer}.toggle input{width:20px;height:20px;accent-color:var(--accent)}.folders{display:grid;gap:8px}.folders textarea{box-sizing:border-box;width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--text-primary);font:inherit;resize:vertical}.actions{display:flex;gap:10px;justify-content:flex-end}.actions button{min-height:44px;padding:8px 16px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--text-primary);font:inherit;cursor:pointer}.actions .save{background:var(--accent);color:var(--text-on-solid)}button:disabled,textarea:disabled{opacity:.55;cursor:default}.error{color:var(--danger)}input:focus-visible,textarea:focus-visible,button:focus-visible{outline:2px solid var(--accent);outline-offset:3px}
+</style>

+ 5 - 0
src/client/components/app/SettingsCenterDialog.vue

@@ -10,6 +10,7 @@ import WorkspaceNodesPanel from '@/components/workspace/WorkspaceNodesPanel.vue'
 import WorkspaceVoiceProviders from '@/components/workspace/WorkspaceVoiceProviders.vue'
 import SystemManagementPanel from '@/components/app/SystemManagementPanel.vue'
 import SystemOverviewPanel from '@/components/app/SystemOverviewPanel.vue'
+import FileAccessPanel from '@/components/app/FileAccessPanel.vue'
 import LocalVmSettingsPanel from '@/components/app/LocalVmSettingsPanel.vue'
 import SystemUpdatePanel from '@/components/app/SystemUpdatePanel.vue'
 import AgentAvatar from '@/components/common/AgentAvatar.vue'
@@ -24,6 +25,7 @@ type SettingsPage =
   | 'account-mobile'
   | 'appearance'
   | 'system-overview'
+  | 'system-file-access'
   | 'system-users'
   | 'system-connection'
   | 'system-push'
@@ -108,6 +110,7 @@ const accountItems = computed<NavigationItem[]>(() => [
 const systemItems: NavigationItem[] = [
   { key: 'system-local-vm', label: '本地虚拟机', icon: 'monitor' },
   { key: 'system-overview', label: '系统概览', icon: 'panel' },
+  { key: 'system-file-access', label: '文件访问', icon: 'panel' },
   { key: 'system-users', label: '用户与权限', icon: 'users' },
   { key: 'system-connection', label: 'Hermes 连接', icon: 'link' },
   { key: 'system-push', label: '消息推送', icon: 'bell' },
@@ -131,6 +134,7 @@ const activeTitle = computed(() => ({
   appearance: '外观',
   'system-local-vm': '本地虚拟机',
   'system-overview': '系统概览',
+  'system-file-access': '文件访问',
   'system-users': '用户与权限',
   'system-connection': 'Hermes 连接',
   'system-push': '消息推送',
@@ -360,6 +364,7 @@ function requestModeSwitch() {
                   </div>
                 </section>
                 <SystemOverviewPanel v-else-if="activePage === 'system-overview' && isAdmin" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @navigate="selectPage" />
+                <FileAccessPanel v-else-if="activePage === 'system-file-access' && isAdmin" :profile="activeProfile?.name" @dirty-change="setDirty('system-file-access', $event)" />
                 <LocalVmSettingsPanel v-else-if="activePage === 'system-local-vm' && isAdmin" />
                 <SystemManagementPanel v-else-if="activePage === 'system-users' && isAdmin" section="users" :profiles="profiles" :active="true" @dirty-change="setDirty('system-users', $event)" />
                 <SystemManagementPanel v-else-if="activePage === 'system-connection' && isAdmin" section="connection" :active="true" :upstream-ready="upstreamReady" :upstream-error="upstreamError" @dirty-change="setDirty('system-connection', $event)" />

+ 43 - 5
src/client/components/messages/MarkdownContent.vue

@@ -7,6 +7,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import { copyTextToClipboard } from '@/utils/clipboard'
 import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
 import { repairMarkdownForRender } from '@/utils/markdownRepair'
+import { serverFilePath, serverFileUrl } from '@shared/serverFiles'
 
 const props = withDefaults(defineProps<{
   content: string
@@ -17,6 +18,7 @@ const props = withDefaults(defineProps<{
   fileCards?: boolean
   processContent?: boolean
   outlinePrefix?: string
+  fileProfile?: string
 }>(), { streaming: false, legacyMedia: false, plain: false, fileCards: false, processContent: false, outlinePrefix: '' })
 
 const emit = defineEmits<{ fileLink: [name: string, url: string]; rendered: [] }>()
@@ -88,10 +90,15 @@ const md = new MarkdownIt({
 
 // Dashboard 规范:保留原文引号,不做智能引号替换。
 md.disable('smartquotes')
+const defaultValidateLink = md.validateLink
+md.validateLink = value => Boolean(serverFileUrl(value)) || defaultValidateLink(value)
 
 const defaultImage = md.renderer.rules.image!
 md.renderer.rules.image = (tokens, index, options, env, self) => {
   if (props.processContent) return escapeHtml(tokens[index].content || tokens[index].attrGet('src') || '图片')
+  const source = tokens[index].attrGet('src') || ''
+  const file = serverFileUrl(source, props.fileProfile)
+  if (file) tokens[index].attrSet('src', file)
   return defaultImage(tokens, index, options, env, self)
 }
 
@@ -101,9 +108,8 @@ md.renderer.rules.link_open = (tokens, index, options, env, self) => {
   // Some assistants wrap real Hermes workspace paths in a sandbox: URI.
   // Resolve only this known file route before sanitizing the Markdown.
   const href = token.attrGet('href') || ''
-  if (!props.processContent && props.fileCards && /^sandbox:\/Users\/[^/]+\/\.hermes\/(?:profiles\/[^/]+\/)?workspace\/.+/i.test(href)) {
-    token.attrSet('href', href.slice('sandbox:'.length))
-  }
+  const file = !props.processContent && props.fileCards ? serverFileUrl(href, props.fileProfile) : undefined
+  if (file && /\.[^/]+$/.test(serverFilePath(href) ?? '')) token.attrSet('href', file)
   token.attrSet('target', '_blank')
   token.attrSet('rel', 'noopener noreferrer')
   return defaultLinkOpen(tokens, index, options, env, self)
@@ -191,7 +197,8 @@ function decorateFileLinks() {
     const isAgentFile = /^\/Users\/[^/]+\/Agents\/.+/.test(url.pathname)
     const isWorkspaceFile = /^\/Users\/[^/]+\/\.hermes\/(?:profiles\/[^/]+\/)?workspace\/.+/.test(url.pathname)
     const isRemoteNodeFile = /^\/api\/app\/files\/(?:[0-9a-f-]{36}|[0-9]+)\/(?:download|preview)$/i.test(url.pathname)
-    if (url.origin !== window.location.origin || (!isAgentFile && !isWorkspaceFile && !isRemoteNodeFile)) return
+    const isServerFile = url.pathname === '/api/files/download' && Boolean(url.searchParams.get('path'))
+    if (url.origin !== window.location.origin || (!isAgentFile && !isWorkspaceFile && !isRemoteNodeFile && !isServerFile)) return
     link.dataset.fileCard = 'true'
     link.classList.add('file-link-card')
     link.removeAttribute('target')
@@ -213,7 +220,7 @@ function decorateMediaPreviews() {
     try { url = new URL(image.currentSrc || image.src, window.location.href) } catch { return }
     if (url.origin !== window.location.origin) return
     image.dataset.mediaPreview = 'true'
-    const name = decodeURIComponent(url.pathname.split('/').at(-1) || '图片')
+    const name = (url.searchParams.get('path') || decodeURIComponent(url.pathname)).split('/').at(-1) || '图片'
     image.setAttribute('role', 'button')
     image.setAttribute('tabindex', '0')
     image.setAttribute('aria-label', `预览图片 ${name}`)
@@ -226,6 +233,35 @@ function decorateMediaPreviews() {
     image.addEventListener('keydown', event => {
       if ((event as KeyboardEvent).key === 'Enter' || (event as KeyboardEvent).key === ' ') preview(event)
     })
+    let failure: HTMLElement | undefined
+    const failed = async () => {
+      if (!image.isConnected || failure) return
+      failure = document.createElement('span')
+      failure.className = 'media-load-error'
+      failure.setAttribute('role', 'alert')
+      const label = document.createElement('span')
+      label.textContent = `${name}:图片加载失败。`
+      const retry = document.createElement('button')
+      retry.type = 'button'; retry.textContent = '重试'
+      retry.addEventListener('click', () => {
+        failure?.remove(); failure = undefined
+        image.hidden = false
+        const next = new URL(url); next.searchParams.set('_retry', String(Date.now())); image.src = next.toString()
+      })
+      failure.append(label, retry); image.after(failure); image.hidden = true
+      if (url.pathname !== '/api/files/download') return
+      try {
+        const response = await fetch(url, { credentials: 'same-origin' })
+        if (!response.ok) {
+          const value = await response.json().catch(() => ({}))
+          const reason = value.error?.message || (typeof value.error === 'string' ? value.error : undefined) || value.message || value.detail
+          label.textContent = `${name}:${typeof reason === 'string' ? reason : response.status === 404 ? '服务器文件不存在或已被移动。' : response.status === 403 ? '此目录未获授权,请检查设置中的文件访问权限。' : '服务器暂时无法读取此文件。'}`
+        } else { await response.body?.cancel(); label.textContent = `${name}:文件内容不是可解码的图片。` }
+      } catch { label.textContent = `${name}:连接失败,请重试。` }
+    }
+    image.addEventListener('error', () => { void failed() })
+    image.addEventListener('load', () => { image.hidden = false; failure?.remove(); failure = undefined })
+    if (image.complete && image.naturalWidth === 0) void failed()
   })
 }
 
@@ -256,6 +292,8 @@ onUpdated(() => { decorateCopyButtons(); decorateFileLinks(); decorateMediaPrevi
 </template>
 
 <style scoped>
+.markdown :deep(img[hidden]){display:none!important}
+.markdown :deep(.media-load-error){display:flex;align-items:center;gap:12px;padding:12px;border:1px solid var(--line);border-radius:10px;background:var(--surface-soft);font-size:12px}.markdown :deep(.media-load-error span){flex:1;overflow-wrap:anywhere}.markdown :deep(.media-load-error button){min-width:48px;min-height:44px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--text-primary);cursor:pointer}.markdown :deep(.media-load-error button:focus-visible){outline:2px solid var(--accent);outline-offset:2px}
 .plain-text { min-width: 0; color: inherit; font-size: 13px; line-height: 1.68; white-space: pre-wrap; overflow-wrap: anywhere; }
 .markdown-block { display: flow-root; }
 .markdown { min-width: 0; color: inherit; font-size: 13px; line-height: 1.7; overflow-wrap: anywhere; }

+ 1 - 0
src/client/components/messages/MessageTimeline.vue

@@ -328,6 +328,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
 
             <div class="message__content">
               <MarkdownContent
+                :file-profile="message.profile"
                 :content="displayContentForMessage(message.role, message.content)"
                 :streaming="message.status === 'streaming'"
                 :legacy-media="message.role === 'assistant'"

+ 2 - 2
src/client/components/messages/ToolTrace.vue

@@ -4,7 +4,7 @@ import type { UiToolCall } from './types'
 
 const props = withDefaults(defineProps<{ tool: UiToolCall; expanded?: boolean }>(), { expanded: false })
 const open = ref(props.expanded || props.tool.status === 'error')
-const statusLabel = computed(() => ({ running: '运行中', success: '完成', error: '失败', pending: '等待' })[props.tool.status])
+const statusLabel = computed(() => ({ running: '运行中', success: '完成', error: '失败', pending: '等待', interrupted: '已结束,结果未确认' })[props.tool.status])
 function detail(value: unknown): string {
   if (value === undefined || value === null || value === '') return ''
   if (typeof value === 'string') return value
@@ -19,7 +19,7 @@ watch(() => props.expanded, value => { if (value) open.value = true })
   <div class="tool-trace" :class="`tool-trace--${tool.status}`">
     <button type="button" @click="open = !open">
       <strong>{{ tool.name }}</strong>
-      <small v-if="tool.status === 'error'">{{ statusLabel }}</small>
+      <small v-if="tool.status === 'error' || tool.status === 'interrupted'">{{ statusLabel }}</small>
       <span class="tool-trace__caret" :class="{ open }">›</span>
     </button>
     <div v-if="open && (inputDetail || outputDetail)" class="tool-trace__details">

+ 1 - 1
src/client/components/messages/types.ts

@@ -1,7 +1,7 @@
 export type UiToolCall = {
   id: string
   name: string
-  status: 'running' | 'success' | 'error' | 'pending'
+  status: 'running' | 'success' | 'error' | 'pending' | 'interrupted'
   input?: unknown
   output?: unknown
   durationMs?: number

+ 22 - 8
src/client/components/workspace/viewModels.ts

@@ -1,4 +1,6 @@
 import { visibleMessageText, messageReasoningText } from '@shared/messageFiles'
+import { toolResultFailed } from '@shared/chatTools'
+import { serverFilePath, serverFileUrl } from '@shared/serverFiles'
 import type {
   ApprovalRequest,
   ChatMessage,
@@ -116,7 +118,7 @@ export function chatMessageToUi(message: ChatMessage, agentNameFor?: (profile?:
     id: attachment.id,
     name: attachment.name,
     kind: attachment.kind === 'image' ? 'image' : 'file',
-    url: attachment.url,
+    url: serverFileUrl(attachment.url || attachment.path || '', message.profile) || attachment.url || attachment.path,
     size: attachment.size,
   }))
   return {
@@ -129,7 +131,8 @@ export function chatMessageToUi(message: ChatMessage, agentNameFor?: (profile?:
     status: message.isStreaming ? 'streaming' : message.stage,
     error: message.error,
     attachments,
-    tools: message.toolCalls?.map(toolToUi),
+    tools: message.toolCalls?.filter(tool => tool.id).map(tool => toolToUi(!message.isStreaming && ['pending', 'running'].includes(tool.status)
+      ? { ...tool, status: 'interrupted' } : tool)),
     profile: message.profile,
   }
 }
@@ -150,7 +153,7 @@ export function chatMessagesToUi(messages: ChatMessage[], agentNameFor?: (profil
       const patch: UiToolCall = {
         id,
         name: message.toolName || tools[index]?.name || '工具',
-        status: message.error ? 'error' : 'success',
+        status: toolResultFailed(toolResult(message), message.error) ? 'error' : 'success',
         input: tools[index]?.input,
         output: message.error || toolResult(message),
       }
@@ -181,7 +184,17 @@ export function chatMessagesToUi(messages: ChatMessage[], agentNameFor?: (profil
     result.push(ui)
     if (message.role === 'assistant') {
       lastAssistant = ui
-      for (const tool of ui.tools ?? []) toolOwners.set(tool.id, ui)
+      ui.tools = ui.tools?.map(tool => {
+        const previous = toolOwners.get(tool.id)
+        const known = previous?.tools?.find(candidate => candidate.id === tool.id)
+        if (previous && known && !/^tool-\d+$/.test(tool.id)) {
+          previous.tools = previous.tools?.filter(candidate => candidate.id !== tool.id)
+          tool = { ...known, ...tool, input: tool.input ?? known.input, output: tool.output ?? known.output,
+            status: ['success', 'error'].includes(known.status) && !['success', 'error'].includes(tool.status) ? known.status : tool.status }
+        }
+        toolOwners.set(tool.id, ui)
+        return tool
+      })
     }
   }
   return result
@@ -271,10 +284,11 @@ export function groupMessageToUi(message: GroupMessage, agents: GroupAgent[] = [
 export function rewriteRemoteNodeFiles(content: string, nodeId: string, streaming = false): string {
   if (!/^[0-9a-f-]{36}$/i.test(nodeId)) return content
   return normalizeAssistantMediaMarkdown(content, streaming).replace(
-    /(!?\[[^\]]*\]\()<?(?:file:\/\/)?(\/(?:Users|private|var|tmp)\/[^)>\n]+)>?(\))/g,
-    (_match, prefix: string, path: string, suffix: string) => (
-      `${prefix}/api/app/groups/nodes/${encodeURIComponent(nodeId)}/files?path=${encodeURIComponent(path)}${suffix}`
-    ),
+    /(!?\[[^\]]*\]\()<?(?:file:\/\/|sandbox:)?(\/[^)>\n]+)>?(\))/g,
+    (match, prefix: string, raw: string, suffix: string) => {
+      const path = serverFilePath(raw)
+      return path && serverFileUrl(raw) ? `${prefix}/api/app/groups/nodes/${encodeURIComponent(nodeId)}/files?path=${encodeURIComponent(path)}${suffix}` : match
+    },
   )
 }
 

+ 5 - 8
src/client/stores/chat.ts

@@ -12,7 +12,8 @@ import { ApiError } from '@/api/client'
 import { encodeAttachment } from '@/utils/attachments'
 import { ScopedCache } from '@/utils/cache'
 import { createId, routeKey } from '@/utils/id'
-import { applyChatEvent, mergeChatMessages } from '@/utils/messageReducer'
+import { applyChatEvent, mergeChatMessages, settleChatMessages } from '@/utils/messageReducer'
+import { reconcileChatHistory } from '@/utils/chatHistory'
 import { bool, normalizeChatMessage, number, record, string, values } from '@/utils/normalize'
 import { appendSessionPage, pinnedSessionsFirst } from '@/utils/sessionOrder'
 import { modelForSession, modelSelectionFromSessionInfo } from '@/utils/sessionModel'
@@ -503,7 +504,7 @@ export const useChatStore = defineStore('chat', () => {
         throw new Error('正在回复,请结束后再刷新历史')
       }
       const retained = forceRefresh ? state.messages.filter(message => message.role === 'user' && message.stage !== 'settled') : state.messages
-      state.messages = mergeChatMessages(retained, page.messages, 'snapshot')
+      state.messages = reconcileChatHistory(retained, page.messages, state.isStreaming)
       state.messageTotal = page.total
       state.loadedMessageCount = page.returned
       state.hasMoreBefore = page.hasMore
@@ -656,9 +657,7 @@ export const useChatStore = defineStore('chat', () => {
     }
     if (!migrated.isStreaming) {
       migrated.liveStatus = undefined
-      migrated.messages = migrated.messages.map(message => message.isStreaming
-        ? { ...message, isStreaming: false, stage: message.stage === 'streaming' ? 'settled' : message.stage }
-        : message)
+      migrated.messages = settleChatMessages(migrated.messages)
     }
     migrated.pendingApproval = undefined
     migrated.pendingClarification = undefined
@@ -839,9 +838,7 @@ export const useChatStore = defineStore('chat', () => {
     await socket.request('session.interrupt', { session_id: current.runtimeSessionId! }, 15_000)
     current.isStreaming = false
     current.isQueued = false
-    current.messages = current.messages.map(message => message.role === 'assistant' && message.isStreaming
-      ? { ...message, stage: message.stage === 'streaming' ? 'settled' : message.stage, isStreaming: false }
-      : message)
+    current.messages = settleChatMessages(current.messages)
     clearInflightMarker(current.route.profile, current.route.sessionId)
   }
 

+ 40 - 0
src/client/utils/chatHistory.ts

@@ -0,0 +1,40 @@
+import type { ChatMessage } from '@shared/types'
+import { mergeChatMessages } from './messageReducer'
+
+const projectedID = /^(?:stream:|resume:|event:|history-)/
+
+/** Replace completed live projections only inside an identified history turn. */
+export function reconcileChatHistory(existing: ChatMessage[], history: ChatMessage[], running: boolean): ChatMessage[] {
+  const aliases = new Map<string, string>()
+  for (const message of history) if (message.role === 'user') {
+    aliases.set(message.id, message.id)
+    if (message.clientMessageId) aliases.set(message.clientMessageId, message.id)
+  }
+  const userKey = (message: ChatMessage) => aliases.get(message.clientMessageId || '') ?? aliases.get(message.id) ?? message.id
+  // A queued user message can appear after the currently executing assistant.
+  // Protect the owner of that live segment, rather than the newest queue entry.
+  let liveIndex = -1
+  for (let index = existing.length - 1; index >= 0; index -= 1) {
+    if (existing[index]!.role === 'assistant' && existing[index]!.isStreaming) { liveIndex = index; break }
+  }
+  const activeUser = [...existing.slice(0, liveIndex < 0 ? undefined : liveIndex)].reverse().find(message => message.role === 'user')
+  const active = running && activeUser ? userKey(activeUser) : ''
+  const canonical = new Map(history.filter(message => message.serverMessageId && !projectedID.test(message.id)).map(message => [message.id, message]))
+  const completedTurns = new Set<string>()
+  let turn = ''
+  for (const message of history) {
+    if (message.role === 'user') turn = userKey(message)
+    if (turn && (!running || active) && turn !== active && canonical.has(message.id) && message.role === 'assistant'
+      && !message.isStreaming && message.content.trim() && !message.toolCalls?.length) completedTurns.add(turn)
+  }
+  turn = ''
+  const retained = existing.flatMap(message => {
+    if (message.role === 'user') turn = userKey(message)
+    if (message.role !== 'assistant') return [message]
+    const persisted = canonical.get(message.id)
+    if (persisted && (!message.isStreaming || completedTurns.has(turn))) return [persisted]
+    if (completedTurns.has(turn) && projectedID.test(message.id)) return []
+    return [message]
+  })
+  return mergeChatMessages(retained, history, 'snapshot')
+}

+ 2 - 1
src/client/utils/mediaMarkdown.ts

@@ -72,7 +72,8 @@ function asMarkdown(path: string): string {
   const fileName = path.split('/').filter(Boolean).at(-1) || '文件'
   const extension = fileName.split('.').at(-1)?.toLocaleLowerCase() || ''
   const label = escapeLabel(fileName)
-  return IMAGE_EXTENSIONS.has(extension) ? `![${label}](${path})` : `[${label}](${path})`
+  const destination = path.split('/').map(part => encodeURIComponent(part).replace(/\(/g, '%28').replace(/\)/g, '%29')).join('/')
+  return IMAGE_EXTENSIONS.has(extension) ? `![${label}](${destination})` : `[${label}](${destination})`
 }
 
 function transformLine(line: string, streaming: boolean, terminated: boolean): string {

+ 22 - 7
src/client/utils/messageReducer.ts

@@ -2,6 +2,7 @@ import type {
   ApprovalRequest, ChatMessage, ChatRouteState, ChatUsage, ClarificationRequest, JsonValue, RpcEventFrame, ToolCall,
 } from '@shared/types'
 import { createId } from './id'
+import { toolStatus } from '@shared/chatTools'
 import { bool, number, record, string } from './normalize'
 
 type MergePosition = 'append' | 'prepend' | 'snapshot'
@@ -104,9 +105,10 @@ function lastStreamingAssistant(messages: ChatMessage[], afterLatestUser = false
     .find(message => message.role === 'assistant' && message.isStreaming)
 }
 
-function settleSupersededStreams(messages: ChatMessage[], current?: ChatMessage): ChatMessage[] {
-  return messages.map(message => message.role === 'assistant' && message.isStreaming && message !== current
-    ? { ...message, stage: message.stage === 'streaming' ? 'settled' : message.stage, isStreaming: false }
+export function settleChatMessages(messages: ChatMessage[], current?: ChatMessage): ChatMessage[] {
+  return messages.map(message => message.role === 'assistant' && message !== current
+    ? { ...message, stage: message.stage === 'streaming' ? 'settled' : message.stage, isStreaming: false,
+      toolCalls: message.toolCalls?.map(tool => ['running', 'pending'].includes(tool.status) ? { ...tool, status: 'interrupted' } : tool) }
     : message)
 }
 
@@ -237,7 +239,7 @@ function updateStreamingMessage(
     toolCalls: existing?.toolCalls,
     raw: payload as JsonValue,
   }
-  const messages = mode === 'start' ? settleSupersededStreams(state.messages, current) : state.messages
+  const messages = mode === 'start' ? settleChatMessages(state.messages, current) : state.messages
   return mergeChatMessages(messages, [message])
 }
 
@@ -288,6 +290,7 @@ export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['para
       const terminalStatus = string(payload.status).trim().toLowerCase()
       const failed = terminalStatus === 'error' || terminalStatus === 'failed' || Boolean(payload.error)
       next.messages = updateStreamingMessage(state, payload, failed ? 'failed' : 'complete')
+      next.messages = settleChatMessages(next.messages)
       next.isStreaming = false
       next.isQueued = false
       next.liveStatus = undefined
@@ -298,6 +301,7 @@ export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['para
     case 'error':
     case 'run.failed':
       next.messages = updateStreamingMessage(state, payload, 'failed')
+      next.messages = settleChatMessages(next.messages)
       next.isStreaming = false
       next.isQueued = false
       next.error = string(payload.error ?? payload.message, '运行失败')
@@ -318,22 +322,33 @@ export function applyChatEvent(state: ChatRouteState, event: RpcEventFrame['para
     case 'tool.progress':
     case 'tool.started':
     case 'tool.generating': {
+      const id = string(payload.tool_call_id ?? payload.toolCallId ?? payload.tool_id ?? payload.id)
+      if (!id) {
+        next.liveStatus = `正在准备 ${string(payload.name ?? payload.tool_name, '工具')}`
+        break
+      }
       const current = lastStreamingAssistant(next.messages)
       if (!current) break
       const tool = toolFromEvent(payload, 'running')
       const tools = [...(current.toolCalls ?? [])]
       const index = tools.findIndex(item => item.id === tool.id)
-      if (index >= 0) tools[index] = { ...tools[index], ...tool }
+      if (index >= 0) tools[index] = { ...tools[index], ...tool,
+        result: tool.result ?? tools[index].result,
+        status: ['completed', 'failed'].includes(tools[index].status) ? tools[index].status : tool.status }
       else tools.push(tool)
       next.messages = mergeChatMessages(next.messages, [{ ...current, toolCalls: tools }])
+      next.liveStatus = undefined
       break
     }
     case 'tool.complete':
     case 'tool.completed':
     case 'tool.failed': {
-      const current = lastStreamingAssistant(next.messages) ?? [...next.messages].reverse().find(message => message.role === 'assistant')
+      const id = string(payload.tool_call_id ?? payload.toolCallId ?? payload.tool_id ?? payload.id)
+      if (!id) break
+      const current = [...next.messages].reverse().find(message => message.role === 'assistant' && message.toolCalls?.some(tool => tool.id === id))
+        ?? lastStreamingAssistant(next.messages)
       if (!current) break
-      const tool = toolFromEvent(payload, event.type === 'tool.failed' || payload.error ? 'failed' : 'completed')
+      const tool = toolFromEvent(payload, toolStatus(event.type, payload.result ?? payload.output, payload.error))
       const tools = [...(current.toolCalls ?? [])]
       const index = tools.findIndex(item => item.id === tool.id)
       if (index >= 0) tools[index] = { ...tools[index], ...tool }

+ 4 - 2
src/client/utils/normalize.ts

@@ -1,3 +1,4 @@
+import { toolStatus } from '@shared/chatTools'
 import type {
   ChatAttachment,
   ChatMessage,
@@ -150,7 +151,7 @@ function normalizeToolCall(value: unknown, index: number): ToolCall {
   return {
     id: string(pick(source, 'id', 'tool_call_id', 'toolCallId', 'tool_id'), `tool-${index}`),
     name: string(pick(source, 'name', 'tool_name', 'toolName') ?? functionCall.name, '工具'),
-    status: error ? 'failed' : result !== undefined ? 'completed' : 'running',
+    status: toolStatus(source.completion_unknown ? 'interrupted' : source.status, result, source.error),
     arguments: (pick(source, 'arguments', 'args', 'context') ?? functionCall.arguments) as JsonValue | undefined,
     result,
     preview: string(pick(source, 'preview', 'summary', 'args_text')) || undefined,
@@ -323,7 +324,8 @@ export function normalizeChatMessage(value: unknown, sessionId: string, fallback
     reasoning: string(pick(source, 'reasoning', 'thinking', 'reasoning_content', 'reasoningContent')) || undefined,
     timestamp: number(pick(source, 'timestamp', 'created_at', 'createdAt', 'updated_at', 'updatedAt'), Date.now() / 1000),
     sequence: number(pick(source, 'seq', 'sequence')) || undefined,
-    stage: status === 'error' || source.error ? 'failed' : 'settled',
+    stage: ['error', 'failed'].includes(status) || source.error ? 'failed' : status === 'streaming' ? 'streaming' : 'settled',
+    isStreaming: status === 'streaming',
     error: string(source.error) || undefined,
     attachments: attachments.length ? attachments : undefined,
     toolCalls: values(typeof pick(source, 'tool_calls', 'toolCalls', 'tools') === 'string'

+ 15 - 3
src/server/chatEventProjection.ts

@@ -1,3 +1,4 @@
+import { toolStatus } from '../shared/chatTools.js'
 /** Shared durable representation of an ordinary Hermes turn. No client timers or UI state. */
 export type ChatRecord = Record<string, any>
 const text = (p: ChatRecord) => String(p.delta ?? p.text_delta ?? p.content_delta ?? p.text ?? '')
@@ -5,6 +6,9 @@ export function projectChatEvent(type: string, payload: ChatRecord, previous: Ch
   fallbackID: string, now: number): ChatRecord | undefined {
   const supported = /^(message\.(start|delta|interim|complete)|reasoning\.(delta|complete)|tool\.|run\.(completed|failed)|error$)/.test(type)
   if (!supported) return undefined
+  // Parameter-generation notifications have no invocation identity. They are
+  // progress text, not an additional tool call which can later complete.
+  if (type.startsWith('tool.') && !String(payload.tool_id ?? payload.tool_call_id ?? payload.id ?? '').trim()) return undefined
   const supplied = payload.message && typeof payload.message === 'object' ? payload.message : {}
   const id = String(supplied.id ?? payload.message_id ?? previous?.id ?? fallbackID)
   const message: ChatRecord = { role: 'assistant', content: '', reasoning: '', timestamp: now / 1000,
@@ -16,7 +20,8 @@ export function projectChatEvent(type: string, payload: ChatRecord, previous: Ch
     const tools: ChatRecord[] = Array.isArray(message.tool_calls) ? [...message.tool_calls] : []
     const toolID = String(payload.tool_id ?? payload.tool_call_id ?? payload.id ?? '')
     const index = tools.findIndex(t => t.id === toolID)
-    const value = { ...(index >= 0 ? tools[index] : {}), ...payload, id: toolID, status: type }
+    const value = { ...(index >= 0 ? tools[index] : {}), ...payload, id: toolID,
+      status: toolStatus(type, payload.result ?? payload.output, payload.error) }
     if (index >= 0) tools[index] = value; else tools.push(value)
     message.tool_calls = tools
   }
@@ -28,7 +33,14 @@ export function projectChatEvent(type: string, payload: ChatRecord, previous: Ch
     if(payload.error)message.error=payload.error
   } else if (type === 'error' || type === 'run.failed') {
     message.status = 'failed'; message.error = String(payload.error ?? payload.message ?? '运行失败')
-  } else if (type !== 'message.interim') message.status = 'streaming'
-  message.final_result = (type === 'message.complete' || type === 'run.completed') && message.status === 'complete'
+  } else if (type !== 'message.interim' && !(type.startsWith('tool.') && ['complete', 'failed'].includes(previous?.status))) message.status = 'streaming'
+  message.final_result = type.startsWith('tool.') && previous?.final_result === true
+    || (type === 'message.complete' || type === 'run.completed') && message.status === 'complete'
+  if (['message.complete', 'run.completed', 'run.failed', 'error'].includes(type)) {
+    message.tool_calls = (message.tool_calls ?? []).filter((tool: ChatRecord) => tool.id).map((tool: ChatRecord) => ({
+      ...tool, status: toolStatus(tool.status, tool.result, tool.error) === 'running' ? 'completed' : toolStatus(tool.status, tool.result, tool.error),
+      ...(toolStatus(tool.status, tool.result, tool.error) === 'running' ? { completion_unknown: true } : {}),
+    }))
+  }
   return message
 }

+ 86 - 0
src/server/fileAccess.ts

@@ -0,0 +1,86 @@
+import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
+import { basename, dirname, join, posix } from 'node:path'
+import { randomUUID } from 'node:crypto'
+import { serverFilePath } from '../shared/serverFiles.js'
+import type { ServerConfig } from './config.js'
+import type { UpstreamServiceSession } from './localAuth.js'
+import { HttpError } from './errors.js'
+import { configuredWorkingDirectory } from './sessionWorkingDirectory.js'
+type FileHost = Pick<UpstreamServiceSession, 'request'>
+
+export interface FileAccessSettings { mode: 'all' | 'folders'; folders: string[] }
+const settingsPath = (home: string) => join(home, 'file-access.json')
+
+export function parseFileAccess(value: unknown): FileAccessSettings {
+  const input = value as Partial<FileAccessSettings> | null
+  if (!input || !['all', 'folders'].includes(String(input.mode)) || !Array.isArray(input.folders) || input.folders.length > 100)
+    throw new HttpError(400, '请选择全部目录或指定目录,目录最多 100 个', 'invalid_file_access')
+  const folders = input.folders.map(folder => {
+    const path = typeof folder === 'string' && folder.length <= 8192 ? serverFilePath(folder, false) : undefined
+    if (!path) throw new HttpError(400, '目录必须使用服务器上的绝对路径,不能包含 ..', 'invalid_file_access_folder')
+    return posix.normalize(path)
+  })
+  return { mode: input.mode!, folders: [...new Set(folders)] }
+}
+
+export function readFileAccess(home: string): FileAccessSettings {
+  if (!existsSync(settingsPath(home))) return { mode: 'folders', folders: [] }
+  try { return parseFileAccess(JSON.parse(readFileSync(settingsPath(home), 'utf8'))) }
+  catch { throw new HttpError(503, '文件访问设置无法读取,请由管理员重新保存', 'file_access_configuration_error') }
+}
+
+export function saveFileAccess(home: string, value: unknown): FileAccessSettings {
+  const settings = parseFileAccess(value)
+  mkdirSync(home, { recursive: true, mode: 0o700 })
+  const temporary = settingsPath(home) + '.' + randomUUID()
+  writeFileSync(temporary, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 })
+  renameSync(temporary, settingsPath(home))
+  return settings
+}
+
+export async function fileAccessWorkingDirectory(upstream: FileHost, profile: string): Promise<string> {
+  const configured = await configuredWorkingDirectory((path, options) => upstream.request(path, options), profile)
+  const absolute = configured ? serverFilePath(configured, false) : undefined
+  if (absolute) return absolute
+  // Let Hermes expand its own home/relative directory; never use the Web process cwd.
+  const response = await upstream.request('/api/fs/default-cwd', { search: new URLSearchParams({ profile }), cache: 'reload' })
+  let path: string | undefined
+  try { path = serverFilePath(JSON.parse(response.body.toString()).cwd ?? '', false) } catch { /* Refuse an unresolved cwd. */ }
+  if (response.status !== 200 || !path) throw new HttpError(503, '无法解析服务端 terminal.cwd,请在服务器配置有效工作目录', 'file_cwd_unavailable')
+  return path
+}
+
+export async function authorizeFileRead(config: ServerConfig, upstream: FileHost, raw: string, profile = 'default', directoryRequest = false): Promise<string> {
+  const path = serverFilePath(raw, false)
+  if (!path) throw new HttpError(400, '文件路径无效', 'invalid_file_path')
+  const policy = readFileAccess(config.home)
+  if (policy.mode === 'all') return path // Upstream authentication and filesystem restrictions still apply.
+  const cwd = await fileAccessWorkingDirectory(upstream, profile)
+  const allowed = [cwd, ...policy.folders]
+  const contains = (file: string, roots: string[]) => roots.some(root => file === root || file.startsWith(root.replace(/\/$/, '') + '/'))
+  // Even a loopback gateway can be in a container. Canonical paths must come
+  // from Hermes, never from the Web process's filesystem or working directory.
+  const directories = new Map<string, Promise<{path: string; entries: Array<{name: string; path: string}>}>>()
+  const directory = (folder: string) => {
+    if (!directories.has(folder)) directories.set(folder, (async () => {
+      const response = await upstream.request('/api/files', { search: new URLSearchParams({ path: folder, profile }), cache: 'reload' })
+      if (response.status !== 200) throw new HttpError(response.status === 404 ? 404 : 403, '无法核对服务器文件目录,请检查目录权限或文件是否存在', 'remote_file_directory_unverified')
+      try {
+        const value = JSON.parse(response.body.toString())
+        if (typeof value.path !== 'string' || !Array.isArray(value.entries)) throw new Error()
+        return value
+      } catch { throw new HttpError(502, '服务器文件目录响应无效', 'remote_file_directory_unverified') }
+    })())
+    return directories.get(folder)!
+  }
+  const roots = (await Promise.all(allowed.map(async root => {
+    try { return serverFilePath((await directory(root)).path, false) } catch { return undefined }
+  }))).filter((value): value is string => Boolean(value))
+  if (!contains(path, [...allowed, ...roots])) throw new HttpError(403, '文件不在工作目录或额外授权目录内,请检查服务端「设置 → 文件访问」', 'file_directory_not_allowed')
+  const parent = directoryRequest ? undefined : await directory(dirname(path))
+  const selected = parent?.entries.find(entry => entry.name === basename(path))
+  if (!directoryRequest && !selected) throw new HttpError(404, '服务器文件不存在、已被移动或不可读取', 'file_not_found')
+  const canonical = serverFilePath(directoryRequest ? (await directory(path)).path : selected!.path, false)
+  if (!canonical || !contains(canonical, roots)) throw new HttpError(403, '文件不在工作目录或额外授权目录内,请检查服务端「设置 → 文件访问」', 'file_directory_not_allowed')
+  return canonical
+}

+ 1 - 0
src/server/hermesBotRelay.ts

@@ -63,6 +63,7 @@ export function hermesBotRelay(nodes: WorkspaceNodes, auth: LocalAuthStore, csrf
       response = await target.client.fetchImpl(new URL(`${target.url.href.replace(/\/$/, '')}${match[2] ? '/api/hermes-bot' : ''}${path}${ctx.search}`), {
         method: ctx.method, redirect: 'error', signal: controller.signal,
         headers: { Authorization: `Bearer ${target.pairedToken}`, 'Content-Type': 'application/json',
+          'X-Hermes-Profile': ctx.get('x-hermes-profile'),
           'Last-Event-ID': ctx.get('last-event-id'), 'Idempotency-Key': ctx.get('idempotency-key') },
         ...(body ? { body: new Uint8Array(body) } : {}),
       })

+ 56 - 7
src/server/routes.ts

@@ -1,4 +1,5 @@
 import { readServerIdentity, updateServerIdentity } from './serverIdentity.js'
+import { authorizeFileRead, fileAccessWorkingDirectory, readFileAccess, saveFileAccess } from './fileAccess.js'
 import type { ServerIdentity } from '../shared/serverIdentity.js'
 import type Koa from 'koa'
 import type { WorkspaceStore } from './workspaceStore.js'
@@ -1064,7 +1065,7 @@ function sendLocalMedia(ctx: Koa.Context, path: string, contentType?: string, di
   ctx.body = createReadStream(path, { start, end: boundedEnd })
 }
 
-function prepareFilePreview(ctx: Koa.Context, name: string): void {
+function prepareFilePreview(ctx: Koa.Context, name: string, preserveDisposition = false): void {
   const type = ctx.response.get('content-type').split(';', 1)[0]?.trim().toLowerCase()
   const activeContent = type === 'text/html'
     || type === 'application/xhtml+xml'
@@ -1073,7 +1074,7 @@ function prepareFilePreview(ctx: Koa.Context, name: string): void {
   if (activeContent) {
     ctx.type = 'application/octet-stream'
     ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`)
-  } else {
+  } else if (!preserveDisposition) {
     ctx.remove('Content-Disposition')
   }
 }
@@ -1098,11 +1099,12 @@ function hermesMediaPath(ctx: Koa.Context, rootDirectory = 'cache'): string {
 
 async function proxyHermesMedia(ctx: Koa.Context, dependencies: RouteDependencies, rootDirectory = 'cache'): Promise<void> {
   const user = dependencies.auth.require(ctx)
-  const path = hermesMediaPath(ctx, rootDirectory)
+  const path = await authorizeFileRead(dependencies.config, dependencies.upstreamSession, hermesMediaPath(ctx, rootDirectory), ctx.params.profile || String(ctx.query.profile || ctx.get('x-hermes-profile') || 'default'))
   const cached = dependencies.chatCache?.store.attachment(user.id, path)
   if (cached) {
     ctx.set('X-Yaoyao-Data-Source', 'local')
     sendLocalMedia(ctx, cached.localPath, cached.mimeType, basename(path))
+    prepareFilePreview(ctx, basename(path), true)
     return
   }
   // Generated media belongs to Hermes, which may run on a different machine
@@ -1142,6 +1144,38 @@ function pairedLocalMediaPath(config: ServerConfig, rawPath: string): string {
 
 export function createApiRouter(dependencies: RouteDependencies): Router {
   const router = new Router()
+  const fileReadRequest = (ctx: Koa.Context, path: string) => ['GET', 'HEAD'].includes(ctx.method)
+    && /^\/api\/(?:files(?:\/[^/]+)?|fs\/[^/]+|media|hermes\/download)$/.test(path)
+  const authorizeFileQuery = async (ctx: Koa.Context, route: string, host: Pick<UpstreamServiceSession, 'request'> = dependencies.upstreamSession) => {
+    const profile = String(ctx.query.profile || ctx.get('x-hermes-profile') || 'default')
+    const directoryRequest = ['/api/files', '/api/fs/list', '/api/fs/git-root'].includes(route)
+    const rawPath = ctx.query.path || (directoryRequest ? await fileAccessWorkingDirectory(host, profile) : undefined)
+    if (typeof rawPath === 'string') {
+      const path = await authorizeFileRead(dependencies.config, host, rawPath, profile, directoryRequest)
+      const search = new URLSearchParams(ctx.querystring)
+      search.set('path', path)
+      search.set('profile', profile)
+      ctx.querystring = search.toString()
+    }
+  }
+  router.use(async (ctx, next) => {
+    if (fileReadRequest(ctx, ctx.path)) {
+      dependencies.auth.require(ctx)
+      await authorizeFileQuery(ctx, ctx.path)
+    }
+    await next()
+  })
+  router.get('/api/app/settings/file-access', async ctx => {
+    dependencies.auth.require(ctx)
+    const settings = readFileAccess(dependencies.config.home)
+    const profile = String(ctx.query.profile || ctx.get('x-hermes-profile') || 'default')
+    try { json(ctx, 200, { ...settings, profile, workingDirectory: await fileAccessWorkingDirectory(dependencies.upstreamSession, profile) }) }
+    catch (error) { json(ctx, 200, { ...settings, profile, workingDirectory: null, cwdError: error instanceof Error ? error.message : '无法读取工作目录' }) }
+  })
+  router.put('/api/app/settings/file-access', ctx => {
+    dependencies.auth.requireAdmin(ctx)
+    json(ctx, 200, saveFileAccess(dependencies.config.home, body(ctx)))
+  })
 
   registerKanbanRoutes(router, dependencies, '/api/app/kanban')
   registerKanbanRoutes(router, dependencies, '/api/kanban/v1')
@@ -1542,6 +1576,9 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       throw new HttpError(414, 'Paired node query is too long', 'node_query_too_long')
     }
     const jar = new CookieJar(cookieHeader)
+    if (fileReadRequest(ctx, path)) await authorizeFileQuery(ctx, path, {
+      request: (path, options) => dependencies.upstream.request(path, jar, options),
+    })
     const response = await dependencies.upstream.withReadScope(`device:${ctx.params.deviceID}`, ctx.get('x-yaoyao-cache') === 'bypass', () => dependencies.upstream.request(path, jar, {
       method: ctx.method,
       search: new URLSearchParams(ctx.querystring),
@@ -1574,7 +1611,9 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
-      sendLocalMedia(ctx, localMediaPath(dependencies.config.mediaRoot, filePath))
+      const path = await authorizeFileRead(dependencies.config, dependencies.upstreamSession, localMediaPath(dependencies.config.mediaRoot, filePath))
+      sendLocalMedia(ctx, path)
+      prepareFilePreview(ctx, basename(path), true)
     })
   })
   router.get('/Users/:owner/.hermes/attachments/*filePath', async (ctx) => {
@@ -1587,7 +1626,9 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
-      sendLocalMedia(ctx, localMediaPath(dependencies.config.attachmentsRoot, filePath))
+      const path = await authorizeFileRead(dependencies.config, dependencies.upstreamSession, localMediaPath(dependencies.config.attachmentsRoot, filePath))
+      sendLocalMedia(ctx, path)
+      prepareFilePreview(ctx, basename(path), true)
     })
   })
   router.get('/Users/:owner/.hermes/images/*filePath', async (ctx) => {
@@ -1600,7 +1641,9 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
-      sendLocalMedia(ctx, localMediaPath(dependencies.config.imagesRoot, filePath))
+      const path = await authorizeFileRead(dependencies.config, dependencies.upstreamSession, localMediaPath(dependencies.config.imagesRoot, filePath))
+      sendLocalMedia(ctx, path)
+      prepareFilePreview(ctx, basename(path), true)
     })
   })
   router.get('/attachments/*filePath', async (ctx) => {
@@ -1610,7 +1653,9 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await withJar(ctx, async (jar) => {
       await requireGatewayAuthentication(ctx, dependencies, jar)
       const filePath = Array.isArray(ctx.params.filePath) ? ctx.params.filePath.join('/') : ctx.params.filePath
-      sendLocalMedia(ctx, localMediaPath(dependencies.config.attachmentsRoot, filePath))
+      const path = await authorizeFileRead(dependencies.config, dependencies.upstreamSession, localMediaPath(dependencies.config.attachmentsRoot, filePath), String(ctx.query.profile || ctx.get('x-hermes-profile') || 'default'))
+      sendLocalMedia(ctx, path)
+      prepareFilePreview(ctx, basename(path), true)
     })
   })
   router.get('/readyz', async (ctx) => {
@@ -2213,6 +2258,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     if (cached) {
       ctx.set('X-Yaoyao-Data-Source', 'local')
       sendLocalMedia(ctx, cached.localPath, cached.mimeType, basename(path))
+      prepareFilePreview(ctx, basename(path), true)
       return
     }
     const response = await dependencies.upstreamSession.request('/api/files/download', {
@@ -2227,6 +2273,9 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       void dependencies.chatCache?.cacheAttachment(user.id, path)
     }
     sendUpstreamResponse(ctx, response, dependencies.upstreamSession.jar)
+    ctx.set('Cache-Control', 'private, no-store')
+    ctx.set('X-Content-Type-Options', 'nosniff')
+    if (response.status >= 200 && response.status < 300 && ctx.query.preview === '1') prepareFilePreview(ctx, basename(path))
   })
 
   router.all('/api/*gatewayPath', async (ctx) => {

+ 22 - 0
src/shared/chatTools.ts

@@ -0,0 +1,22 @@
+/** Tool events and persisted tool-result rows share the same outcome rules. */
+export function toolResultFailed(value: unknown, error?: unknown): boolean {
+  if (error && error !== 'false') return true
+  if (typeof value === 'string') {
+    try { value = JSON.parse(value) } catch { return false }
+  }
+  if (!value || typeof value !== 'object' || Array.isArray(value)) return false
+  const result = value as Record<string, unknown>
+  return Boolean(result.error && result.error !== 'false') || result.is_error === true
+    || result.ok === false || result.success === false
+    || ['failed', 'error', 'cancelled', 'canceled'].includes(String(result.status))
+    || (typeof result.exit_code === 'number' && result.exit_code !== 0)
+}
+
+export function toolStatus(status: unknown, result?: unknown, error?: unknown): 'running' | 'completed' | 'failed' | 'interrupted' {
+  if (toolResultFailed(result, error)) return 'failed'
+  const value = String(status ?? '').replace(/^tool\./, '')
+  if (['failed', 'error'].includes(value)) return 'failed'
+  if (['interrupted', 'cancelled', 'canceled', 'unknown'].includes(value)) return 'interrupted'
+  if (['complete', 'completed', 'success', 'done'].includes(value) || result !== undefined && result !== null) return 'completed'
+  return 'running'
+}

+ 17 - 0
src/shared/serverFiles.ts

@@ -0,0 +1,17 @@
+/** Normalize a server-side file reference without confusing it with an app URL. */
+export function serverFilePath(raw: string, decode = true): string | undefined {
+  let value = raw.trim().replace(/^sandbox:/i, '')
+  if (/^file:/i.test(value)) {
+    try { const url = new URL(value); if (url.hostname && url.hostname !== 'localhost') return; value = url.pathname; decode = true } catch { return }
+  }
+  if (decode) try { value = decodeURIComponent(value) } catch { return }
+  if (!value.startsWith('/') || value.startsWith('//') || /[\\\u0000-\u001f\u007f]/.test(value)) return
+  if (value.split('/').some(part => part === '..' || /%2e|%2f|%5c/i.test(part))) return
+  return value.replace(/\/{2,}/g, '/')
+}
+
+export function serverFileUrl(raw: string, profile?: string): string | undefined {
+  const path = serverFilePath(raw)
+  if (!path || !/^(?:file:|sandbox:)/i.test(raw) && /^\/(?:api|assets|icons|brand|attachments|uploads|media|chat|history|conversations|kanban)(?:\/|$)/.test(path)) return
+  return `/api/files/download?${new URLSearchParams({ path, preview: '1', ...(profile ? { profile } : {}) })}`
+}

+ 1 - 1
src/shared/types.ts

@@ -122,7 +122,7 @@ export interface ChatAttachment {
 export interface ToolCall {
   id: string
   name: string
-  status: 'pending' | 'running' | 'completed' | 'failed'
+  status: 'pending' | 'running' | 'completed' | 'failed' | 'interrupted'
   arguments?: JsonValue
   result?: JsonValue
   preview?: string

+ 80 - 0
tests/client/chatLifecycle.test.ts

@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest'
+import type { ChatMessage, ChatRouteState } from '@shared/types'
+import { applyChatEvent, settleChatMessages } from '@/utils/messageReducer'
+import { normalizeChatMessage } from '@/utils/normalize'
+import { reconcileChatHistory } from '@/utils/chatHistory'
+import { chatMessagesToUi } from '@/components/workspace/viewModels'
+import { buildMessageTimelineRows } from '@/utils/turnTrace'
+import { projectChatEvent } from '../../src/server/chatEventProjection'
+
+function state(): ChatRouteState { return { route: { sessionId: 's', profile: 'p' }, messages: [], isStreaming: false, isQueued: false,
+  historySynced: true, hasMoreBefore: false, loadedMessageCount: 0, messageTotal: 0, isLoadingHistory: false, generation: 1 } }
+const user = (id: string): ChatMessage => ({ id, sessionId: 's', role: 'user', content: 'pwd', timestamp: 1, stage: 'settled' })
+
+describe('ordinary tool lifecycle and authoritative history', () => {
+  it('counts one invocation across generating/start/complete in both realtime and durable projection', () => {
+    let current = state(), durable: Record<string, any> | undefined
+    for (const [type, payload] of [
+      ['message.start', {}], ['tool.generating', { name: 'terminal' }],
+      ['tool.start', { tool_id: 'call-1', name: 'terminal', args: { command: 'pwd' } }],
+      ['tool.complete', { tool_id: 'call-1', name: 'terminal', result: { output: '/work', exit_code: 0 } }],
+      ['message.complete', { text: '/work', status: 'complete' }],
+    ] as Array<[string, any]>) {
+      current = applyChatEvent(current, { type, payload })
+      durable = projectChatEvent(type, payload, durable, 'event:1', 1000) ?? durable
+    }
+    expect(current.messages.flatMap(message => message.toolCalls ?? [])).toHaveLength(1)
+    expect(durable!.tool_calls).toHaveLength(1)
+    expect(durable!.tool_calls[0].status).toBe('completed')
+    expect(buildMessageTimelineRows(chatMessagesToUi(current.messages)).filter(row => row.kind === 'trace').map(row => row.status)).toEqual(['success'])
+  })
+
+  it.each(['message.complete', 'error'])('settles every unfinished tool on %s without inventing success', type => {
+    let current = state()
+    for (const [event, payload] of [['message.start', {}], ['tool.start', { tool_id: 'call', name: 'terminal' }], ['message.interim', { text: '稍等' }], [type, { text: '结束' }]] as Array<[string, any]>)
+      current = applyChatEvent(current, { type: event, payload })
+    expect(current.messages.some(message => message.isStreaming)).toBe(false)
+    expect(current.messages.flatMap(message => message.toolCalls ?? []).map(tool => tool.status)).toEqual(['interrupted'])
+    expect(settleChatMessages(current.messages)).toEqual(current.messages)
+  })
+
+  it('recognizes persisted event status aliases and nonzero tool exit codes', () => {
+    const message = normalizeChatMessage({ id: '1', role: 'assistant', tool_calls: [
+      { id: 'empty', status: 'tool.complete' }, { id: 'failed', status: 'tool.complete', result: { exit_code: 1, error: null } },
+    ] }, 's')
+    expect(message.toolCalls?.map(tool => tool.status)).toEqual(['completed', 'failed'])
+    const rows = chatMessagesToUi([message, normalizeChatMessage({ id: '2', role: 'tool', tool_call_id: 'empty', content: '{"exit_code":1}' }, 's')])
+    expect(rows[0]!.tools?.every(tool => tool.status === 'error')).toBe(true)
+  })
+
+  it('replaces stale live projections by identified completed turns while retaining real repeated sends and active tail', () => {
+    const first = user('1'), second = user('2')
+    const stale: ChatMessage = { id: 'stream:p:s:1:x', sessionId: 's', role: 'assistant', content: '/work', timestamp: 2, stage: 'streaming', isStreaming: true }
+    const final = normalizeChatMessage({ id: '10', role: 'assistant', content: '/work', timestamp: 2 }, 's')
+    const active = { ...stale, id: 'stream:p:s:2:y', timestamp: 4 }
+    const result = reconcileChatHistory([first, stale, second, active], [first, final], true)
+    expect(result.map(message => message.id)).toEqual(['1', '2', '10', active.id])
+    expect(result.at(-1)?.isStreaming).toBe(true)
+  })
+
+  it('restores a canonical tool row whose text was overwritten by a live final answer', () => {
+    const call = normalizeChatMessage({ id: '1018', role: 'assistant', content: '', tool_calls: [{ id: 'call-2', function: { name: 'terminal' } }] }, 's')
+    const final = normalizeChatMessage({ id: '1020', role: 'assistant', content: '给你图片', timestamp: 3 }, 's')
+    const result = reconcileChatHistory([user('1'), { ...call, content: '给你图片' }], [user('1'), call, final], false)
+    expect(result.filter(message => message.content === '给你图片')).toHaveLength(1)
+    expect(result.find(message => message.id === '1018')?.content).toBe('')
+  })
+  it('keeps an active turn when a newer user message is queued after it', () => {
+    const live: ChatMessage = { id: 'stream:p:s:1:x', role: 'assistant', sessionId: 's', content: '当前输出', timestamp: 2, stage: 'streaming', isStreaming: true }
+    const interim = normalizeChatMessage({ id: '10', role: 'assistant', content: '之前的阶段性说明', timestamp: 1.5 }, 's')
+    expect(reconcileChatHistory([user('1'), live, { ...user('queue'), timestamp: 3 }], [user('1'), interim], true))
+      .toContainEqual(live)
+  })
+  it('counts a stable tool ID once when persisted history overlaps a live projection', () => {
+    const live = normalizeChatMessage({ id: 'stream:p:s:1:x', role: 'assistant', status: 'streaming', tool_calls: [{ id: 'call', name: 'terminal', result: 'done' }] }, 's')
+    const stored = normalizeChatMessage({ id: '10', role: 'assistant', tool_calls: [{ id: 'call', function: { name: 'terminal' } }] }, 's')
+    const tools = chatMessagesToUi([user('1'), live, stored]).flatMap(message => message.tools ?? [])
+    expect(tools).toHaveLength(1)
+    expect(tools[0]).toMatchObject({ id: 'call', status: 'success', output: 'done' })
+  })
+})

+ 42 - 0
tests/client/fileAccessPanel.test.ts

@@ -0,0 +1,42 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { flushPromises, mount } from '@vue/test-utils'
+import FileAccessPanel from '@/components/app/FileAccessPanel.vue'
+import MarkdownContent from '@/components/messages/MarkdownContent.vue'
+
+const api = vi.hoisted(() => ({ request: vi.fn() }))
+vi.mock('@/api/client', () => ({ apiRequest: api.request, apiUrl: (path: string, query: Record<string, string>) => `${path}?${new URLSearchParams(query)}` }))
+afterEach(() => { api.request.mockReset(); vi.unstubAllGlobals() })
+
+describe('server file permission interface', () => {
+  it('keeps cwd visible and saves the global switch to the server without local storage', async () => {
+    api.request.mockResolvedValue({ mode: 'folders', folders: [], workingDirectory: '/remote/work' })
+    const wrapper = mount(FileAccessPanel, { props: { profile: 'server' } })
+    await flushPromises()
+    expect(wrapper.text()).toContain('/remote/work')
+    expect((wrapper.get('input[type=checkbox]').element as HTMLInputElement).checked).toBe(false)
+    expect(api.request).toHaveBeenCalledWith('/api/app/settings/file-access?profile=server')
+    await wrapper.get('input[type=checkbox]').setValue(true)
+    expect(wrapper.get('textarea').attributes('disabled')).toBeDefined()
+    await wrapper.get('form').trigger('submit')
+    await flushPromises()
+    expect(api.request).toHaveBeenLastCalledWith('/api/app/settings/file-access', { method: 'PUT', body: { mode: 'all', folders: [] } })
+    expect(wrapper.text()).toContain('已保存')
+    wrapper.unmount()
+  })
+  it.each(['flat', 'nested'])('shows a %s server rejection next to a failed image and provides retry', async shape => {
+    const reason = '目录未获授权,请检查文件访问设置'
+    vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: shape === 'flat' ? reason : { message: reason } }), { status: 403 })))
+    const wrapper = mount(MarkdownContent, { attachTo: document.body, props: {
+      content: 'MEDIA: /tmp/hello.png', legacyMedia: true, fileCards: true, fileProfile: 'server',
+    } })
+    await wrapper.get('img').trigger('error')
+    await flushPromises()
+    expect(wrapper.get('[role=alert]').text()).toContain('目录未获授权')
+    const src = new URL(wrapper.get('img').attributes('src')!, 'http://localhost')
+    expect(src.searchParams.get('profile')).toBe('server')
+    expect(src.searchParams.get('path')).toBe('/tmp/hello.png')
+    await wrapper.get('[role=alert] button').trigger('click')
+    expect(wrapper.get('img').attributes('src')).toContain('_retry=')
+    wrapper.unmount()
+  })
+})

+ 7 - 3
tests/client/markdownContent.test.ts

@@ -57,18 +57,22 @@ describe('Markdown file cards', () => {
     expect(card.attributes('target')).toBeUndefined()
     expect(card.attributes('aria-label')).toBe('预览文件 报告.md')
     await card.trigger('click')
-    expect(wrapper.emitted('fileLink')).toEqual([['报告.md', encodeURI(path.replace(/^sandbox:/, ''))]])
+    const href = card.attributes('href')!
+    if (!path.startsWith('/api/')) expect(new URL(href, 'http://localhost').searchParams.get('path')).toBe(path.replace(/^sandbox:/, ''))
+    else expect(href).toBe(path)
+    expect(wrapper.emitted('fileLink')).toEqual([['报告.md', href]])
     wrapper.unmount()
   })
 
-  it('preserves ordinary links and keeps unknown protocols sanitized', () => {
+  it('preserves external links, routes server files through permissions and sanitizes unknown protocols', () => {
     const wrapper = mount(MarkdownContent, { props: { fileCards: true, content: [
       '[外部](https://example.com/Users/samien/.hermes/workspace/report.md)',
       '[普通](https://example.com/report.md)',
       '[配置](/Users/samien/.hermes/config.yaml)',
       '[未知](sandbox:/etc/passwd)',
     ].join('\n\n') } })
-    expect(wrapper.find('.file-link-card').exists()).toBe(false)
+    expect(wrapper.findAll('.file-link-card')).toHaveLength(1)
+    expect(new URL(wrapper.get('.file-link-card').attributes('href')!, 'http://localhost').searchParams.get('path')).toBe('/Users/samien/.hermes/config.yaml')
     expect(wrapper.find('a[href^="sandbox:"]').exists()).toBe(false)
     expect(wrapper.findAll('a[href]')).toHaveLength(3)
     wrapper.unmount()

+ 18 - 10
tests/client/mediaMarkdown.test.ts

@@ -1,23 +1,31 @@
 import { describe, expect, it } from 'vitest'
 import { normalizeAssistantMediaMarkdown } from '@/utils/mediaMarkdown'
+import MarkdownIt from 'markdown-it'
+
+function destinations(value: string): string[] {
+  return new MarkdownIt().parse(value, {}).flatMap(token => (token.children ?? []).flatMap(child => {
+    const path = child.type === 'image' ? child.attrGet('src') : child.type === 'link_open' ? child.attrGet('href') : null
+    return path ? [decodeURIComponent(path)] : []
+  }))
+}
 
 describe('historical assistant MEDIA compatibility', () => {
-  it('converts image and non-image absolute paths to standard Markdown', () => {
-    expect(normalizeAssistantMediaMarkdown('MEDIA:/Users/samien/图片 文件.jpg\nMEDIA:/tmp/报告.docx')).toBe(
-      '![图片 文件.jpg](/Users/samien/图片 文件.jpg)\n[报告.docx](/tmp/报告.docx)',
-    )
+  it('converts image and non-image paths to valid Markdown with round-trip filenames', () => {
+    const rendered = normalizeAssistantMediaMarkdown('MEDIA:/Users/samien/图片 文件.jpg\nMEDIA:/tmp/报告.docx')
+    expect(destinations(rendered)).toEqual(['/Users/samien/图片 文件.jpg', '/tmp/报告.docx'])
+    expect(rendered).toContain('![图片 文件.jpg]')
   })
 
   it('accepts quoted paths with Chinese, spaces, quotes, and backticks', () => {
-    expect(normalizeAssistantMediaMarkdown("MEDIA:' /not-a-path'\nMEDIA:\"/Users/瑶儿/带 空格 '引号'.png\"\nMEDIA:\"/tmp/含`反引号`.pdf\"\nMEDIA:`/tmp/反引号.pdf`")).toBe(
-      "MEDIA:' /not-a-path'\n![带 空格 '引号'.png](/Users/瑶儿/带 空格 '引号'.png)\n[含`反引号`.pdf](/tmp/含`反引号`.pdf)\n[反引号.pdf](/tmp/反引号.pdf)",
-    )
+    const paths = ["/Users/瑶儿/带 空格 '引号'.png", '/tmp/含`反引号`.pdf']
+    const rendered = normalizeAssistantMediaMarkdown(paths.map(path => `MEDIA:"${path}"`).join('\n'))
+    expect(destinations(rendered)).toEqual(paths)
   })
 
-  it('waits for an unquoted streaming path to reach a line boundary', () => {
+  it('waits for a streaming path boundary and accepts a closed quoted path', () => {
     expect(normalizeAssistantMediaMarkdown('MEDIA:/tmp/正在生成.png', true)).toBe('MEDIA:/tmp/正在生成.png')
-    expect(normalizeAssistantMediaMarkdown('MEDIA:/tmp/正在生成.png\n下一行', true)).toBe('![正在生成.png](/tmp/正在生成.png)\n下一行')
-    expect(normalizeAssistantMediaMarkdown('MEDIA:`/tmp/已完整.png`', true)).toBe('![已完整.png](/tmp/已完整.png)')
+    expect(destinations(normalizeAssistantMediaMarkdown('MEDIA:/tmp/正在生成.png\n下一行', true))).toEqual(['/tmp/正在生成.png'])
+    expect(destinations(normalizeAssistantMediaMarkdown('MEDIA:`/tmp/已完整.png`', true))).toEqual(['/tmp/已完整.png'])
   })
 
   it('does not transform code, headings, tables, URLs, or traversal paths', () => {

+ 6 - 0
tests/fixtures/e2e-web-server.mjs

@@ -43,6 +43,12 @@ const ownedSessions = [
   ...Array.from({ length: 101 }, (_, index) => [`session-page-${index + 1}`, 'yaoyao']),
 ]
 const { ChatCacheStore } = await import('../../dist-server/server/chatCache.js')
+// General media fixtures intentionally span several server directories.
+// Permission-specific QA leaves the production cwd-only default intact.
+if (process.env.YAOYAO_FILE_ACCESS_TEST_MODE !== 'cwd') {
+  const { saveFileAccess } = await import('../../dist-server/server/fileAccess.js')
+  saveFileAccess(testHome, { mode: 'all', folders: [] })
+}
 const registry = new ChatCacheStore(testHome, testUserID)
 for (const [sessionID, profile] of ownedSessions) {
   registry.recordCommand(testUserID, profile, sessionID, 'session.create', {

+ 17 - 1
tests/fixtures/fake-hermes.mjs

@@ -200,9 +200,16 @@ const server = createServer(async (request, response) => {
   if (url.pathname === '/auth/logout' && request.method === 'POST') return json(response, 200, { ok: true }, { 'Set-Cookie': 'fake_session=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax' })
   if (!authenticated(request)) return json(response, 401, { detail: 'Unauthorized' })
   if (url.pathname === '/api/config') return json(response, 200, { terminal: { cwd: '/tmp/hermes-fixture' } })
+  if (url.pathname === '/api/fs/default-cwd') return json(response, 200, { cwd: '/tmp/hermes-fixture' })
+  if (url.pathname === '/api/files') {
+    const path = url.searchParams.get('path') || '/tmp/hermes-fixture'
+    return json(response, 200, { path, entries: [...profileMediaPaths, ...userProfileMediaPaths, '/tmp/hello.png']
+      .filter(file => file.slice(0, file.lastIndexOf('/')) === path)
+      .map(file => ({ name: file.split('/').at(-1), path: file, is_directory: false })) })
+  }
   if (url.pathname === '/api/files/download' && request.method === 'GET') {
     const path = url.searchParams.get('path')
-    if (![...profileMediaPaths, ...userProfileMediaPaths].includes(path)) return json(response, 404, { detail: 'File not found' })
+    if (![...profileMediaPaths, ...userProfileMediaPaths, '/tmp/hello.png'].includes(path)) return json(response, 404, { detail: 'File not found' })
     response.writeHead(200, {
       'Content-Type': 'image/png',
       'Content-Length': profileMediaPng.length,
@@ -490,6 +497,15 @@ server.on('upgrade', (request, socket, head) => {
             jsonrpc: '2.0', method: 'event',
             params: { type, session_id: requestFrame.params.session_id, profile: 'yaoyao', ...(payload ? { payload } : {}) },
           }))
+          if (requestFrame.params.text === '验证工具和文件权限') {
+            const tool_id = `file-test-${Date.now()}`
+            emit('message.start', {})
+            emit('tool.generating', { name: 'terminal' })
+            emit('tool.start', { tool_id, name: 'terminal', args: { command: '创建测试图片' } })
+            emit('tool.complete', { tool_id, name: 'terminal', result: { output: '', exit_code: 0 } })
+            emit('message.complete', { text: '已完成。\n\nMEDIA: /tmp/hello.png', status: 'complete' })
+            return
+          }
           if (requestFrame.params.text === '验证流式分段') {
             emit('message.start')
             emit('message.delta', { text: '我先检查配置。' })

+ 2 - 0
tests/server/app.test.ts

@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest'
 import { createApplication as createUnauthenticatedApplication, type ApplicationRuntime } from '../../src/server/app.js'
 import type { ServerConfig } from '../../src/server/config.js'
 import { createAuthenticatedApplication as createApplication } from './authenticatedApplication.js'
+import { saveFileAccess } from '../../src/server/fileAccess.js'
 
 interface RecordedRequest {
   path: string
@@ -25,6 +26,7 @@ afterEach(() => {
 
 function makeConfig(upstream = 'http://127.0.0.1:9119'): ServerConfig {
   const home = mkdtempSync(join(tmpdir(), 'hermes-yaoyao-server-'))
+  saveFileAccess(home, { mode: 'all', folders: [] })
   const mediaRoot = join(home, 'media')
   const attachmentsRoot = join(home, 'attachments')
   const imagesRoot = join(home, 'images')

+ 2 - 0
tests/server/chatCacheRoutes.test.ts

@@ -6,6 +6,7 @@ import request from 'supertest'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
 import type { ServerConfig } from '../../src/server/config.js'
+import { saveFileAccess } from '../../src/server/fileAccess.js'
 
 const homes: string[] = []
 const runtimes: ApplicationRuntime[] = []
@@ -342,6 +343,7 @@ describe('source=web chat cache routes', () => {
 
   it('serves warm and restarted chat reads locally while history stays upstream-only', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-routes-')); homes.push(home)
+    saveFileAccess(home, { mode: 'all', folders: [] })
     const counts = { list: 0, detail: 0, messages: 0, history: 0, media: 0 }
     const mediaPath = '/Users/test/.hermes/profiles/default/images/result.png'
     const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {

+ 78 - 0
tests/server/fileAccess.test.ts

@@ -0,0 +1,78 @@
+// @vitest-environment node
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { authorizeFileRead, readFileAccess, saveFileAccess } from '../../src/server/fileAccess'
+import { serverFilePath, serverFileUrl } from '../../src/shared/serverFiles'
+import type { ServerConfig } from '../../src/server/config'
+import type { UpstreamServiceSession } from '../../src/server/localAuth'
+
+const homes: string[] = []
+afterEach(() => homes.splice(0).forEach(home => rmSync(home, { recursive: true, force: true })))
+function fixture() {
+  const home = mkdtempSync(join(tmpdir(), 'file-access-')); homes.push(home)
+  const cwd = { alpha: '/remote/work', beta: '/other/work' } as Record<string, string>
+  const request = vi.fn(async (path: string, options: {search: URLSearchParams}) => {
+    const folder = options.search.get('path') || '', profile = options.search.get('profile') || 'alpha'
+    const canonical = folder === '/tmp' ? '/private/tmp' : folder
+    const value = path === '/api/config' ? { terminal: { cwd: cwd[profile] } }
+      : { path: canonical, entries: [{ name: 'hello.png', path: canonical + '/hello.png' }, { name: 'escape.png', path: '/secrets/escape.png' }] }
+    return { status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify(value)) }
+  })
+  return { home, cwd, request, upstream: { request } as unknown as UpstreamServiceSession,
+    config: { home, upstream: new URL('http://127.0.0.1:9119') } as ServerConfig }
+}
+describe('server-owned file permissions', () => {
+  it('defaults to cwd only and keeps cwd authorized when extra folders are configured', async () => {
+    const f = fixture()
+    expect(readFileAccess(f.home)).toEqual({ mode: 'folders', folders: [] })
+    expect(await authorizeFileRead(f.config, f.upstream, '/remote/work/hello.png', 'alpha')).toBe('/remote/work/hello.png')
+    expect(await authorizeFileRead(f.config, f.upstream, '/remote/work', 'alpha', true)).toBe('/remote/work')
+    await expect(authorizeFileRead(f.config, f.upstream, '/tmp/hello.png', 'alpha')).rejects.toMatchObject({ status: 403 })
+    saveFileAccess(f.home, { mode: 'folders', folders: ['/tmp'] })
+    expect(await authorizeFileRead(f.config, f.upstream, '/remote/work/hello.png', 'alpha')).toBe('/remote/work/hello.png')
+    expect(await authorizeFileRead(f.config, f.upstream, '/tmp/hello.png', 'alpha')).toBe('/private/tmp/hello.png')
+  })
+  it('uses each profile cwd, follows changed server config, and never uses the Web process cwd', async () => {
+    const f = fixture()
+    await expect(authorizeFileRead(f.config, f.upstream, '/remote/work/hello.png', 'beta')).rejects.toMatchObject({ status: 403 })
+    f.cwd.alpha = '/new/work'
+    await expect(authorizeFileRead(f.config, f.upstream, '/remote/work/hello.png', 'alpha')).rejects.toMatchObject({ status: 403 })
+    expect(await authorizeFileRead(f.config, f.upstream, '/new/work/hello.png', 'alpha')).toBe('/new/work/hello.png')
+  })
+  it('persists global authorization, allows arbitrary directories only when enabled, and applies revocation immediately', async () => {
+    const f = fixture()
+    saveFileAccess(f.home, { mode: 'all', folders: [] })
+    expect(readFileAccess(f.home).mode).toBe('all')
+    expect(await authorizeFileRead(f.config, f.upstream, '/anything/hello.png', 'alpha')).toBe('/anything/hello.png')
+    expect(f.request).not.toHaveBeenCalled()
+    saveFileAccess(f.home, { mode: 'folders', folders: [] })
+    await expect(authorizeFileRead(f.config, f.upstream, '/anything/hello.png', 'alpha')).rejects.toMatchObject({ status: 403 })
+  })
+  it('rejects symlink escapes and path traversal', async () => {
+    const f = fixture()
+    await expect(authorizeFileRead(f.config, f.upstream, '/remote/work/escape.png', 'alpha')).rejects.toMatchObject({ status: 403 })
+    expect(() => saveFileAccess(f.home, { mode: 'folders', folders: ['/work/../secret'] })).toThrow()
+    for (const path of ['/a/../secret', '//host/file', '/a/%2e%2e/file', 'file://remote/tmp/a.png']) expect(serverFilePath(path)).toBeUndefined()
+  })
+  it('resolves relative cwd on Hermes instead of the Web host', async () => {
+    const f = fixture(); f.cwd.alpha = '.'
+    f.request.mockImplementation(async (path, options) => ({ status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify(
+      path === '/api/config' ? { terminal: { cwd: '.' } } : path === '/api/fs/default-cwd' ? { cwd: '/remote/work' }
+        : { path: options.search.get('path'), entries: [{ name: 'hello.png', path: '/remote/work/hello.png' }] },
+    )) }))
+    expect(await authorizeFileRead(f.config, f.upstream, '/remote/work/hello.png', 'alpha')).toBe('/remote/work/hello.png')
+  })
+  it('routes all absolute server reference forms with profile and filename escaping', () => {
+    for (const path of ['/tmp/hello%20world.png', 'sandbox:/tmp/hello%20world.png', 'file:///tmp/hello%20world.png']) {
+      const url = new URL(serverFileUrl(path, 'beta')!, 'https://web.test')
+      expect(url.pathname).toBe('/api/files/download')
+      expect(url.searchParams.get('path')).toBe('/tmp/hello world.png')
+      expect(url.searchParams.get('profile')).toBe('beta')
+    }
+    expect(serverFileUrl('/api/app/files/id/preview')).toBeUndefined()
+    expect(serverFileUrl('https://example.com/a.png')).toBeUndefined()
+    expect(serverFilePath('/tmp/100%25.png', false)).toBe('/tmp/100%25.png')
+  })
+})

+ 2 - 0
tests/server/hermesMediaRoutes.test.ts

@@ -7,6 +7,7 @@ import { afterEach, describe, expect, it } from 'vitest'
 import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
 import type { ServerConfig } from '../../src/server/config.js'
 import { createAuthenticatedApplication } from './authenticatedApplication.js'
+import { saveFileAccess } from '../../src/server/fileAccess.js'
 
 const HOST = '127.0.0.1:15300'
 const IMAGE_NAME = 'openai_codex_gpt-image-2-high_20260902_194820_1d225f08.png'
@@ -28,6 +29,7 @@ interface UpstreamCall {
 function config(): ServerConfig {
   const home = mkdtempSync(join(tmpdir(), 'yaoyao-hermes-media-'))
   homes.push(home)
+  saveFileAccess(home, { mode: 'all', folders: [] })
   return {
     host: '127.0.0.1', port: 15300,
     upstream: new URL('http://10.10.1.200:9119'),