Browse Source

支持 Docker 映射 Hermes 目录安装工具桥

增加可选 Compose 映射文件与显式安装授权配置,使用容器 Python/PyYAML 检查和安装共享目录中的工具桥。补全镜像构建所需的插件资源,保留非 root、只读根文件系统和外部 Hermes 生命周期边界,并通过真实容器验证默认及命名 Profile 安装与备份写回。
Samien 1 month ago
parent
commit
a89f20a73a

+ 7 - 0
.dockerignore

@@ -26,6 +26,13 @@
 !bin/**
 !src/
 !src/**
+!integrations/
+!integrations/hermes-bots-bridge/
+!integrations/hermes-bots-bridge/**
+
+integrations/hermes-bots-bridge/tests/**
+**/__pycache__/**
+**/*.py[cod]
 
 **/.DS_Store
 **/*.log

+ 5 - 1
Dockerfile

@@ -16,6 +16,7 @@ COPY bin ./bin
 COPY deploy ./deploy
 COPY third-party ./third-party
 COPY src ./src
+COPY integrations/hermes-bots-bridge ./integrations/hermes-bots-bridge
 
 RUN npm run build \
   && npm run runner:build \
@@ -34,7 +35,10 @@ ENV NODE_ENV=production \
 
 WORKDIR /app
 
-RUN install -d -o node -g node -m 0700 /home/node/.yaoyao
+RUN apt-get update \
+  && apt-get install -y --no-install-recommends python3 python3-yaml \
+  && rm -rf /var/lib/apt/lists/* \
+  && install -d -o node -g node -m 0700 /home/node/.yaoyao
 
 COPY --chown=node:node package.json package-lock.json release.json ./
 COPY --chown=node:node LICENSE NOTICE THIRD_PARTY_NOTICES.md ./

+ 15 - 0
compose.hermes-bridge.yaml

@@ -0,0 +1,15 @@
+# Optional overlay: mount the SAME data directory used by HERMES_YAOYAO_UPSTREAM.
+# Combine with compose.yaml, compose.desktops.yaml or compose.desktops.cursor.yaml.
+services:
+  web:
+    environment:
+      HERMES_YAOYAO_BRIDGE_MOUNTED: "1"
+      HERMES_YAOYAO_BRIDGE_HOME: /hermes
+      HERMES_YAOYAO_BRIDGE_PYTHON: /usr/bin/python3
+    volumes:
+      - type: bind
+        source: "${HERMES_YAOYAO_HERMES_DIR:?Set HERMES_YAOYAO_HERMES_DIR to the existing Hermes data directory}"
+        target: /hermes
+        read_only: false
+        bind:
+          create_host_path: false

+ 4 - 0
docker.env.example

@@ -17,6 +17,10 @@ HERMES_YAOYAO_PUBLISHED_PORT=15300
 # 填写容器可访问的 Hermes Dashboard/Gateway 地址。
 # Docker Desktop 可用这个地址访问宿主机;Linux 上的 9119 还必须监听容器可达的宿主机地址。
 HERMES_YAOYAO_UPSTREAM=http://host.docker.internal:9119
+# 可选工具桥安装:填写上述 Hermes 使用的数据目录的宿主机绝对路径(包含 config.yaml)。
+# 启动时额外加 -f compose.hermes-bridge.yaml;目录必须已存在且容器用户有读写权限。
+# Linux 示例:/srv/hermes-data;macOS 示例:/Users/你的用户名/.hermes。
+# HERMES_YAOYAO_HERMES_DIR=/srv/hermes-data
 HERMES_YAOYAO_CHAT_CACHE_MODE=prefer-local
 # 官方稳定发布源;自定义 Git 仓库可覆盖。容器仍由部署者重建升级。
 HERMES_YAOYAO_RELEASE_SOURCE=https://github.com/Lsamien/yaoyao.git

+ 39 - 0
docs/docker-install.md

@@ -62,6 +62,45 @@ docker compose --env-file docker.env ps
 
 默认打开 `http://127.0.0.1:15300`。局域网设备使用宿主机 IP 和发布端口访问。首次打开页面创建管理员账号,再到系统设置配置 Hermes 连接凭据。通过域名提供公网访问时,在反向代理上配置 HTTPS,并将域名加入允许列表。
 
+## 映射 Hermes 目录以安装工具桥
+
+可额外加载 `compose.hermes-bridge.yaml`,让“设置 → Hermes 连接 → 工具桥插件”直接检查、安装和修复共享目录中的插件。这个可选文件适用于普通 Web、标准共享桌面和 Cursor 共享桌面三种 Compose 配置。
+
+在 `docker.env` 中填写 **上游 Hermes 实际使用的数据目录** 的宿主机绝对路径。该目录应包含 `config.yaml`,命名 Profile 位于其中的 `profiles/`:
+
+```dotenv
+# Linux 示例;macOS 可填写 /Users/你的用户名/.hermes。
+HERMES_YAOYAO_HERMES_DIR=/srv/hermes-data
+```
+
+普通 Web 使用:
+
+```sh
+docker compose --env-file docker.env -f compose.yaml -f compose.hermes-bridge.yaml config --quiet
+docker compose --env-file docker.env -f compose.yaml -f compose.hermes-bridge.yaml up -d --build web
+```
+
+若使用共享桌面,将上面的第一个 `-f compose.yaml` 换成 `-f compose.desktops.yaml` 或 `-f compose.desktops.cursor.yaml`,保留第二个映射文件。
+
+- 宿主目录以读写方式挂载到容器 `/hermes`,必须已存在;路径写错时不会自动创建一个空 Hermes 目录。
+- 选择与 `HERMES_YAOYAO_UPSTREAM` 对应实例相同的数据目录。远程主机的目录需要先以共享存储等方式提供给 Docker 宿主机。
+- Web 仍使用非 root 用户,默认 UID/GID 为 `1000:1000`。映射目录需要允许该用户读取配置,并写入 `config.yaml`、`plugins/`、`profiles/` 和 `backups/`。权限不匹配时应在宿主机配置对应用户或 ACL;Web 页面会报告读取或安装失败。
+- 镜像自带 Linux Python 3 和 PyYAML。安装器只使用容器 `/usr/bin/python3`,宿主机的 `venv` 和 Hermes 程序不会被执行。
+- 安装前备份插件与配置,先安装默认 Profile 的后台入口,再处理命名 Profile。安装后在 Hermes 所在节点重启服务,再在 Web 点击“重新检查”。
+- 映射不会赋予 Web 管理 Docker 或 Hermes 进程的能力;既有 Compose 桌面数量、隔离设置及生命周期继续按原配置管理。
+
+此功能需要包含本次改动的新镜像;旧的远程镜像标签不会因添加目录映射而自动获得安装功能。可按上述命令从当前源码构建。
+
+自定义部署可使用以下容器环境变量:
+
+| 配置 | 用途 |
+| --- | --- |
+| `HERMES_YAOYAO_BRIDGE_MOUNTED=1` | 明确允许通过映射目录安装工具桥 |
+| `HERMES_YAOYAO_BRIDGE_HOME=/hermes` | 容器内实际挂载路径,必须是绝对路径 |
+| `HERMES_YAOYAO_BRIDGE_PYTHON=/usr/bin/python3` | 容器自身的 Python 路径 |
+
+仅设置目录但没有开启 `BRIDGE_MOUNTED` 时,外部 Hermes 仍只提供状态检查,不开放目录安装。
+
 ## 验证
 
 以下命令使用默认发布端口;修改端口后同步调整 URL:

+ 1 - 1
docs/local-vm.md

@@ -43,7 +43,7 @@
 - “安装并启用”只启用选中的工具桥及其工具集,保留其他插件、禁用项和模型配置。每次安装保留原插件与配置备份,界面可查看备份位置。
 - 检查会比较插件文件指纹与 Hermes 启动时加载的指纹;同版本修复后未重启也会显示“待重启”,不会仅凭安装文件判断就绪。
 - 安装不会自动重启 Hermes。请在空闲时重启,再点击“重新检查”。正在执行任务时暂不可安装。
-- 本机安装只用于当前服务可访问的本机 Hermes;远程节点支持状态检查,需在对应节点安装。
+- 本机安装用于当前服务可访问的本机 Hermes。Docker 可显式映射对应 Hermes 数据目录以安装工具桥,详见 [Docker 目录映射](docker-install.md#映射-hermes-目录以安装工具桥);其他远程节点支持状态检查,需在对应节点安装。
 
 管理员 API:`GET /api/app/admin/hermes-bridge` 与 `POST /api/app/admin/hermes-bridge/install`,安装参数仅为 `profile` 和可选的 `enable`;不接受客户端传入目录、命令或 Python 路径。
 

+ 26 - 0
docs/verification/2026-09-15-docker-hermes-bridge/README.md

@@ -0,0 +1,26 @@
+# Docker Hermes 目录映射验证
+
+## 桌面包先行完成
+
+桌面包在 Docker 修改前已生成,源码提交为 `3872bf0`,版本 `0.4.21`、macOS arm64。DMG、App 严格签名和独立 Web 服务资源校验通过;正式 App 的 25 项桌面测试通过,包括独立服务启动、重试、同版本覆盖及数据保留。签名为 Apple Development,未公证、未安装到用户应用目录。
+
+## Docker 验证
+
+- 本地构建镜像:`yaoyao:hermes-bridge-mount-20260915`(本次验证为 linux/arm64)。
+- 使用 `tests/live/dockerHermesBridge.live.test.ts` 启动真实 Web 容器,Hermes HTTP 上游采用私有测试网络内的确定性模拟服务。
+- 普通 Web、标准共享桌面和 Cursor 共享桌面三种 Compose 配置均成功合并可选映射,保留只读根文件系统、cap_drop、外部 Runner 和关闭 Hermes 自动托管等设置。
+- 容器以 UID 1000 运行,自带 Python/PyYAML,在 `/hermes` 映射中检查和安装默认及命名 Profile 的插件。
+- 通过管理员 HTTP 接口安装后,在宿主机临时目录确认插件文件、原始配置备份和保留的配置内容。
+- 未安装默认后台入口时,命名 Profile 的无效安装请求被拒绝。
+- 安装结果为“待重启”,未把复制成功误报成已加载;没有请求上游启动、停止或重启。
+- 测试容器、网络、数据卷与临时目录均已清理。用户现有 Hermes 目录和运行中的部署未修改。
+
+复现命令:
+
+```sh
+docker build -t yaoyao:hermes-bridge-mount-test .
+YAOYAO_DOCKER_BRIDGE_IMAGE=yaoyao:hermes-bridge-mount-test \
+  NO_PROXY=127.0.0.1,localhost no_proxy=127.0.0.1,localhost \
+  NODE_OPTIONS=--no-experimental-webstorage \
+  npx vitest run tests/live/dockerHermesBridge.live.test.ts
+```

+ 11 - 1
src/server/config.ts

@@ -2,7 +2,7 @@ import { isIP } from 'node:net'
 import { normalizeReleaseSource } from '../../bin/lib/release-source.mjs'
 import { resolveDataHome } from '../../bin/lib/data-home.mjs'
 import { homedir } from 'node:os'
-import { basename, resolve } from 'node:path'
+import { basename, resolve, isAbsolute } from 'node:path'
 import { readFileSync } from 'node:fs'
 import type { APNsEnvironment } from './apns.js'
 import { loadAPNsConfiguration, type APNsConfigurationSnapshot } from './apnsConfiguration.js'
@@ -47,6 +47,7 @@ export interface ServerConfig {
   production: boolean
   superviseDashboard?: boolean
   localVmHost?: 'server' | 'runner'
+  hermesBridgeMount?: { home: string; python: string }
   composeDesktops?: ComposeDesktop[]
   releaseSource?: string
   releaseRoot?: string
@@ -170,6 +171,14 @@ export function loadServerConfig(
   const superviseDashboard = flag(env.HERMES_YAOYAO_SUPERVISE_DASHBOARD)
   const localVmHost = env.HERMES_YAOYAO_LOCAL_VM_HOST?.trim() || 'server'
   if(localVmHost!=='server'&&localVmHost!=='runner')throw new Error('HERMES_YAOYAO_LOCAL_VM_HOST must be server or runner')
+  let hermesBridgeMount: ServerConfig['hermesBridgeMount']
+  if(flag(env.HERMES_YAOYAO_BRIDGE_MOUNTED)){
+    const home=env.HERMES_YAOYAO_BRIDGE_HOME?.trim()??''
+    const python=env.HERMES_YAOYAO_BRIDGE_PYTHON?.trim()||'/usr/bin/python3'
+    if(!home||!isAbsolute(home)||/[\0\r\n]/.test(home))throw new Error('HERMES_YAOYAO_BRIDGE_HOME must be an absolute mounted Hermes directory')
+    if(!isAbsolute(python)||/[\0\r\n]/.test(python))throw new Error('HERMES_YAOYAO_BRIDGE_PYTHON must be an absolute container Python path')
+    hermesBridgeMount={home:resolve(home),python}
+  }
   const chatCacheMode = parseChatCacheMode(env.HERMES_YAOYAO_CHAT_CACHE_MODE)
   const releaseSource = parseReleaseSource(env.HERMES_YAOYAO_RELEASE_SOURCE)
   const releaseRoot = resolve(env.HERMES_YAOYAO_RELEASE_ROOT?.trim() || `${homedir()}/.local/share/hermes-yaoyao`)
@@ -209,6 +218,7 @@ export function loadServerConfig(
     production,
     superviseDashboard,
     localVmHost,
+    ...(hermesBridgeMount?{hermesBridgeMount}:{}),
     composeDesktops:parseComposeDesktops(env.HERMES_YAOYAO_COMPOSE_DESKTOPS),
     chatCacheMode,
     releaseSource,

+ 13 - 10
src/server/hermesBridge.ts

@@ -29,17 +29,19 @@ export class HermesBridgeManager {
   readonly bundled:boolean
   readonly bundledVersion:string
   readonly local:boolean
+  readonly mapped:boolean
   private installing?:string
   private readonly run:(python:string,args:string[])=>Promise<string>
   constructor(readonly config:ServerConfig,readonly session:Pick<UpstreamServiceSession,'request'>,readonly options:Options={}){
-    this.home=resolve(options.home??process.env.HERMES_HOME??join(homedir(),'.hermes'))
-    this.python=options.python??join(this.home,'hermes-agent','venv','bin','python')
+    this.mapped=!!config.hermesBridgeMount
+    this.home=resolve(options.home??config.hermesBridgeMount?.home??process.env.HERMES_HOME??join(homedir(),'.hermes'))
+    this.python=options.python??config.hermesBridgeMount?.python??join(this.home,'hermes-agent','venv','bin','python')
     const root=options.assetsRoot??assetRoot()??''
     this.script=root?(existsSync(join(root,'install-hermes-bridge.py'))?join(root,'install-hermes-bridge.py'):join(root,'scripts/install-hermes-bridge.py')):''
     this.bundled=!!root&&existsSync(this.script)&&(existsSync(join(root,'hermes-bots-bridge/plugin.yaml'))||existsSync(join(root,'integrations/hermes-bots-bridge/plugin.yaml')))
     const manifest=root&&[join(root,'hermes-bots-bridge/plugin.yaml'),join(root,'integrations/hermes-bots-bridge/plugin.yaml')].find(existsSync)
     this.bundledVersion=manifest?readFileSync(manifest,'utf8').match(/^version:\s*["']?([^\s"']+)/m)?.[1]??'未知':'不可用'
-    this.local=isLoopbackHost(config.upstream.hostname)&&config.localVmHost!=='runner'
+    this.local=this.mapped||(isLoopbackHost(config.upstream.hostname)&&config.localVmHost!=='runner')
     this.run=options.run??(async(python,args)=>(await execFileAsync(python,args,{timeout:20000,maxBuffer:512*1024,env:{PATH:process.env.PATH,HOME:homedir(),PYTHONNOUSERSITE:'1',PYTHONDONTWRITEBYTECODE:'1'}})).stdout)
   }
   get idleForUpdate(){return !this.installing}
@@ -49,7 +51,7 @@ export class HermesBridgeManager {
     try{
       const output=await this.run(this.python,[this.script,'--hermes-home',this.home,'--check'])
       return output.trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)).filter(item=>profileName.safeParse(item.profile).success).slice(0,64)
-    }catch{throw new HttpError(409,'无法检查本机工具桥,请确认 Hermes 的 Python 环境和 Profile 配置可用。','hermes_bridge_check_failed')}
+    }catch{throw new HttpError(409,'无法检查工具桥,请确认 Hermes 目录权限、Python 环境和 Profile 配置可用。','hermes_bridge_check_failed')}
   }
   async status():Promise<HermesBridgeStatus>{
     const [disk,remote]=await Promise.allSettled([this.disk(),this.session.request('/api/profiles',{cache:'reload'})])
@@ -86,24 +88,25 @@ export class HermesBridgeManager {
       if(this.local&&profile!=='default'&&!hostEntryInstalled){result.canInstall=false;result.message+=';请先安装或更新默认 Profile 的工具桥入口'}
       return result
     })))
-    return {endpoint:this.config.upstream.origin,local:this.local,bundledVersion:this.bundledVersion,checkedAt:Date.now(),profiles,...(this.installing?{installing:this.installing}:{}),
+    return {endpoint:this.config.upstream.origin,local:this.local,...(this.mapped?{mapped:true}:{}),bundledVersion:this.bundledVersion,checkedAt:Date.now(),profiles,...(this.installing?{installing:this.installing}:{}),
       ...(!this.local?{message:'当前 Hermes 由其他节点提供,请在 Hermes 所在节点安装或修复工具桥。'}:
         !this.bundled?{message:'当前服务未包含工具桥安装包,请更新 Yaoyao。'}:
-        !this.available?{message:'当前服务环境未找到本机 Hermes 安装目录和 Python,无法直接安装。'}:
-        disk.status==='rejected'?{message:'本机插件检查失败,请核对 Hermes 环境。'}:
-        this.options.isIdle&&!this.options.isIdle()?{message:'当前仍有任务运行,请结束任务后安装。'}:{})}
+        !this.available?{message:this.mapped?`映射目录 ${this.home} 或容器 Python 不可用,请检查目录映射和读取权限。`:'当前服务环境未找到本机 Hermes 安装目录和 Python,无法直接安装。'}:
+        disk.status==='rejected'?{message:'插件检查失败,请核对 Hermes 目录权限和运行环境。'}:
+        this.options.isIdle&&!this.options.isIdle()?{message:'当前仍有任务运行,请结束任务后安装。'}:
+        this.mapped?{message:`已映射 Hermes 目录:${this.home}。安装会写入此目录;完成后请在 Hermes 所在节点重启服务。`}:{})}
   }
   async install(value:unknown):Promise<HermesBridgeInstallResult>{
     const parsed=installInput.safeParse(value)
     if(!parsed.success)throw new HttpError(400,'请选择有效的 Profile','invalid_bridge_install_request')
-    if(!this.available)throw new HttpError(409,'只能为当前服务可管理的本机 Hermes 安装工具桥。','hermes_bridge_install_unavailable')
+    if(!this.available)throw new HttpError(409,'只能为当前服务可管理的本机或显式映射的 Hermes 目录安装工具桥。','hermes_bridge_install_unavailable')
     if(this.installing)throw new HttpError(409,'工具桥正在安装,请等待完成。','hermes_bridge_install_busy')
     if(this.options.isIdle&&!this.options.isIdle())throw new HttpError(409,'当前仍有任务运行,请结束任务后安装。','hermes_bridge_tasks_running')
     this.installing=parsed.data.profile
     let result:any
     try{
       const disk=await this.disk(),local=disk.find(item=>item.profile===parsed.data.profile)
-      if(!local?.valid)throw new HttpError(404,'本机没有此 Profile 的有效配置。','hermes_bridge_profile_missing')
+      if(!local?.valid)throw new HttpError(404,'Hermes 目录中没有此 Profile 的有效配置。','hermes_bridge_profile_missing')
       const hostEntry=disk.find(item=>item.profile==='default')
       if(parsed.data.profile!=='default'&&(!hostEntry?.filesCurrent||!hostEntry.enabled||hostEntry.disabled))
         throw new HttpError(409,'请先安装或更新默认 Profile 的工具桥入口,再安装这个 Profile。','hermes_bridge_host_entry_required')

+ 1 - 0
src/shared/hermesBridge.ts

@@ -10,6 +10,7 @@ export interface HermesBridgeProfileStatus {
 export interface HermesBridgeStatus {
   endpoint: string
   local: boolean
+  mapped?: boolean
   bundledVersion: string
   checkedAt: number
   installing?: string

+ 77 - 0
tests/live/dockerHermesBridge.live.test.ts

@@ -0,0 +1,77 @@
+// @vitest-environment node
+import {expect,it} from 'vitest'
+import {execFile} from 'node:child_process'
+import {promisify} from 'node:util'
+import {mkdtemp,mkdir,writeFile,readFile,chmod,rm,stat} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {randomUUID,randomBytes,scryptSync} from 'node:crypto'
+
+const command=promisify(execFile),image=process.env.YAOYAO_DOCKER_BRIDGE_IMAGE
+const docker=async(...args:string[])=>(await command('docker',args,{timeout:90000,maxBuffer:2*1024*1024})).stdout.trim()
+it.skipIf(!image)('installs bridges into mapped Hermes storage through the real non-root, read-only Docker Web',async()=>{
+  const root=await mkdtemp(join(tmpdir(),'yaoyao-docker-bridge-')),hermes=join(root,'hermes')
+  const id='yaoyao-bridge-'+randomUUID().slice(0,8),web=id+'-web',upstream=id+'-hermes',network=id+'-net',volume=id+'-data'
+  const original=JSON.stringify({model:{default:'fixture-model'},notes:'preserve-default',plugins:{enabled:['existing']}})
+  await chmod(root,0o755);await mkdir(join(hermes,'profiles','writer'),{recursive:true})
+  // Disposable fixture permissions allow the image's uid 1000 to write; never
+  // change permissions on the user's real Hermes directory.
+  for(const path of [hermes,join(hermes,'profiles'),join(hermes,'profiles','writer')])await chmod(path,0o777)
+  await writeFile(join(hermes,'config.yaml'),original,{mode:0o666});await chmod(join(hermes,'config.yaml'),0o666)
+  await writeFile(join(hermes,'profiles','writer','config.yaml'),JSON.stringify({notes:'preserve-writer',plugins:{disabled:['yaoyao-bot-bridge']}}),{mode:0o666})
+  await chmod(join(hermes,'profiles','writer','config.yaml'),0o666)
+  const salt=randomBytes(16),now=Date.now()
+  await writeFile(join(root,'users.json'),JSON.stringify({version:1,users:[{id:randomUUID(),username:'fixture',normalizedUsername:'fixture',role:'admin',enabled:true,mustChangePassword:false,salt:salt.toString('base64'),passwordHash:scryptSync('fixture-pass',salt,32,{N:2**14,r:8,p:1,maxmem:64*1024*1024}).toString('base64'),authVersion:1,createdAt:now,updatedAt:now}]}))
+  await writeFile(join(root,'upstream.mjs'),`import {createServer} from 'node:http';const calls=[];createServer((req,res)=>{const path=new URL(req.url,'http://hermes').pathname;calls.push({path,method:req.method});res.setHeader('Content-Type','application/json');let value={ok:true};if(path==='/api/status')value={auth_required:true,gateway_running:true};if(path==='/auth/password-login')res.setHeader('Set-Cookie','hermes_session_at=fixture; Path=/; HttpOnly');if(path==='/api/auth/me')value={user_id:'fixture'};if(path==='/api/profiles')value={profiles:[{name:'default'},{name:'writer'}]};if(path.endsWith('/capabilities'))value={version:1,ready:false,in_process:true};if(path==='/__calls')value=calls;res.end(JSON.stringify(value))}).listen(9119,'0.0.0.0');`)
+  const cookies=new Map<string,string>();let csrf=''
+  try{
+    for(const base of ['compose.yaml','compose.desktops.yaml','compose.desktops.cursor.yaml']){
+      const output=await command('docker',['compose','--env-file','/dev/null','-f',base,'-f','compose.hermes-bridge.yaml','config','--format','json'],{env:{...process.env,HERMES_YAOYAO_HERMES_DIR:hermes},maxBuffer:2*1024*1024})
+      const config=JSON.parse(output.stdout),service=config.services.web
+      expect(service.read_only).toBe(true);expect(service.cap_drop).toEqual(['ALL'])
+      expect(service.environment.HERMES_YAOYAO_LOCAL_VM_HOST).toBe('runner')
+      expect(service.environment.HERMES_YAOYAO_SUPERVISE_DASHBOARD).toBe('0')
+      expect(service.volumes.find((v:any)=>v.target==='/hermes')).toMatchObject({type:'bind',source:hermes,bind:{create_host_path:false}})
+      expect(JSON.stringify(service.volumes)).not.toContain('docker.sock')
+    }
+    await docker('network','create',network);await docker('volume','create',volume)
+    await docker('run','--rm','--network','none','--mount',`type=volume,src=${volume},dst=/home/node/.yaoyao`,'--mount',`type=bind,src=${root},dst=/fixture,readonly`,'--entrypoint','node',image!,'-e',"require('fs').copyFileSync('/fixture/users.json','/home/node/.yaoyao/users.json')")
+    await docker('run','-d','--name',upstream,'--network',network,'--network-alias','hermes','--no-healthcheck','--mount',`type=bind,src=${root},dst=/fixture,readonly`,image!,'node','/fixture/upstream.mjs')
+    await docker('run','-d','--name',web,'--network',network,'--read-only','--cap-drop','ALL','--security-opt','no-new-privileges:true','--tmpfs','/tmp:mode=1777','-p','127.0.0.1::15300',
+      '--mount',`type=volume,src=${volume},dst=/home/node/.yaoyao`,'--mount',`type=bind,src=${hermes},dst=/hermes`,
+      '-e','HERMES_YAOYAO_UPSTREAM=http://hermes:9119','-e','HERMES_YAOYAO_UPSTREAM_USERNAME=fixture','-e','HERMES_YAOYAO_UPSTREAM_PASSWORD=fixture','-e','HERMES_YAOYAO_ALLOW_INSECURE_LAN=1','-e','HERMES_YAOYAO_ALLOWED_HOSTS=127.0.0.1,localhost',
+      '-e','HERMES_YAOYAO_BRIDGE_MOUNTED=1','-e','HERMES_YAOYAO_BRIDGE_HOME=/hermes','-e','HERMES_YAOYAO_BRIDGE_PYTHON=/usr/bin/python3',image!)
+    const port=(await docker('port',web,'15300/tcp')).split(':').at(-1)!,origin='http://127.0.0.1:'+port
+    const api=async(path:string,body?:unknown,expected=200)=>{
+      const response=await fetch(origin+path,{method:body===undefined?'GET':'POST',headers:{Origin:origin,'Content-Type':'application/json',Cookie:[...cookies].map(([key,value])=>key+'='+value).join('; '),...(csrf?{'X-CSRF-Token':csrf}:{})},...(body!==undefined?{body:JSON.stringify(body)}:{})})
+      for(const header of response.headers.getSetCookie()){const item=header.split(';')[0],equals=item.indexOf('=');cookies.set(item.slice(0,equals),item.slice(equals+1))}
+      const text=await response.text();expect(response.status,text).toBe(expected);return JSON.parse(text)
+    }
+    await expect.poll(async()=>{try{return(await fetch(origin+'/healthz')).status}catch{return 0}},{timeout:30000}).toBe(200)
+    await api('/auth/password-login',{username:'fixture',password:'fixture-pass'})
+    csrf=(await api('/api/app/bootstrap?csrfOnly=1')).csrfToken
+    const before=await api('/api/app/admin/hermes-bridge')
+    expect(before.mapped).toBe(true);expect(before.local).toBe(true);expect(before.profiles[0].canInstall).toBe(true)
+    expect(before.profiles[0].state).toBe('missing')
+    await api('/api/app/admin/hermes-bridge/install',{profile:'writer',enable:true},409)
+    const installed=await api('/api/app/admin/hermes-bridge/install',{profile:'default',enable:true})
+    expect(installed.status.profiles[0].state).toBe('restart-required')
+    const backup=join(hermes,installed.backup.slice('/hermes/'.length),'config.yaml')
+    expect(await readFile(backup,'utf8')).toBe(original)
+    expect(await readFile(join(hermes,'config.yaml'),'utf8')).toContain('preserve-default')
+    expect(await readFile(join(hermes,'plugins','yaoyao-bot-bridge','bridge_runtime.py'),'utf8')).toContain('computer_runtime_version')
+    await api('/api/app/admin/hermes-bridge/install',{profile:'writer',enable:true})
+    expect(await readFile(join(hermes,'profiles','writer','config.yaml'),'utf8')).toContain('preserve-writer')
+    expect((await stat(join(hermes,'profiles','writer','plugins','yaoyao-bot-bridge','plugin.yaml'))).isFile()).toBe(true)
+    expect(await docker('exec',web,'id','-u')).toBe('1000')
+    expect(await docker('exec',web,'python3','-c','import yaml;print("yaml-ready")')).toBe('yaml-ready')
+    expect(await docker('inspect','--format','{{.HostConfig.ReadonlyRootfs}}',web)).toBe('true')
+    const calls=JSON.parse(await docker('exec',web,'node','-e',"fetch('http://hermes:9119/__calls').then(r=>r.text()).then(console.log)"))
+    expect(calls.some((c:any)=>/restart|stop|gateway\/start/.test(c.path))).toBe(false)
+  }catch(error){try{console.error((await docker('logs','--tail','35',web)).slice(-6000))}catch{}throw error}
+  finally{
+    await docker('rm','-f',web,upstream).catch(()=>{})
+    await docker('volume','rm',volume).catch(()=>{});await docker('network','rm',network).catch(()=>{})
+    await rm(root,{recursive:true,force:true})
+  }
+},120000)

+ 15 - 0
tests/server/hermesBridge.test.ts

@@ -87,3 +87,18 @@ it('does not report an offline Hermes as loaded or expose installer diagnostic s
   f.run.mockRejectedValue(new Error('sensitive diagnostic must stay private'))
   await expect(f.manager.install({profile:'default'})).rejects.not.toThrow('sensitive diagnostic')
 })
+
+it('uses explicitly mapped Hermes storage and the container interpreter with an external upstream',async()=>{
+  const f=fixture()
+  const mapped={...config,localVmHost:'runner' as const,upstream:new URL('http://hermes:9119'),hermesBridgeMount:{home,python:process.execPath}}
+  const manager=new HermesBridgeManager(mapped,f.session,{assetsRoot:resolve('.'),run:f.run})
+  expect(manager.home).toBe(home);expect(manager.python).toBe(process.execPath)
+  const status=await manager.status()
+  expect(status.mapped).toBe(true);expect(status.local).toBe(true);expect(status.profiles[0].canInstall).toBe(true)
+  expect(status.message).toContain(home)
+  await manager.install({profile:'default'})
+  const call=f.run.mock.calls.find(([,args])=>args.includes('--profile'))!
+  expect(call[0]).toBe(process.execPath)
+  expect(call[1]).toContain(home)
+  expect(call[1].join(' ')).not.toContain('/hermes-agent/venv/')
+})

+ 11 - 0
tests/server/security.test.ts

@@ -152,3 +152,14 @@ describe('server security boundary', () => {
       .toBe('ssh://git@git.example/hermes-yaoyao.git')
   })
 })
+
+it('requires explicit mounted-Hermes opt-in and container-absolute installation paths',()=>{
+  const base={HERMES_YAOYAO_LOCAL_VM_HOST:'runner',HERMES_YAOYAO_UPSTREAM:'http://hermes:9119'}
+  expect(loadServerConfig(isolatedEnv({...base,HERMES_YAOYAO_BRIDGE_HOME:'/hermes'})).hermesBridgeMount).toBeUndefined()
+  const mapped=loadServerConfig(isolatedEnv({...base,HERMES_YAOYAO_BRIDGE_MOUNTED:'1',HERMES_YAOYAO_BRIDGE_HOME:'/hermes'}))
+  expect(mapped.hermesBridgeMount).toEqual({home:'/hermes',python:'/usr/bin/python3'})
+  expect(mapped.localVmHost).toBe('runner');expect(mapped.superviseDashboard).toBe(false)
+  for(const patch of [{HERMES_YAOYAO_BRIDGE_HOME:''},{HERMES_YAOYAO_BRIDGE_HOME:'relative'},{HERMES_YAOYAO_BRIDGE_HOME:'/hermes\nother'},{HERMES_YAOYAO_BRIDGE_PYTHON:'python3'}]){
+    expect(()=>loadServerConfig(isolatedEnv({...base,HERMES_YAOYAO_BRIDGE_MOUNTED:'1',HERMES_YAOYAO_BRIDGE_HOME:'/hermes',...patch}))).toThrow()
+  }
+})