Просмотр исходного кода

修复 Hermes 生成图片预览并发布 v0.2.31

LSamien 1 месяц назад
Родитель
Сommit
5fbb1ad456

+ 1 - 1
README.md

@@ -82,7 +82,7 @@ ID 和包名。浏览器只提交路径和公开元数据;8800 本地校验 RS
 插件安装接口安装。默认源为
 `https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao`。
 
-当前发布版本:**Git `v0.2.30` / 夭夭 Web `0.2.30` / Hermes Dashboard 插件 `1.7.3`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
+当前发布版本:**Git `v0.2.31` / 夭夭 Web `0.2.31` / Hermes Dashboard 插件 `1.7.3`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
 
 本版支持 Web 独立检查更新、升级和回滚,9119 离线、超时或认证失败不再阻塞升级。8800 仍保留管理员登录及安全校验,并兼容 9119 的本机 Session Token、密码认证和旧版本机直连模式;9119 可保持仅本机监听,8800 继续面向局域网提供服务。
 

+ 5 - 5
docs/agent-install.md

@@ -6,8 +6,8 @@
 
 | 项目 | 版本 | 校验位置 |
 | --- | --- | --- |
-| Git 发布标签 | `v0.2.30` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
-| 夭夭 Web | `0.2.30` | `release.json` 与 `package.json` 的 `version` |
+| Git 发布标签 | `v0.2.31` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
+| 夭夭 Web | `0.2.31` | `release.json` 与 `package.json` 的 `version` |
 | Hermes Dashboard 插件 | `1.7.3` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
 
 以下步骤以仓库根目录为工作目录。不要使用浮动分支替代发布标签。
@@ -75,12 +75,12 @@ curl --fail --silent --show-error http://127.0.0.1:9119/api/auth/providers
 ## 1. 获取并校验指定发布版本
 
 ```bash
-RELEASE_VERSION=v0.2.30
+RELEASE_VERSION=v0.2.31
 git fetch --tags
 git checkout "$RELEASE_VERSION"
 
 test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
-test "$(node -p \"require('./package.json').version\")" = "0.2.30"
+test "$(node -p \"require('./package.json').version\")" = "0.2.31"
 test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.7.3"
 npm run release:verify
 ```
@@ -244,7 +244,7 @@ Git、发布目录、命令输出或日志。配置后检查系统管理中的 A
 
 ## 7. 通过受管服务独立升级 Web
 
-首次按第 6 步安装 Web `0.2.30` 后,可在左下角 Agent 菜单打开“系统更新”。
+首次按第 6 步安装 Web `0.2.31` 后,可在左下角 Agent 菜单打开“系统更新”。
 旧版首次升级仍受旧更新器的上游检查限制,需要在 9119 可用时升级一次,或按第 1、6 步手动部署新版。独立升级能力在运行 `v0.2.30` 后生效。
 检查更新只读取固定的 `HERMES_YAOYAO_RELEASE_SOURCE`,并锁定最新 `vX.Y.Z`
 标签解析到的 Git 提交。更新状态、检查、执行、任务查询和回滚仅要求 8800 本地管理员登录;

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.2.30",
+  "version": "0.2.31",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.2.30",
+      "version": "0.2.31",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.2.30",
+  "version": "0.2.31",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,7 +1,7 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.2.30",
-  "webVersion": "0.2.30",
+  "releaseVersion": "0.2.31",
+  "webVersion": "0.2.31",
   "pluginVersion": "1.7.3",
-  "gitTag": "v0.2.30"
+  "gitTag": "v0.2.31"
 }

+ 53 - 11
src/server/routes.ts

@@ -1169,6 +1169,53 @@ function sendLocalMedia(ctx: Koa.Context, path: string): void {
   ctx.body = createReadStream(path, { start, end: boundedEnd })
 }
 
+function prepareFilePreview(ctx: Koa.Context, name: string): void {
+  const type = ctx.response.get('content-type').split(';', 1)[0]?.trim().toLowerCase()
+  const activeContent = type === 'text/html'
+    || type === 'application/xhtml+xml'
+    || type === 'image/svg+xml'
+    || Boolean(type?.endsWith('/xml') || type?.endsWith('+xml'))
+  if (activeContent) {
+    ctx.type = 'application/octet-stream'
+    ctx.set('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(name)}`)
+  } else {
+    ctx.remove('Content-Disposition')
+  }
+}
+
+function hermesMediaPath(ctx: Koa.Context): string {
+  const segment = (value: string): string => {
+    if (!value || value === '.' || value === '..' || /[/\\\u0000-\u001f\u007f]/.test(value)) {
+      throw new HttpError(400, '媒体路径无效', 'invalid_media_path')
+    }
+    return value
+  }
+  const owner = segment(ctx.params.owner)
+  const profile = ctx.params.profile ? segment(ctx.params.profile) : undefined
+  const directory = profile ? ctx.params.mediaDir : 'cache'
+  if (!['cache', 'images', 'screenshots', 'attachments'].includes(directory)) {
+    throw new HttpError(404, '媒体目录不存在', 'media_not_found')
+  }
+  const raw = ctx.params.filePath
+  const parts = (Array.isArray(raw) ? raw : [raw]).flatMap(part => part.split('/')).map(segment)
+  return `/Users/${owner}/.hermes/${profile ? `profiles/${profile}/` : ''}${directory}/${parts.join('/')}`
+}
+
+async function proxyHermesMedia(ctx: Koa.Context, dependencies: RouteDependencies): Promise<void> {
+  dependencies.auth.require(ctx)
+  const path = hermesMediaPath(ctx)
+  // Generated media belongs to Hermes, which may run on a different machine
+  // or inside a container. Let its file API enforce the managed-root policy.
+  const response = await dependencies.upstreamSession.request('/api/files/download', {
+    search: new URLSearchParams({ path }),
+    headers: ctx.get('range') ? { range: ctx.get('range') } : undefined,
+    maxResponseBytes: 100 * 1_024 * 1_024,
+  })
+  sendUpstreamResponse(ctx, response, dependencies.upstreamSession.jar)
+  ctx.set('Cache-Control', 'private, no-store')
+  if (response.status >= 200 && response.status < 300) prepareFilePreview(ctx, basename(path))
+}
+
 function pairedLocalMediaPath(config: ServerConfig, rawPath: string): string {
   if (!rawPath || rawPath.length > 8_192 || rawPath.includes('\u0000')) {
     throw new HttpError(400, '远程文件路径无效', 'invalid_node_file_path')
@@ -1620,6 +1667,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     sendUpstreamResponse(ctx, response, jar)
   })
 
+  // Keep the original URL usable by inline Markdown, the lightbox and downloads.
+  // These root/profile cache paths are not files on the Web server itself.
+  router.get('/Users/:owner/.hermes/cache/*filePath', ctx => proxyHermesMedia(ctx, dependencies))
+  router.get('/Users/:owner/.hermes/profiles/:profile/:mediaDir/*filePath', ctx => proxyHermesMedia(ctx, dependencies))
+
   // Historical messages can contain Markdown links such as
   // /Users/<owner>/Agents/<path>. Map only the configured media root, never
   // an arbitrary local filesystem path.
@@ -2421,17 +2473,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
         maxResponseBytes: 256 * 1_024 * 1_024,
       })
       if (action === 'preview') {
-        const type = ctx.response.get('content-type').split(';', 1)[0]?.trim().toLowerCase()
-        const activeContent = type === 'text/html'
-          || type === 'application/xhtml+xml'
-          || type === 'image/svg+xml'
-          || Boolean(type?.endsWith('/xml') || type?.endsWith('+xml'))
-        if (activeContent) {
-          ctx.type = 'application/octet-stream'
-          ctx.set('Content-Disposition', `attachment; filename="file-${id}"`)
-        } else {
-          ctx.remove('Content-Disposition')
-        }
+        prepareFilePreview(ctx, `file-${id}`)
       }
     })
   }

+ 76 - 0
tests/e2e/hermesMedia.spec.ts

@@ -0,0 +1,76 @@
+import { expect, test, type APIRequestContext, type Locator } from '@playwright/test'
+
+const mediaPaths = [
+  '/Users/samien/.hermes/profiles/yaoer/cache/images/openai_codex_gpt-image-2-high_20260902_194820_1d225f08.png',
+  '/Users/samien/.hermes/profiles/yaoer/cache/images/国漫三视图.png',
+]
+const mediaNames = mediaPaths.map(path => path.split('/').at(-1)!)
+
+async function signIn(request: APIRequestContext, origin: string) {
+  for (const password of ['e2e-password', 'admin']) {
+    const bootstrap = await (await request.get('/api/app/bootstrap')).json()
+    const login = await request.post('/api/app/login', {
+      headers: { Origin: origin, 'X-CSRF-Token': bootstrap.csrfToken },
+      data: { username: 'admin', password },
+    })
+    if (!login.ok()) continue
+    const account = await login.json()
+    if (account.user.mustChangePassword) {
+      const changed = await request.put('/api/app/account/credentials', {
+        headers: { Origin: origin, 'X-CSRF-Token': account.csrfToken },
+        data: { currentPassword: password, newPassword: 'e2e-password', username: 'admin' },
+      })
+      expect(changed.ok()).toBe(true)
+    }
+    return
+  }
+  throw new Error('Could not sign in to the fixture Web server')
+}
+
+async function expectDecodedImage(image: Locator) {
+  await expect(image).toBeVisible()
+  await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
+}
+
+test('loads Hermes profile cache images through the Web server and switches their full-size previews', async ({ page, baseURL }, testInfo) => {
+  await signIn(page.request, new URL(baseURL!).origin)
+  // No image interception: this exercises the real Web route, upstream account
+  // authentication, attachment-to-inline response handling, and browser decode.
+  const responses = mediaPaths.map(path => page.waitForResponse(response => (
+    decodeURIComponent(new URL(response.url()).pathname) === path
+  )))
+  await page.goto('/chat/session-media?profile=yaoer')
+  await expect(page).toHaveTitle('瑶儿生成图片验收 · 夭夭')
+
+  for (const [index, name] of mediaNames.entries()) {
+    const image = page.getByRole('button', { name: `预览图片 ${name}`, exact: true })
+    await image.scrollIntoViewIfNeeded()
+    await expectDecodedImage(image)
+    const response = await responses[index]!
+    expect(response.status()).toBe(200)
+    expect(response.headers()['content-type']).toContain('image/png')
+    expect(response.headers()['content-disposition'] ?? '').not.toMatch(/^attachment(?:;|$)/)
+    expect(new URL(response.url()).origin).toBe(new URL(baseURL!).origin)
+  }
+
+  await page.getByRole('button', { name: `预览图片 ${mediaNames[0]}`, exact: true }).click()
+  const preview = page.locator('.image-preview-overlay')
+  await expect(preview).toHaveAccessibleName(`预览 ${mediaNames[0]}`)
+  await expectDecodedImage(preview.getByRole('img', { name: mediaNames[0], exact: true }))
+  await expect(preview.locator('.image-preview-counter')).toHaveText('1 / 2')
+
+  await preview.getByRole('button', { name: '下一张媒体' }).click()
+  await expect(preview).toHaveAccessibleName(`预览 ${mediaNames[1]}`)
+  const secondPreview = preview.getByRole('img', { name: mediaNames[1], exact: true })
+  await expectDecodedImage(secondPreview)
+  expect(decodeURIComponent(new URL(await secondPreview.getAttribute('src') || '', baseURL).pathname)).toBe(mediaPaths[1])
+  await expect(preview.locator('.image-preview-counter')).toHaveText('2 / 2')
+
+  await page.keyboard.press('ArrowLeft')
+  await expect(preview).toHaveAccessibleName(`预览 ${mediaNames[0]}`)
+  await expectDecodedImage(preview.getByRole('img', { name: mediaNames[0], exact: true }))
+  await expect(preview.locator('.image-preview-counter')).toHaveText('1 / 2')
+  await page.screenshot({ path: testInfo.outputPath('hermes-profile-cache-preview.png') })
+  await page.keyboard.press('Escape')
+  await expect(preview).toBeHidden()
+})

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

@@ -1,4 +1,5 @@
 import { createServer } from 'node:http'
+import { readFileSync } from 'node:fs'
 import WebSocket, { WebSocketServer } from 'ws'
 
 const port = Number(process.env.FAKE_HERMES_PORT || 19119)
@@ -105,6 +106,7 @@ const sessions = [
   { id: 'session-second', profile: 'yaoyao', source: 'web', title: '第二个会话', preview: '用于验证列表和切换', message_count: 2, tool_call_count: 0, started_at: now() - 7200, last_active_at: now() - 600, model: 'gpt-5.5', provider: 'openai' },
   { id: 'session-demo', profile: 'yaoyao', source: 'web', title: '夭夭 Web 验收会话', preview: '文件库与群聊已经就绪', message_count: 4, tool_call_count: 1, started_at: now() - 3600, last_active_at: now(), pinned: true, model: 'gpt-5.6', provider: 'openai' },
   { id: 'session-yaoer', profile: 'yaoer', source: 'web', title: '瑶儿专属会话', message_count: 1, tool_call_count: 0, started_at: now() - 3500, last_active_at: now() - 20, model: 'gpt-5.6', provider: 'openai' },
+  { id: 'session-media', profile: 'yaoer', source: 'web', title: '瑶儿生成图片验收', message_count: 1, tool_call_count: 0, started_at: now() - 3400, last_active_at: now() - 30, model: 'gpt-5.6', provider: 'openai' },
   ...Array.from({ length: 101 }, (_, index) => ({ id: `session-page-${index + 1}`, profile: 'yaoyao', source: 'web', title: `分页会话 ${index + 1}`, message_count: 0, tool_call_count: 0, started_at: now() - 10_000 - index, last_active_at: now() - 10_000 - index })),
 ]
 const messages = [
@@ -124,6 +126,17 @@ const messages = [
   { id: 'message-background-process', role: 'user', content: '[IMPORTANT: Background process proc_6be40e6c3864 exited (exit code 143, SIGTERM).\nCommand: ./run_mac.sh\nOutput:\nmodel loaded', timestamp: now() - 135 },
   { id: 'message-system', role: 'user', content: '[System: The active model for this chat has changed to gpt-5.6-terra via provider openai.]', timestamp: now() - 130 },
 ]
+// These are Hermes-owned paths, deliberately absent from the Web server's
+// filesystem. Only the authenticated upstream file API can serve their bytes.
+const profileMediaPaths = [
+  '/Users/samien/.hermes/profiles/yaoer/cache/images/openai_codex_gpt-image-2-high_20260902_194820_1d225f08.png',
+  '/Users/samien/.hermes/profiles/yaoer/cache/images/国漫三视图.png',
+]
+const profileMediaPng = readFileSync(new URL('../../public/brand/AppIcon-1024.png', import.meta.url))
+const profileMediaMessages = [{
+  id: 'message-profile-media', role: 'assistant', timestamp: now() - 30,
+  content: `已经生成两张图片:\n\n![3D 半写实女性四视图](${profileMediaPaths[0]})\n\n![国漫三视图](${profileMediaPaths[1]})`,
+}]
 const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, isHost: true, model: 'gpt-5.6', provider: 'openai', reasoningEffort: 'high', fastMode: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }
 const secondGroupAgent = { id: secondAgentId, roomId, profile: 'yaoer', nodeId: 'remote-node', nodeLabel: '远程节点', displayName: '瑶儿', description: '评审 Agent', enabled: true, replyWithoutMention: false, isHost: false, model: 'gpt-5.6', provider: 'openai', reasoningEffort: 'medium', fastMode: false, status: 'idle', createdAt: now() - 1900, updatedAt: now() }
 const groupAgents = [groupAgent, secondGroupAgent]
@@ -171,6 +184,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/files/download' && request.method === 'GET') {
+    const path = url.searchParams.get('path')
+    if (!profileMediaPaths.includes(path)) return json(response, 404, { detail: 'File not found' })
+    response.writeHead(200, {
+      'Content-Type': 'image/png',
+      'Content-Length': profileMediaPng.length,
+      'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(path.split('/').at(-1))}`,
+    })
+    return response.end(profileMediaPng)
+  }
   if (url.pathname.startsWith('/api/plugins/yaoyao/v1/') && !groupAvailable) return json(response, 503, { detail: 'Group service temporarily unavailable' })
   if (url.pathname === '/api/auth/me') return json(response, 200, { user_id: 'demo-user', display_name: '验收用户', email: 'demo@example.invalid', provider: 'basic' })
   if (url.pathname === '/api/auth/ws-ticket' && request.method === 'POST') return json(response, 200, { ticket: 'fake-ticket' })
@@ -305,7 +328,8 @@ const server = createServer(async (request, response) => {
   }
   if (/^\/api\/sessions\/[^/]+\/messages$/.test(url.pathname)) {
     const id = decodeURIComponent(url.pathname.split('/')[3])
-    const scopedMessages = id === 'session-yaoer' ? [{ id: 'yaoer-history', role: 'assistant', content: '瑶儿历史消息', timestamp: now() - 10 }] : messages
+    const scopedMessages = id === 'session-media' ? profileMediaMessages
+      : id === 'session-yaoer' ? [{ id: 'yaoer-history', role: 'assistant', content: '瑶儿历史消息', timestamp: now() - 10 }] : messages
     return json(response, 200, { messages: scopedMessages, pagination: { total: scopedMessages.length, returned: scopedMessages.length, limit: 150, hasMore: false } })
   }
   if (/^\/api\/sessions\/[^/]+$/.test(url.pathname)) {

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

@@ -0,0 +1,190 @@
+// @vitest-environment node
+import { mkdtempSync, rmSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import request from 'supertest'
+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'
+
+const HOST = '127.0.0.1:8800'
+const IMAGE_NAME = 'openai_codex_gpt-image-2-high_20260902_194820_1d225f08.png'
+const IMAGE = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jR5kAAAAASUVORK5CYII=', 'base64')
+const homes: string[] = []
+const runtimes: ApplicationRuntime[] = []
+
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
+})
+
+interface UpstreamCall {
+  url: URL
+  method: string
+  headers: Headers
+}
+
+function config(): ServerConfig {
+  const home = mkdtempSync(join(tmpdir(), 'yaoyao-hermes-media-'))
+  homes.push(home)
+  return {
+    host: '127.0.0.1', port: 8800,
+    upstream: new URL('http://10.10.1.200:9119'),
+    upstreamUsername: 'fixture-service', upstreamPassword: 'fixture-password',
+    allowedHosts: new Set(), home,
+    mediaRoot: home, attachmentsRoot: home, imagesRoot: home,
+    mediaOwner: 'web-host-owner',
+    allowInsecureLan: false, insecureLan: false, production: false,
+  }
+}
+
+function gateway(response: () => Response = () => new Response(IMAGE, {
+  headers: { 'content-type': 'image/png', 'content-disposition': `attachment; filename="${IMAGE_NAME}"` },
+})) {
+  const calls: UpstreamCall[] = []
+  const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
+    const url = new URL(input instanceof Request ? input.url : String(input))
+    calls.push({ url, method: init?.method ?? 'GET', headers: new Headers(init?.headers) })
+    if (url.pathname === '/api/status') return Response.json({ auth_required: true }, {
+      headers: { 'set-cookie': 'hermes_service=fixture-service-cookie; Path=/; HttpOnly' },
+    })
+    if (url.pathname === '/api/auth/me') return Response.json({ user_id: 'fixture-service' })
+    if (url.pathname === '/api/auth/providers') return Response.json({ providers: [{ name: 'basic', supports_password: true }] })
+    if (url.pathname === '/auth/password-login') return Response.json({ ok: true }, {
+      headers: { 'set-cookie': 'hermes_service=fixture-service-cookie; Path=/; HttpOnly' },
+    })
+    if (url.pathname === '/api/files/download') return response()
+    return Response.json({ error: 'Unexpected upstream route' }, { status: 404 })
+  }) as typeof fetch
+  return { calls, fetchImpl, downloads: () => calls.filter(call => call.url.pathname === '/api/files/download') }
+}
+
+function runtimeFor(fixture: ReturnType<typeof gateway>, authenticated = true): ApplicationRuntime {
+  const runtime = (authenticated ? createAuthenticatedApplication : createApplication)({
+    config: config(), fetchImpl: fixture.fetchImpl,
+  })
+  runtimes.push(runtime)
+  return runtime
+}
+
+function bodyBytes(response: request.Response): Buffer {
+  return Buffer.isBuffer(response.body) ? response.body : Buffer.from(response.text ?? '')
+}
+
+describe('historical Hermes media routes', () => {
+  it.each([
+    `/Users/samien/.hermes/cache/images/${IMAGE_NAME}`,
+    `/Users/samien/.hermes/profiles/yaoer/cache/images/${IMAGE_NAME}`,
+  ])('serves the original generated-image URL through the remote Hermes service: %s', async path => {
+    const fixture = gateway()
+    const runtime = runtimeFor(fixture)
+    const response = await request(runtime.app.callback()).get(path)
+      .set('Host', HOST).set('Cookie', 'browser_only=must-not-forward').expect(200)
+
+    expect(bodyBytes(response)).toEqual(IMAGE)
+    expect(response.headers['content-type']).toMatch(/^image\/png/)
+    expect(response.headers['content-disposition'] ?? '').not.toMatch(/^attachment/i)
+    expect(response.headers['cache-control']).toContain('private')
+    expect(response.headers['cache-control']).toContain('no-store')
+    expect(response.headers['set-cookie']).toBeUndefined()
+    expect(fixture.downloads()).toHaveLength(1)
+    const download = fixture.downloads()[0]!
+    expect(download.url.origin).toBe('http://10.10.1.200:9119')
+    expect([...download.url.searchParams]).toEqual([['path', path]])
+    expect(download.method).toBe('GET')
+    expect(download.headers.get('cookie')).toContain('hermes_service=fixture-service-cookie')
+    expect(download.headers.get('cookie')).not.toContain('browser_only')
+  })
+
+  it('decodes nested Chinese filenames once and ignores browser query overrides', async () => {
+    const fixture = gateway()
+    const runtime = runtimeFor(fixture)
+    const path = '/Users/远端用户/.hermes/profiles/yaoer/cache/images/三视图/角色 图.png'
+    const encodedPath = path.split('/').map(encodeURIComponent).join('/')
+    await request(runtime.app.callback())
+      .get(`${encodedPath}?path=%2Fetc%2Fpasswd&profile=forged&token=browser-secret`)
+      .set('Host', HOST).expect(200)
+
+    expect([...fixture.downloads()[0]!.url.searchParams]).toEqual([['path', path]])
+  })
+
+  it.each(['images', 'screenshots', 'attachments'])('supports the profile %s media directory', async directory => {
+    const fixture = gateway()
+    const runtime = runtimeFor(fixture)
+    const path = `/Users/remote/.hermes/profiles/yaoer/${directory}/nested/result.png`
+    await request(runtime.app.callback()).get(path).set('Host', HOST).expect(200)
+    expect(fixture.downloads()[0]!.url.searchParams.get('path')).toBe(path)
+  })
+
+  it('preserves range requests and partial image responses', async () => {
+    const partial = IMAGE.subarray(2, 8)
+    const fixture = gateway(() => new Response(partial, {
+      status: 206,
+      headers: {
+        'content-type': 'image/png', 'content-disposition': 'attachment; filename="result.png"',
+        'accept-ranges': 'bytes', 'content-range': `bytes 2-7/${IMAGE.length}`, 'content-length': '6',
+      },
+    }))
+    const runtime = runtimeFor(fixture)
+    const response = await request(runtime.app.callback())
+      .get('/Users/samien/.hermes/profiles/yaoer/cache/images/result.png')
+      .set('Host', HOST).set('Range', 'bytes=2-7').expect(206)
+
+    expect(bodyBytes(response)).toEqual(partial)
+    expect(fixture.downloads()[0]!.headers.get('range')).toBe('bytes=2-7')
+    expect(response.headers['content-range']).toBe(`bytes 2-7/${IMAGE.length}`)
+    expect(response.headers['accept-ranges']).toBe('bytes')
+    expect(response.headers['content-disposition'] ?? '').not.toMatch(/^attachment/i)
+  })
+
+  it('requires a local login before contacting Hermes', async () => {
+    const fixture = gateway()
+    const runtime = runtimeFor(fixture, false)
+    await request(runtime.app.callback())
+      .get('/Users/samien/.hermes/profiles/yaoer/cache/images/result.png')
+      .set('Host', HOST).expect(401)
+    expect(fixture.calls).toHaveLength(0)
+  })
+
+  it.each([401, 403, 404])('preserves upstream %s without a broader file-read fallback', async status => {
+    const fixture = gateway(() => Response.json({ detail: `upstream-${status}` }, { status }))
+    const runtime = runtimeFor(fixture)
+    const response = await request(runtime.app.callback())
+      .get('/Users/samien/.hermes/profiles/yaoer/cache/images/result.png')
+      .set('Host', HOST).expect(status)
+
+    expect(response.body).toEqual({ detail: `upstream-${status}` })
+    expect(fixture.downloads().length).toBeGreaterThan(0)
+    expect(fixture.calls.some(call => call.url.pathname === '/api/fs/read-data-url' || call.url.pathname === '/api/media')).toBe(false)
+  })
+
+  it('keeps active content as an attachment even beneath a media directory', async () => {
+    const fixture = gateway(() => new Response('<svg xmlns="http://www.w3.org/2000/svg"/>', {
+      headers: { 'content-type': 'image/svg+xml', 'content-disposition': 'inline; filename="result.svg"' },
+    }))
+    const runtime = runtimeFor(fixture)
+    const response = await request(runtime.app.callback())
+      .get('/Users/samien/.hermes/profiles/yaoer/cache/images/result.svg')
+      .set('Host', HOST).expect(200)
+
+    expect(response.headers['content-type']).toMatch(/^application\/octet-stream/)
+    expect(response.headers['content-disposition']).toMatch(/^attachment/i)
+    expect(response.headers['cache-control']).toContain('no-store')
+  })
+
+  it.each([
+    '/Users/samien/.hermes/profiles/yaoer/config/result.png',
+    '/Users/samien/.hermes/profiles/yaoer/cache/images/nested%2F..%2Fresult.png',
+    '/Users/samien/.hermes/profiles/yaoer/cache/images/result%5C.png',
+    '/Users/samien/.hermes/profiles/yaoer/cache/images/result%00.png',
+    '/Users/samien/.hermes/profiles/yaoer%2Fother/cache/images/result.png',
+    '/Users/samien%2Fother/.hermes/cache/images/result.png',
+  ])('rejects unsupported directories and invalid decoded path segments: %s', async path => {
+    const fixture = gateway()
+    const runtime = runtimeFor(fixture)
+    const response = await request(runtime.app.callback()).get(path).set('Host', HOST)
+    expect([400, 404]).toContain(response.status)
+    expect(fixture.calls).toHaveLength(0)
+  })
+})