Browse Source

修复 Docker 映射模式下工具桥安装失败

Samien 1 month ago
parent
commit
bffb9e3d31

+ 20 - 0
docs/docker-install.md

@@ -86,6 +86,7 @@ docker compose --env-file docker.env -f compose.yaml -f compose.hermes-bridge.ya
 - 选择与 `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 程序不会被执行。
+- 映射模式只安装共享数据目录中的工具桥,不执行 `--repair-profile-runtime`。Profile 模型兼容修复必须在实际运行 Hermes 的容器或节点,使用其 Python 单独执行;页面会明确提示这一点。
 - 安装前备份插件与配置,先安装默认 Profile 的后台入口,再处理命名 Profile。安装后在 Hermes 所在节点重启服务,再在 Web 点击“重新检查”。
 - 映射不会赋予 Web 管理 Docker 或 Hermes 进程的能力;既有 Compose 桌面数量、隔离设置及生命周期继续按原配置管理。
 
@@ -101,6 +102,25 @@ docker compose --env-file docker.env -f compose.yaml -f compose.hermes-bridge.ya
 
 仅设置目录但没有开启 `BRIDGE_MOUNTED` 时,外部 Hermes 仍只提供状态检查,不开放目录安装。
 
+### Docker 安装报 Profile 模型兼容修复失败
+
+旧版 Web 安装接口会无条件添加 `--repair-profile-runtime`。当 Hermes 与夭夭运行在不同容器、仅共享数据目录时,夭夭容器的 Python 找不到 `tui_gateway`,即使目录可写也会报“工具桥或 Profile 模型兼容修复安装失败”。应更新到包含修复的 Web 镜像,或从当前源码构建;添加目录映射、反复重启不会修复旧版接口。
+
+暂未更新镜像时,可直接调用镜像内的安装器,仅安装工具桥。以下假设容器名为 `yaoyao`、`hermes-agent`,共享目录在夭夭中为 `/hermes`:
+
+```sh
+docker exec yaoyao /usr/bin/python3 /app/dist-server/runner/install-hermes-bridge.py \
+  --hermes-home /hermes --profile default --enable
+```
+
+每个使用中的命名 Profile 也需安装,将 `default` 替换为对应名称;先完成默认 Profile。命令会备份配置和旧插件,失败会直接显示具体原因。安装成功后,在空闲时执行 `docker restart hermes-agent`,再回 Web 重新检查。
+
+此命令不修复 Hermes 核心模型解析。需要该修复时,将同一 Web 镜像中的安装器和插件包复制到 Hermes 容器,使用运行 Hermes 的 Python 执行安装器并添加 `--repair-profile-runtime`。修复需要 Hermes 源码写入权限,并会先验证兼容性;验证失败时不修改核心文件。Hermes 程序位于容器自身文件系统时,核心修复不会随共享数据目录持久化,重建或升级 Hermes 容器后需重新检查。
+
+共享数据目录还需要匹配两个服务的运行 UID/GID。可用 `docker exec yaoyao id` 和 Hermes 容器中实际服务用户的 `id` 核对。安装器会原子替换配置文件,只有旧文件的写权限并不足够,新的配置也必须能由 Hermes 服务用户读取;优先对齐两端运行 UID/GID,不要仅放宽旧文件权限。
+
+若两个服务在同一 Compose 网络,直接设置 `HERMES_YAOYAO_UPSTREAM: http://hermes-agent:9119`。宿主机发布的 `9120:9119` 用于从宿主机访问;不需要把 `hermes-agent` 通过 `extra_hosts` 覆盖成宿主机网关。
+
 ## 验证
 
 以下命令使用默认发布端口;修改端口后同步调整 URL:

+ 1 - 1
integrations/hermes-bots-bridge/README.md

@@ -18,7 +18,7 @@ Yaoyao 的虚拟机模式和本机协作模式都使用真实 Hermes Profile 会
 
 ## 安装
 
-Yaoyao 管理员可从“设置 → Hermes 连接 → 工具桥插件”执行检查和安装。安装会同时备份并修复 Hermes 的 Profile 模型解析与会话元信息。默认 Profile 提供共享后台入口,应先安装默认入口,再处理命名 Profile。界面依据磁盘与已加载插件代码的指纹区分待重启与已就绪。
+Yaoyao 管理员可从“设置 → Hermes 连接 → 工具桥插件”执行检查和安装。本机安装会同时备份并修复 Hermes 的 Profile 模型解析与会话元信息;Docker 映射模式只安装共享数据目录中的插件,模型兼容修复需在运行 Hermes 的容器或节点单独执行,见 [Docker 安装说明](../../docs/docker-install.md#docker-安装报-profile-模型兼容修复失败)。默认 Profile 提供共享后台入口,应先安装默认入口,再处理命名 Profile。界面依据磁盘与已加载插件代码的指纹区分待重启与已就绪。
 
 
 使用 Hermes 的 Python,从 Yaoyao 仓库运行:

+ 6 - 4
src/server/hermesBridge.ts

@@ -113,7 +113,7 @@ export class HermesBridgeManager {
         !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 所在节点重启服务。`}:{})}
+        this.mapped?{message:`已映射 Hermes 目录:${this.home}。此处仅安装工具桥;Profile 模型兼容修复需在 Hermes 所在容器或节点单独执行。完成后请重启 Hermes 服务。`}:{})}
   }
   async install(value:unknown):Promise<HermesBridgeInstallResult>{
     const parsed=installInput.safeParse(value)
@@ -130,15 +130,17 @@ export class HermesBridgeManager {
       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')
-      const output=await this.run(this.python,[this.script,'--hermes-home',this.home,'--profile',parsed.data.profile,'--repair-profile-runtime',...(parsed.data.enable?['--enable']:[])])
+      // A mapped data directory does not expose the upstream Hermes runtime.
+      // Repair core files only with the local Hermes interpreter.
+      const output=await this.run(this.python,[this.script,'--hermes-home',this.home,'--profile',parsed.data.profile,...(this.mapped?[]:['--repair-profile-runtime']),...(parsed.data.enable?['--enable']:[])])
       result=JSON.parse(output.trim())
       if(result.profile!==parsed.data.profile||typeof result.backup!=='string')throw new Error('invalid installer result')
     }catch(error){
       if(error instanceof HttpError)throw error
-      throw new HttpError(409,'工具桥或 Profile 模型兼容修复安装失败,请检查 Hermes 版本兼容性与目录权限后重新检查状态。','hermes_bridge_install_failed')
+      throw new HttpError(409,this.mapped?'工具桥安装失败,请检查共享 Hermes 目录的读写权限及 Profile 配置后重新检查状态。':'工具桥或 Profile 模型兼容修复安装失败,请检查 Hermes 版本兼容性与目录权限后重新检查状态。','hermes_bridge_install_failed')
     }finally{this.installing=undefined}
     const message=(result.disabled?'插件文件已更新,保留了禁用设置。':'工具桥已安装并启用。')
-      +(result.profileRuntimeRepaired?'已备份并应用 Profile 模型兼容修复。':'')
+      +(this.mapped?'此处仅安装工具桥;Profile 模型兼容修复需在 Hermes 所在容器或节点单独执行。':result.profileRuntimeRepaired?'已备份并应用 Profile 模型兼容修复。':'')
       +'请在空闲时重启 Hermes Dashboard 服务,然后重新检查。'
     return {profile:parsed.data.profile,backup:result.backup,message,status:await this.status()}
   }

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

@@ -56,6 +56,7 @@ it.skipIf(!image)('installs bridges into mapped Hermes storage through the real
     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')
+    expect(installed.message).toContain('Profile 模型兼容修复需在 Hermes 所在容器或节点单独执行')
     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')
@@ -65,6 +66,7 @@ it.skipIf(!image)('installs bridges into mapped Hermes storage through the real
     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('exec',web,'python3','-c','import importlib.util;print(importlib.util.find_spec("tui_gateway"))')).toBe('None')
     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)

+ 4 - 1
tests/server/hermesBridge.test.ts

@@ -250,9 +250,12 @@ it('uses explicitly mapped Hermes storage and the container interpreter with an
   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 installed=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/')
+  expect(call[1]).not.toContain('--repair-profile-runtime')
+  expect(installed.message).toContain('Profile 模型兼容修复需在 Hermes 所在容器或节点单独执行')
+  expect(installed.message).not.toContain('已备份并应用 Profile 模型兼容修复')
 })