Hermes 多用户隔离 · 实施交接 脱敏版

用途:把一个多实例 Hermes 部署方案与当前进展,完整交接给另一个 agent。读完这一份即可继续推进,无需回溯讨论。

状态快照:主实例与第一个用户实例均已跑通并已升级;第二个用户实例未开始;身份层(Cloudflare Access)仍处于共享测试态(待拆)。

本文档已脱敏:域名、内网 IP、端口、绝对路径等真实值一律用占位符 <…> 表示。占位符对照表见页首 §0.4。

第一部分 · 实施交接

1. 交接须知与环境

1.1 任务是什么

把一台已在跑的 Hermes 从「单实例」改成「一人一容器、彼此隔离」,并把底层从「应用商店容器」迁到「容器编排(compose)自建」,顺带修掉一个会导致每次重启踢掉所有人的隐患。

1.2 已被否决的方案

「单容器 + 多 Profile」 —— Hermes 的 hermes serve --isolated 只提供应用层作用域,不提供文件系统隔离:agent 自带 terminal,同容器内可以 cat 到别人的记忆、会话、.env。给"别人"用不成立。完整论证见 第二部分

1.3 协作方式(用户偏好,务必遵守)

1.4 占位符对照

占位符含义
<DOMAIN>主域名
<SUB_MAIN>主实例子域
<SUB_U1> / <SUB_U2>用户 1 / 用户 2 子域
<SUB_PANEL>1Panel 面板入口域名
<TEAM>Cloudflare Zero Trust 组织名(team domain)
<A_IP>Hermes 服务器 A 的内网 IP
<PORT_MAIN> / <PORT_U1> / <PORT_U2>主实例 / 用户 1 / 用户 2 的宿主端口
<DATA_ROOT>数据盘挂载点
<IMG>Hermes 镜像名

1.5 环境事实(已实测)

面板1Panel v2 国内版,A、B 两台服务器各自都有一个
1Panel 数据目录<DATA_ROOT>/1panel —— 装在数据盘,不是 /opt
服务器 A跑 Docker 容器;内网 IP <A_IP>
服务器 B跑反向代理(1Panel + OpenResty);回源地址形如 http://<A_IP>:<PORT_x>(实测确认走内网 IP)
磁盘系统盘 /数据盘独立挂载在 <DATA_ROOT>
镜像<IMG>:<日期 tag>,ARM64,已验证可用
容器内运行用户uid 10000(见 §5.3
外部 Docker 网络1panel-networkexternal: true,已存在)

2. 目标架构

Windows Hermes Desktop
    ↓
Cloudflare(Access 身份层)
    ↓
反向代理服务器 B(1Panel + OpenResty)
    ↓  走内网
Hermes 服务器 A  (<A_IP>)(1Panel + Docker)
    ├── 容器 hermes-default   <PORT_MAIN> → 9119   <DATA_ROOT>/hermes/default   <SUB_MAIN>.<DOMAIN>
    ├── 容器 hermes-user1     <PORT_U1>   → 9119   <DATA_ROOT>/hermes/user1     <SUB_U1>.<DOMAIN>
    └── 容器 hermes-user2     <PORT_U2>   → 9119   <DATA_ROOT>/hermes/user2     <SUB_U2>.<DOMAIN>   ← 待做

隔离单位 = 容器。 每用户独立容器、独立数据目录、独立 dashboard 凭据、独立 Service Token。


3. 当前进展

主实例用户 1
数据目录<DATA_ROOT>/hermes/default<DATA_ROOT>/hermes/user1
宿主端口<PORT_MAIN><PORT_U1>
容器状态运行中运行中
.env(模型 key)已配已配(API_SERVER_KEY 已换独立值)
容器层验收 auth_required:true
桌面端登录 + 对话
子域名 + 反代
Cloudflare DNS
Cloudflare Access⚠️ 与用户 1 共用同一个应用(测试态)⚠️ 同上 —— 必须拆
重启不掉线验收❓ 待补
隔离性验收❓ 待补(见 §6.1
前后端版本已升级到最新已升级到最新(两实例一致)

旧的「应用商店容器」:已停止但未删除,数据留在应用商店自己的目录下。保留作回滚退路与配置参照,全部跑通后再卸载

备份:1Panel 计划任务(类型:备份目录;备份内容 <DATA_ROOT>/hermes;每天一次;保留 14 份;已设压缩密码)。备份账号「本机」的备份目录已从 1Panel 默认位置(在 1Panel 自己的数据目录里)移到 <DATA_ROOT>/hermes-backup


4. 编排文件(当前实际形态)

一份 compose 文件、多个 service,用 YAML 别名复用镜像 tag。

networks:
  1panel-network:
    external: true

services:
  hermes-default:
    image: &hermes-image <IMG>:<tag>        # ← 定义别名;升级只改这一行
    container_name: hermes-default
    restart: always
    networks:
      - 1panel-network
    ports:
      - "<PORT_MAIN>:9119"
    volumes:
      - <DATA_ROOT>/hermes/default:/opt/data
      - /etc/localtime:/etc/localtime
    environment:
      HERMES_DASHBOARD: "1"
      HERMES_DASHBOARD_HOST: 0.0.0.0
      HERMES_DASHBOARD_PORT: "9119"
      HERMES_HOME: /opt/data
      HOME: /opt/data/home
      TERM: xterm-256color
      HERMES_DASHBOARD_BASIC_AUTH_USERNAME: <主实例用户名>
      HERMES_DASHBOARD_BASIC_AUTH_PASSWORD: <主实例密码>
      HERMES_DASHBOARD_BASIC_AUTH_SECRET:   <主实例 SECRET>

  hermes-user1:
    image: *hermes-image                     # ← 引用别名
    container_name: hermes-user1
    restart: always
    networks:
      - 1panel-network
    ports:
      - "<PORT_U1>:9119"
    volumes:
      - <DATA_ROOT>/hermes/user1:/opt/data
      - /etc/localtime:/etc/localtime
    environment:
      HERMES_DASHBOARD: "1"
      HERMES_DASHBOARD_HOST: 0.0.0.0
      HERMES_DASHBOARD_PORT: "9119"
      HERMES_HOME: /opt/data
      HOME: /opt/data/home
      TERM: xterm-256color
      HERMES_DASHBOARD_BASIC_AUTH_USERNAME: user1
      HERMES_DASHBOARD_BASIC_AUTH_PASSWORD: <用户1 密码>
      HERMES_DASHBOARD_BASIC_AUTH_SECRET:   <用户1 SECRET>

4.1 加一个新实例的完整配方

1. 文件管理器建目录 <DATA_ROOT>/hermes/<用户名>
     权限 0755 默认即可;属主不用纠结(容器会自己 chown,见 §5.3)

2. 编排 → 编辑 → 在 services: 下追加:

  hermes-<用户名>:
    image: *hermes-image
    container_name: hermes-<用户名>
    restart: always
    networks:
      - 1panel-network
    ports:
      - "<新端口>:9119"
    volumes:
      - <DATA_ROOT>/hermes/<用户名>:/opt/data
      - /etc/localtime:/etc/localtime
    environment:
      HERMES_DASHBOARD: "1"
      HERMES_DASHBOARD_HOST: 0.0.0.0
      HERMES_DASHBOARD_PORT: "9119"
      HERMES_HOME: /opt/data
      HOME: /opt/data/home
      TERM: xterm-256color
      HERMES_DASHBOARD_BASIC_AUTH_USERNAME: <用户名>
      HERMES_DASHBOARD_BASIC_AUTH_PASSWORD: <密码>
      HERMES_DASHBOARD_BASIC_AUTH_SECRET:   <SECRET>

3. 确认 → 容器起起来

4. 复制主实例的 .env 到新实例的 .env
     ★ API_SERVER_KEY 换成一个新生成的值
     ★ 权限保持 0644,绝不能设 0600

5. 重启新容器

6. Cloudflare:DNS 加一条记录(照抄主实例那条:类型/目标/代理开关一致)

7. Cloudflare:新建 Access 应用 + 独立的 Service Token

8. 服务器 B:1Panel → 网站 → 创建反向代理
     主域名 <新子域>.<DOMAIN>  →  http://<A_IP>:<新端口>
     · 证书照主实例站点配(否则 Cloudflare 回源 502/526)
     · 创建站点时 1Panel 会自动生成 "/ → 后端" 的规则,不要再手动加 "/"

9. 桌面端:注册新网关,填 URL + 该实例自己的 CF-Access 请求头

10. 走 §6.1 的验收清单

4.2 升级后端

0. 【必须先】计划任务 → 备份 → 手动执行一次
1. 编辑器里改 &hermes-image 那一行的 tag → 确认(全部实例一起重建)

单独升级某一个(灰度验证):
   把那个 service 的 image 行从 *hermes-image 改成完整新 tag
   其余实例配置没变 → 不重建
   验证是否真的没被重建:看其它容器的「创建时间」有没有变
   (1Panel 有可能加 --force-recreate —— 但即使重建,因为 secret 固定,用户不必重新登录)

升级 = 重建容器,但


5. ★ 硬约束与已踩过的坑

这一节最重要 —— 全是花时间踩出来的,接手方不要重走。

5.1 YAML / compose

#约束后果
1<<: 是浅合并(shallow merge)若锚点里有 environment,而 service 自己也写了 → service 那份整体替换锚点的,公共变量静默丢失。⇒ 每个 service 完整写 environment,锚点只用于 image 这类单值
2compose 会做 $ 变量插值secret / 密码含 $ 时,$XX 被当变量替换成空字符串,几乎无报错。加引号也救不了(插值在 YAML 解析之后)。⇒ secret 用纯字母数字或 base64url
3手工抄 compose 要删掉应用商店的 labels: createdBy: Apps那是"应用商店创建"标记,抄进自建编排会让面板归类错
4deploy.resources.limits0/0直接省略等效无限制,省两个坑
5端口不要写 127.0.0.1:反代在另一台机器(走内网 IP),绑环回会连不上

5.2 1Panel 行为

#事实
6备份账号的默认目录在 1Panel 自己的数据目录里 → 卸载/重装面板会连它一起删 → 必须改到别处
7删除 compose 编排会连 compose 目录一起删(官方文档原话)→ 数据目录必须在 compose 目录之外
8应用商店容器的「编辑容器」页,改动会被商店操作覆盖 → 那个页面只用来"看"
9创建站点时 1Panel 已自动生成 / → 后端 的反代规则,不需要再手动加;重复加 / 会路径冲突
10WebSocket 三行不在反代规则表单里,是 1Panel 自动写进 nginx 配置的。要看/改去 网站 → 配置文件,找 location 段里的 proxy_http_version 1.1; + proxy_set_header Upgrade / Connection。缺了的表现:HTTP 全通但对话界面废掉 / 反复断连

5.3 容器与文件权限

#事实
11容器以 uid 10000 运行。启动时 entrypoint 会把数据目录 chown 10000:10000 并设 0700 —— 所以在文件管理器里给目录选什么属主都会被覆盖,不用纠结
12推论:.env 权限不能设 0600 —— 容器 uid 10000 读不到,模型 key 失效。保持 0644

5.4 Cloudflare Access

#事实
13策略 Action 必须是 Service Auth,写成 Allow 不生效
14桌面端 < 0.17.6 不会把额外请求头带进 OAuth 登录窗口(已于 #110987 修复)。症状:配了 Service Token 却仍要求邮箱验证码 → 先升级桌面端,别去折腾 Access 配置
15Access 的一次性 PIN 只对"被某条 Allow 策略允许"的邮箱真正发信;其他邮箱页面照样显示"已发送"但不发。国内邮箱(163 / QQ)易被过滤,也可能被 suppression list 拦
16一个 Access 应用挂多个 destination = 一个 Service Token 通吃所有入口。要实例级隔离,必须一个子域一个应用(token 数量只决定吊销粒度,不决定隔离边界)
17别用 Require → Country / IP list 收窄策略 —— 该环境常开代理,出口 IP 与地理位置随时变,等于随机自锁

5.5 凭据设计规则

凡是"你自己能随手生成"的凭据,就各给各的;只有"必须去第三方后台申请"的,才值得考虑共用。

凭据谁能生成结论
provider API key要去 provider 后台开暂时共用可接受,跑通后再拆
API_SERVER_KEY自己随机生成各给各的。它是 Hermes OpenAI 兼容 HTTP API/v1/chat/completions)的 Bearer 令牌,与 dashboard 登录无关
dashboard 密码自己定各给各的
..._SECRET自己随机生成各给各的(共用 = 一把密钥泄露可伪造多实例会话)

5.6 排障时别被误导

现象真相
桌面端设置页显示「暂时无法访问此网关…」那是裸探(不带额外请求头)的结果,Access 后面必然报错,可忽略。判断连通只看带凭据的「测试」按钮
客户端显示"已登录"却报 ws-ticket 被拒桌面端只看本地 cookie 存在与否、从不向服务端核实。清掉 %APPDATA%\Hermes\Partitions\hermes-remote-oauth\Network\Cookies必须先完全退出桌面端,否则文件被锁)

6. 待办与验收

6.1 用户 1 实例的两项验收(补做)

□ 重启该容器 → 桌面端不掉线、不用重新登录
□ 隔离性验收(两层):
    UI 层:切到该用户的连接 → 会话 / 记忆 / 技能 / 项目 里没有任何主实例的东西
    容器层:1Panel → 容器 → 终端
            执行  ls <DATA_ROOT>/hermes
            期望  No such file or directory
            ← 容器内只挂了该实例自己的数据目录为 /opt/data,
              宿主机上的 <DATA_ROOT>/hermes 在容器内根本不存在(内核级不可见)

6.2 拆分 Cloudflare Access(❗红线)

□ 在现共享的应用里删掉用户 1 这个 destination
□ 为用户 1 新建独立应用 + 独立 Service Token
□ 桌面端该连接换成自己的 token → 重测登录

红线:在把 token 交给任何用户之前,必须完成拆分。

6.3 加用户 2

§4.1 配方。

6.4 收尾

□ 卸载旧的应用商店容器(卸载时【不要】勾"删除数据目录")
□ 可选:Shell 脚本任务把最新一份备份复制到系统盘(当前源数据与备份在同一块数据盘上)
□ 可选:加 S3 兼容云存储账号做异地备份
□ 给每个用户写一份接入说明(URL + 用户名 + 密码 + 桌面端请求头怎么填)

7. 关键路径速查

<DATA_ROOT>/hermes/<实例>          ← 数据(容器内 = /opt/data = HERMES_HOME)
<DATA_ROOT>/hermes/<实例>/.env     ← 该实例的模型 API key(权限 0644!)
<DATA_ROOT>/1panel/docker/compose/<编排名>/docker-compose.yml   ← 编排文件
<DATA_ROOT>/hermes-backup          ← 备份落点
(旧的)应用商店容器数据目录        ← 保留作参照,跑通后再卸

容器内:/opt/data(HERMES_HOME)、/opt/data/home(HOME)

凭据真实值的位置

dashboard 用户名 / 密码 / SECRET → 编排文件 docker-compose.yml 的 environment(明文)
模型 API key / API_SERVER_KEY    → <DATA_ROOT>/hermes/<实例>/.env
Cloudflare Service Token         → Cloudflare 控制台(创建时只显示一次,由各用户保管)

8. 未决问题

#问题影响
1是否给每个用户开专属 provider API key(当前共用一把)非阻塞;影响用量归因与吊销粒度
2是否给用户开放 Hermes 网页 UI(当前只走桌面端)若开放,Access 策略需额外加 Allow + 邮箱,且要接受一次性 PIN 邮件链路不稳
3是否做跨盘 / 异地备份非阻塞;当前源数据与备份同在一块数据盘
4旧商店容器数据里是否有值得保留的自定义配置决定 §6.4 卸载前是否需要先手工备份


第二部分 · 方案评审:为什么放弃「单容器多 Profile」

评审对象:另一份提出的《Hermes 多用户隔离方案(待验证)》—— 单容器、多 Profile、多 serve 进程。

评审方式:对照 Hermes 实际安装实测 CLI 行为 + 读源码核实,非纸面推演。

评审结论方案技术上成立,可落地;但它自己列出的备选方案(多容器)才是面向"不可信用户"的正确答案。

B.1 一句话结论

使用场景单容器多 Profile结论
自己的多个 agent、完全互信的小圈子✅ 够用,省资源可采用
给"别人"用,需要数据彼此不可见❌ 缺文件级隔离改用多容器

分界线只有一条:对方的 API Key / 记忆被偶然看到,你介意吗? 不介意 → 单容器;介意 → 多容器。

B.2 已核实成立的部分

原方案的假设核实结果证据
hermes serve --isolated 存在且语义正确 CLI 帮助原文:"When launched from a named profile, run a dedicated server scoped to that profile instead of routing to the machine-level server. Default behavior is unified: profile launches attach to (or start) ONE machine-level server and preselect the profile."
默认行为是"一个机器级 server + 预选 profile" 同上帮助文本
serve --status 可用于检查 ✅ 存在 但语义是"所有 running Hermes web server processes",是机器级而非单实例级
多个 isolated serve 共享状态库是受支持形态 hermes_state_common.py 注释:"multi-backend state.db shared by isolated serve processes",靠 heartbeat 表判定后端存活
--host 0.0.0.0 对外绑定可行 但加固后 public bind 强制要求 auth provider(密码或 OAuth),--insecure 已废弃且为 no-op

架构判断(单容器 vs 多容器)本身没有走错 —— --isolated 是官方为这个目的提供的开关,方向正确。原方案最后那句风险提示尤其值得肯定:

"不能仅凭 --isolated 参数存在,就直接认定整个生产部署方案已经具备自动重启、网络安全和完整权限隔离能力。"

B.3 必须修正的三处具体错误

① 命令写错:--profile 是顶层参数,不是 serve 的参数。

它必须放在子命令之前(源码中的调用形态为 ["--profile", name]["serve", "--isolated", …])。短选项 -p 在 CLI 帮助与源码中均未找到,应使用长形式:

hermes --profile <名字> serve --isolated --host 0.0.0.0 --port <端口>

② 数据目录画错:default profile 不在子目录里。

原方案画的是 <数据根>/default/ + profiles/<名字>。实际布局是:default profile 的数据直接铺在 HERMES_HOME 根下,命名 profile 才在 profiles/<name>

$HERMES_HOME/
├── config.yaml        ← default profile 的配置
├── skills/  memories/  sessions/
├── state.db  logs/  plugins/  cron/  kanban.db
└── profiles/
    ├── <用户1>/
    └── <用户2>/

影响:挂载点、备份脚本、迁移流程都会写错。

--insecure 已废弃。 CLI 帮助原文:"DEPRECATED / NO-OP. Formerly bypassed auth on a non-loopback bind. As of the June 2026 hardening it no longer disables authentication — a public bind always requires an auth provider." 避免从旧资料抄到。

B.4 P0:单容器不是安全边界(这条决定方案适用范围)

--isolated 提供的是"应用层作用域",不是"文件系统隔离"。

它保证:HTTP / WebSocket 服务只暴露自己那个 profile,客户端在 UI 上看不到其他 profile 的会话与配置。

不保证:Hermes agent 自带 terminal 与文件工具,且跑在同一个容器内。因此:

# 用户 1 的 agent 在容器内可以执行:
cat /opt/data/profiles/<用户2>/config.yaml
cat /opt/data/profiles/<用户2>/memories/*
cat /opt/data/profiles/<用户2>/.env          # 用户 2 的 API Key

全部可读。

对原方案"用户 A 无法访问用户 B 的会话和 Profile"这一目标:只在客户端层面成立,在容器内部不成立

而且"大家都不主动去读"不等于安全——读取能力存在就一定会被意外触发,因为这是 agent 的常态行为:

触发场景后果
用户说"帮我在目录里搜一下 xxx" → agent 用 grep -r / find / 全盘扫扫过 profiles/,他人的记忆与 .env 内容进入对话
agent 排查磁盘/权限问题 → ls -la /opt/data暴露他人目录结构
某技能或脚本写死绝对路径,或 cd /opt/data 后相对路径乱走越界读取

这些都不是恶意,是正常操作。而且一旦内容进入某个 session,无法收回

--isolated 隔离的是"服务作用域",不是"文件系统"。容器内任何 agent 都能读到其他 profile 的数据 —— 把它当权限边界,等于把 Docker 当沙箱用。

B.5 P1–P3:单容器方案的三个工程硬伤

#问题单容器多容器
P1 端口映射 Docker 容器创建后无法追加端口映射,必须重建容器 → 重建会触发已知问题:若未显式固定 dashboard 的签名 secret,所有已发出的会话立即失效 各自独立创建,互不影响
P2 进程管理 容器 entrypoint 只负责一个 serve。多 serve 需自建 s6 / supervisor / 启动脚本,且镜像升级可能覆盖hermes serve --stop机器级命令,会一次性停掉全部 serve 进程 面板原生管重启、升级、监控
P3 凭据隔离 HERMES_DASHBOARD_BASIC_AUTH_*进程级环境变量,一个容器内为多个 serve 配多套凭据很难做对;且 env 非空时会覆盖 config.yaml,因此必须保证容器不设这些 env,改为在每个 profile 自己的 config.yaml 里配置 env 天然按容器分离

P3 的实操陷阱:如果容器保留了 HERMES_DASHBOARD_BASIC_AUTH_USERNAME/PASSWORD 这两个 env,那么所有 profile 的 serve 会共用同一套凭据 —— 隔离在认证层面直接失效,且从 UI 上完全看不出来。

B.6 原方案的验证清单里遗漏的一项

原方案关注的是"端口是否冲突",但更关键的是机器级锁文件争用。实测 $HERMES_HOME 根下存在:

gateway.lock
auth.lock
kanban.db.dispatch.lock
kanban.db.init.lock

多个 serve 挤在同一个 HERMES_HOME 下时,是否会互相抢锁、谁先启动谁赢、后启动的会不会静默降级或挂起 —— 这是"能否稳定共处"的核心问题,比端口冲突重要得多,建议列为验证项 0。

B.7 原方案完全未覆盖的两点

① 身份层(Cloudflare Access):原方案的反代结构只画到"反代 → 内网端口",没有身份认证层。既然每个用户一个子域,每个子域应当对应一个独立的 Access 应用:

② 数据持久化:原方案把它列为"需要确认",但必须升级为阻断项 —— P1 已明确容器一定会被重建,若数据目录未正确挂载为持久卷,重建时所有用户的数据一起丢失

B.8 建议

若用户是"自己 / 完全互信的小圈子" → 可采用单容器多 Profile,但需先修正 B.3 的三处错误,并补上 B.6 的锁文件验证项。

若用户是"别人"(彼此需要数据不可见)直接采用多容器。 原方案自己列出的多容器优点,恰好一条不落地对应 B.4 / B.5 的问题:

多容器方案唯一的代价(容器数量增加、逐个配置、需确认镜像与卷的升级流程)是一次性配置成本,而单容器方案的代价是持续存在的安全边界缺失。这两者不对等。

整体评价:原方案方向正确,是官方支持的技术路径,验证清单也写得比多数同类方案扎实 —— 尤其把它自己没把握的点都显式列了出来,而不是当成已知条件。真正的问题不在技术选型,而在把"应用层作用域"误当成了"安全隔离"


本文档为脱敏版本:所有域名、IP、端口、绝对路径已替换为占位符。文中所有 CLI 行为、配置字段、官方文档引用均可复现核对。