用途:把一个多实例 Hermes 部署方案与当前进展,完整交接给另一个 agent。读完这一份即可继续推进,无需回溯讨论。
状态快照:主实例与第一个用户实例均已跑通并已升级;第二个用户实例未开始;身份层(Cloudflare Access)仍处于共享测试态(待拆)。
本文档已脱敏:域名、内网 IP、端口、绝对路径等真实值一律用占位符 <…> 表示。占位符对照表见页首 §0.4。
把一台已在跑的 Hermes 从「单实例」改成「一人一容器、彼此隔离」,并把底层从「应用商店容器」迁到「容器编排(compose)自建」,顺带修掉一个会导致每次重启踢掉所有人的隐患。
「单容器 + 多 Profile」 —— Hermes 的 hermes serve --isolated 只提供应用层作用域,不提供文件系统隔离:agent 自带 terminal,同容器内可以 cat 到别人的记忆、会话、.env。给"别人"用不成立。完整论证见 第二部分。
*.md)—— 会把同目录下无关文件一起删掉。必须逐个列出目标路径再删。删除一律走回收站。| 占位符 | 含义 |
|---|---|
| <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 镜像名 |
| 项 | 值 |
|---|---|
| 面板 | 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-network(external: true,已存在) |
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。
| 项 | 主实例 | 用户 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。
一份 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>
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 的验收清单
0. 【必须先】计划任务 → 备份 → 手动执行一次
1. 编辑器里改 &hermes-image 那一行的 tag → 确认(全部实例一起重建)
单独升级某一个(灰度验证):
把那个 service 的 image 行从 *hermes-image 改成完整新 tag
其余实例配置没变 → 不重建
验证是否真的没被重建:看其它容器的「创建时间」有没有变
(1Panel 有可能加 --force-recreate —— 但即使重建,因为 secret 固定,用户不必重新登录)
升级 = 重建容器,但:
state.db schema 迁移)→ 升级前的备份才是唯一可靠退路这一节最重要 —— 全是花时间踩出来的,接手方不要重走。
| # | 约束 | 后果 |
|---|---|---|
| 1 | <<: 是浅合并(shallow merge) | 若锚点里有 environment,而 service 自己也写了 → service 那份整体替换锚点的,公共变量静默丢失。⇒ 每个 service 完整写 environment,锚点只用于 image 这类单值 |
| 2 | compose 会做 $ 变量插值 | secret / 密码含 $ 时,$XX 被当变量替换成空字符串,几乎无报错。加引号也救不了(插值在 YAML 解析之后)。⇒ secret 用纯字母数字或 base64url |
| 3 | 手工抄 compose 要删掉应用商店的 labels: createdBy: Apps | 那是"应用商店创建"标记,抄进自建编排会让面板归类错 |
| 4 | deploy.resources.limits 为 0/0 时直接省略 | 等效无限制,省两个坑 |
| 5 | 端口不要写 127.0.0.1: | 反代在另一台机器(走内网 IP),绑环回会连不上 |
| # | 事实 |
|---|---|
| 6 | 备份账号的默认目录在 1Panel 自己的数据目录里 → 卸载/重装面板会连它一起删 → 必须改到别处 |
| 7 | 删除 compose 编排会连 compose 目录一起删(官方文档原话)→ 数据目录必须在 compose 目录之外 |
| 8 | 应用商店容器的「编辑容器」页,改动会被商店操作覆盖 → 那个页面只用来"看" |
| 9 | 创建站点时 1Panel 已自动生成 / → 后端 的反代规则,不需要再手动加;重复加 / 会路径冲突 |
| 10 | WebSocket 三行不在反代规则表单里,是 1Panel 自动写进 nginx 配置的。要看/改去 网站 → 配置文件,找 location 段里的 proxy_http_version 1.1; + proxy_set_header Upgrade / Connection。缺了的表现:HTTP 全通但对话界面废掉 / 反复断连 |
| # | 事实 |
|---|---|
| 11 | 容器以 uid 10000 运行。启动时 entrypoint 会把数据目录 chown 10000:10000 并设 0700 —— 所以在文件管理器里给目录选什么属主都会被覆盖,不用纠结 |
| 12 | 推论:.env 权限不能设 0600 —— 容器 uid 10000 读不到,模型 key 失效。保持 0644 |
| # | 事实 |
|---|---|
| 13 | 策略 Action 必须是 Service Auth,写成 Allow 不生效 |
| 14 | 桌面端 < 0.17.6 不会把额外请求头带进 OAuth 登录窗口(已于 #110987 修复)。症状:配了 Service Token 却仍要求邮箱验证码 → 先升级桌面端,别去折腾 Access 配置 |
| 15 | Access 的一次性 PIN 只对"被某条 Allow 策略允许"的邮箱真正发信;其他邮箱页面照样显示"已发送"但不发。国内邮箱(163 / QQ)易被过滤,也可能被 suppression list 拦 |
| 16 | 一个 Access 应用挂多个 destination = 一个 Service Token 通吃所有入口。要实例级隔离,必须一个子域一个应用(token 数量只决定吊销粒度,不决定隔离边界) |
| 17 | 别用 Require → Country / IP list 收窄策略 —— 该环境常开代理,出口 IP 与地理位置随时变,等于随机自锁 |
凡是"你自己能随手生成"的凭据,就各给各的;只有"必须去第三方后台申请"的,才值得考虑共用。
| 凭据 | 谁能生成 | 结论 |
|---|---|---|
| provider API key | 要去 provider 后台开 | 暂时共用可接受,跑通后再拆 |
API_SERVER_KEY | 自己随机生成 | 各给各的。它是 Hermes OpenAI 兼容 HTTP API(/v1/chat/completions)的 Bearer 令牌,与 dashboard 登录无关 |
| dashboard 密码 | 自己定 | 各给各的 |
..._SECRET | 自己随机生成 | 各给各的(共用 = 一把密钥泄露可伪造多实例会话) |
| 现象 | 真相 |
|---|---|
| 桌面端设置页显示「暂时无法访问此网关…」 | 那是裸探(不带额外请求头)的结果,Access 后面必然报错,可忽略。判断连通只看带凭据的「测试」按钮 |
| 客户端显示"已登录"却报 ws-ticket 被拒 | 桌面端只看本地 cookie 存在与否、从不向服务端核实。清掉 %APPDATA%\Hermes\Partitions\hermes-remote-oauth\Network\Cookies(必须先完全退出桌面端,否则文件被锁) |
□ 重启该容器 → 桌面端不掉线、不用重新登录
□ 隔离性验收(两层):
UI 层:切到该用户的连接 → 会话 / 记忆 / 技能 / 项目 里没有任何主实例的东西
容器层:1Panel → 容器 → 终端
执行 ls <DATA_ROOT>/hermes
期望 No such file or directory
← 容器内只挂了该实例自己的数据目录为 /opt/data,
宿主机上的 <DATA_ROOT>/hermes 在容器内根本不存在(内核级不可见)
□ 在现共享的应用里删掉用户 1 这个 destination
□ 为用户 1 新建独立应用 + 独立 Service Token
□ 桌面端该连接换成自己的 token → 重测登录
红线:在把 token 交给任何用户之前,必须完成拆分。
见 §4.1 配方。
□ 卸载旧的应用商店容器(卸载时【不要】勾"删除数据目录")
□ 可选:Shell 脚本任务把最新一份备份复制到系统盘(当前源数据与备份在同一块数据盘上)
□ 可选:加 S3 兼容云存储账号做异地备份
□ 给每个用户写一份接入说明(URL + 用户名 + 密码 + 桌面端请求头怎么填)
<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 控制台(创建时只显示一次,由各用户保管)
| # | 问题 | 影响 |
|---|---|---|
| 1 | 是否给每个用户开专属 provider API key(当前共用一把) | 非阻塞;影响用量归因与吊销粒度 |
| 2 | 是否给用户开放 Hermes 网页 UI(当前只走桌面端) | 若开放,Access 策略需额外加 Allow + 邮箱,且要接受一次性 PIN 邮件链路不稳 |
| 3 | 是否做跨盘 / 异地备份 | 非阻塞;当前源数据与备份同在一块数据盘上 |
| 4 | 旧商店容器数据里是否有值得保留的自定义配置 | 决定 §6.4 卸载前是否需要先手工备份 |
评审对象:另一份提出的《Hermes 多用户隔离方案(待验证)》—— 单容器、多 Profile、多 serve 进程。
评审方式:对照 Hermes 实际安装实测 CLI 行为 + 读源码核实,非纸面推演。
评审结论:方案技术上成立,可落地;但它自己列出的备选方案(多容器)才是面向"不可信用户"的正确答案。
| 使用场景 | 单容器多 Profile | 结论 |
|---|---|---|
| 自己的多个 agent、完全互信的小圈子 | ✅ 够用,省资源 | 可采用 |
| 给"别人"用,需要数据彼此不可见 | ❌ 缺文件级隔离 | 改用多容器 |
分界线只有一条:对方的 API Key / 记忆被偶然看到,你介意吗? 不介意 → 单容器;介意 → 多容器。
| 原方案的假设 | 核实结果 | 证据 |
|---|---|---|
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参数存在,就直接认定整个生产部署方案已经具备自动重启、网络安全和完整权限隔离能力。"
① 命令写错:--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." 避免从旧资料抄到。
--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 当沙箱用。
| # | 问题 | 单容器 | 多容器 |
|---|---|---|---|
| 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 上完全看不出来。
原方案关注的是"端口是否冲突",但更关键的是机器级锁文件争用。实测 $HERMES_HOME 根下存在:
gateway.lock
auth.lock
kanban.db.dispatch.lock
kanban.db.init.lock
多个 serve 挤在同一个 HERMES_HOME 下时,是否会互相抢锁、谁先启动谁赢、后启动的会不会静默降级或挂起 —— 这是"能否稳定共处"的核心问题,比端口冲突重要得多,建议列为验证项 0。
① 身份层(Cloudflare Access):原方案的反代结构只画到"反代 → 内网端口",没有身份认证层。既然每个用户一个子域,每个子域应当对应一个独立的 Access 应用:
Allow 策略,只放那一个人的邮箱(或 IdP 身份)Service Auth,写成 Allow 不生效② 数据持久化:原方案把它列为"需要确认",但必须升级为阻断项 —— P1 已明确容器一定会被重建,若数据目录未正确挂载为持久卷,重建时所有用户的数据一起丢失。
若用户是"自己 / 完全互信的小圈子" → 可采用单容器多 Profile,但需先修正 B.3 的三处错误,并补上 B.6 的锁文件验证项。
若用户是"别人"(彼此需要数据不可见) → 直接采用多容器。 原方案自己列出的多容器优点,恰好一条不落地对应 B.4 / B.5 的问题:
多容器方案唯一的代价(容器数量增加、逐个配置、需确认镜像与卷的升级流程)是一次性配置成本,而单容器方案的代价是持续存在的安全边界缺失。这两者不对等。
整体评价:原方案方向正确,是官方支持的技术路径,验证清单也写得比多数同类方案扎实 —— 尤其把它自己没把握的点都显式列了出来,而不是当成已知条件。真正的问题不在技术选型,而在把"应用层作用域"误当成了"安全隔离"。