systemd 显示 Gateway 正在运行,飞书却始终连不上。最后发现,问题不在凭据,而在新 Profile 的服务没有继承代理环境变量。


问题现象

我为 Hermes 新建了 blog-writer Profile,并为它启动了独立的 systemd 用户服务:

1
systemctl --user status hermes-gateway-blog-writer.service

服务状态显示为 active (running),乍看没有异常。但 Gateway 日志中的飞书连接会等待 30 秒后超时:

1
2
feishu connect timed out after 30s
Gateway started with no connected platforms — 1 platform(s) queued for retry

后续自动重连也没有成功:

1
Reconnect feishu error: feishu connect timed out after 30s, next retry in 60s

这里最容易被 active 误导。它只能说明 Gateway 主进程没有退出,不能证明飞书适配器已经建立 WebSocket 连接。即使所有消息平台都连接失败,Gateway 仍可继续运行定时任务,因此 systemd 不会把它判为失败。


排查思路

先确认服务是否活着,再确认平台是否真的连上。这两个状态不能混在一起判断。

我按下面的顺序排查:

  • 使用 systemctl --user status 确认进程没有崩溃或反复重启。
  • 使用 journalctl 查看启动阶段的完整日志,确认失败点是飞书 WebSocket 连接超时,而不是 Hermes 配置解析失败。
  • 检查 blog-writer Profile 的飞书配置是否已被 Gateway 读取,但不在终端或文章中输出任何凭据。
  • 对比默认 Gateway 与新 Profile Gateway 的 systemd 环境变量和 drop-in 配置,包括 HOME、XDG 路径和代理变量。
  • 重启服务后,以飞书连接成功日志作为最终判断依据,而不是只看 systemd 的 active 状态。

查看日志可以使用:

1
journalctl --user -u hermes-gateway-blog-writer.service -n 100 --no-pager

对比服务加载的 drop-in 和环境变量可以使用:

1
2
3
4
5
systemctl --user show hermes-gateway.service \
-p DropInPaths -p Environment --no-pager

systemctl --user show hermes-gateway-blog-writer.service \
-p DropInPaths -p Environment --no-pager

对比结果很直接:默认服务加载了自己的代理 drop-in,新服务最初没有。


非标准 HOME/XDG 路径场景

代理变量不是唯一的排查方向。某些服务器账号的登录目录不在 /home 下,systemd 用户管理器里的 HOME 却仍然保留着账号记录中的旧路径,这也会让飞书适配器不断重连。

例如,账号记录的 HOME 是:

1
/home/tlj

实际用户目录却是:

1
/mnt/nfs_share/tlj

如果 Gateway 进程继承了错误的 HOME=/home/tlj,Hermes 会尝试在下面的默认状态目录创建 Gateway 锁文件:

1
~/.local/state/hermes/gateway-locks

此时日志可能出现:

1
PermissionError: [Errno 13] Permission denied: '/home/tlj'

表面上看,飞书适配器仍在反复重连;实际失败点并不是 WebSocket 网络连接,而是进程无法创建状态目录。只检查代理或飞书凭据,很容易绕过这个真正的错误。

排查时应同时比较 Gateway 服务中的以下变量:

1
2
systemctl --user show hermes-gateway-blog-writer.service \
-p Environment --no-pager
  • HOME 应指向实际可读写的用户目录。
  • XDG_CONFIG_HOME 应指向 Hermes 能读取配置的目录。
  • XDG_STATE_HOME 应指向可创建 hermes/gateway-locks 的状态目录。
  • XDG_CACHE_HOMEXDG_DATA_HOME 也应与实际用户目录一致。
  • HTTP_PROXYHTTPS_PROXYALL_PROXYNO_PROXY 及其小写版本应符合当前网络环境。

也可以直接查看正在运行的 Gateway 进程环境。先通过 systemctl --user status 找到 Main PID,再执行:

1
2
tr '\0' '\n' < /proc/<MainPID>/environ \
| grep -E '^(HOME|XDG_(CONFIG|STATE|CACHE|DATA)_HOME|HTTP_PROXY|HTTPS_PROXY|ALL_PROXY|NO_PROXY|http_proxy|https_proxy|all_proxy|no_proxy)='

不要把完整环境直接贴到公开位置,其中可能包含其他凭据。


根因

Clash 代理变量原本配置在 ~/.bashrc 中,所以从交互式 Shell 启动的命令可以正常访问外网。但 WSL 中由 systemd 用户管理器启动的服务不会读取 ~/.bashrc,它拿不到这些代理变量。

默认 Gateway 之所以能连接飞书,是因为它已有独立的 drop-in:

1
~/.config/systemd/user/hermes-gateway.service.d/proxy.conf

新 Profile 使用的是另一个 systemd unit:

1
hermes-gateway-blog-writer.service

它对应的 drop-in 目录也必须使用自己的 unit 名称。默认服务的配置不会因为两个服务都属于 Hermes 就自动继承给新服务。

因此,这次故障的完整链路是:

  • blog-writer Gateway 由 systemd 用户服务启动,没有读取 ~/.bashrc
  • 新 unit 没有独立的代理 drop-in,进程环境中缺少 Clash 代理变量。
  • Gateway 主进程能够正常启动,所以 systemd 显示 active
  • 飞书 WebSocket 无法在 30 秒内建立连接,平台适配器进入重试队列。

在非标准 HOME/XDG 场景中,故障链路略有不同:

  • systemd 用户管理器为 Gateway 注入了错误的 HOME,或者 XDG 目录仍指向旧路径。
  • Hermes 尝试创建 ~/.local/state/hermes/gateway-locks 时触发 PermissionError
  • 飞书适配器无法完成启动,随后进入反复重连。
  • Gateway 主进程仍可能保持运行,因此 systemd 继续显示 active (running)

修复步骤

先为新服务创建与 unit 名称完全对应的 drop-in 目录:

1
mkdir -p ~/.config/systemd/user/hermes-gateway-blog-writer.service.d

然后创建:

1
~/.config/systemd/user/hermes-gateway-blog-writer.service.d/proxy.conf

内容如下:

1
2
3
4
5
6
7
8
9
[Service]
Environment="HTTP_PROXY=http://127.0.0.1:7890"
Environment="HTTPS_PROXY=http://127.0.0.1:7890"
Environment="ALL_PROXY=socks5h://127.0.0.1:7890"
Environment="NO_PROXY=localhost,127.0.0.1,::1"
Environment="http_proxy=http://127.0.0.1:7890"
Environment="https_proxy=http://127.0.0.1:7890"
Environment="all_proxy=socks5h://127.0.0.1:7890"
Environment="no_proxy=localhost,127.0.0.1,::1"

这里同时写入大写和小写变量,是为了兼容不同 HTTP、WebSocket 库读取代理环境变量时的差异。ALL_PROXY 使用 socks5h,让域名解析也经由代理完成;NO_PROXY 则保留本机回环地址的直连,避免本地服务被错误送入代理。

如果用户目录不是账号记录中的默认 HOME,应在同一个 unit drop-in 中显式设置正确的 HOME/XDG 路径。例如实际目录是 /mnt/nfs_share/tlj

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[Service]
Environment="HOME=/mnt/nfs_share/tlj"
Environment="XDG_CONFIG_HOME=/mnt/nfs_share/tlj/.config"
Environment="XDG_STATE_HOME=/mnt/nfs_share/tlj/.local/state"
Environment="XDG_CACHE_HOME=/mnt/nfs_share/tlj/.cache"
Environment="XDG_DATA_HOME=/mnt/nfs_share/tlj/.local/share"
Environment="HTTP_PROXY=http://127.0.0.1:7890"
Environment="HTTPS_PROXY=http://127.0.0.1:7890"
Environment="ALL_PROXY=socks5h://127.0.0.1:7890"
Environment="NO_PROXY=localhost,127.0.0.1,::1"
Environment="http_proxy=http://127.0.0.1:7890"
Environment="https_proxy=http://127.0.0.1:7890"
Environment="all_proxy=socks5h://127.0.0.1:7890"
Environment="no_proxy=localhost,127.0.0.1,::1"

这里的路径只是示例,应换成该账号真实、可读写的目录。修复时优先使用对应 systemd unit 的 drop-in,不要为了绕过路径问题直接修改 Hermes Agent 源码。环境问题留在服务配置层处理,后续升级 Hermes 时也不会被覆盖。

配置写入后,重新加载 systemd 用户配置并重启新服务:

1
2
systemctl --user daemon-reload
systemctl --user restart hermes-gateway-blog-writer.service

这一步不能只执行 daemon-reload。它只让 systemd 重新读取 unit 和 drop-in,正在运行的进程不会自动获得新的环境变量,必须重启服务。


验证方式

先确认新服务确实加载了正确的 drop-in:

1
2
systemctl --user show hermes-gateway-blog-writer.service \
-p DropInPaths -p Environment --no-pager

输出中应包含:

1
DropInPaths=/home/tianlejin/.config/systemd/user/hermes-gateway-blog-writer.service.d/proxy.conf

同时应能看到正确的 HOMEXDG_CONFIG_HOMEXDG_STATE_HOMEXDG_CACHE_HOMEXDG_DATA_HOME,以及 HTTP_PROXYHTTPS_PROXYALL_PROXYNO_PROXY 及其小写版本。检查时不要把包含凭据的其他环境变量复制到公开位置。

接着查看本次启动日志:

1
2
journalctl --user -u hermes-gateway-blog-writer.service \
--since "5 minutes ago" --no-pager

最终应出现这两条成功标志:

1
2
[Feishu] Connected in websocket mode
Gateway running with 1 platform(s)

本次修复后,飞书 WebSocket 已成功连接,Gateway 也确认有一个平台处于运行状态。相比 active (running),这两条平台层日志才是验证完成的依据。

不同版本的日志措辞可能略有差异,也可能显示为 WebSocket connected。判断标准不变:必须看到飞书 WebSocket 已连接的明确日志,同时确认不再出现 PermissionError 和周期性重连。

如果日志里出现包含连接参数的 WebSocket URL,不要直接复制到博客、Issue 或聊天记录中,其中可能带有临时票据或访问凭据。


为什么每个 Profile 都需要独立 drop-in

Hermes Profile 隔离的不只是配置目录,也会对应独立的 Gateway 服务。例如:

1
2
hermes-gateway.service
hermes-gateway-blog-writer.service

systemd 的 drop-in 以完整 unit 名称为作用域:

1
~/.config/systemd/user/<完整 unit 名称>.service.d/*.conf

所以:

  • hermes-gateway.service.d/proxy.conf 只修改默认 Gateway。
  • hermes-gateway-blog-writer.service.d/proxy.conf 只修改 blog-writer Gateway。
  • 创建新的 Profile Gateway 后,不能假设它会继承默认 Gateway 的 drop-in。
  • ~/.bashrc 面向交互式 Shell,不是 systemd 用户服务的通用环境配置入口。

独立 drop-in 也符合 Profile 隔离的目标。不同 Profile 可以使用不同代理、不同本地服务地址或完全不使用代理,不会互相污染。


新 Profile Gateway 检查清单

以后新建 Hermes Profile 并启用 Gateway,可以按这份清单逐项检查:

  • 确认 Profile 已创建,并确认 Gateway 使用的是目标 Profile 的 HERMES_HOME
  • 确认对应的 systemd unit 名称,不要把默认服务和 Profile 服务混为一谈。
  • 使用 systemctl --user status <unit> 检查主进程是否稳定运行。
  • 使用 journalctl --user -u <unit> 检查平台适配器的连接结果。
  • 如果 WSL 通过 Clash 访问外网,为该 unit 创建独立的 *.service.d/proxy.conf
  • 如果账号使用 NFS 等非标准用户目录,比较 Gateway 进程的 HOMEXDG_CONFIG_HOMEXDG_STATE_HOMEXDG_CACHE_HOMEXDG_DATA_HOME
  • 如果日志出现 PermissionError: [Errno 13] Permission denied: '/home/...',检查 Gateway 是否把锁目录写到了错误的 HOME,而不是继续只查网络。
  • 同时配置大写和小写的代理变量,并为本地回环地址设置 NO_PROXYno_proxy
  • 在对应 unit 的 drop-in 中显式设置正确的 HOME/XDG 路径和代理变量,不要直接修改 Hermes Agent 源码。
  • 修改 drop-in 后执行 systemctl --user daemon-reload,随后重启对应服务。
  • 使用 systemctl --user show <unit> -p DropInPaths -p Environment 确认配置已进入进程环境。
  • 不要只看 active,还要确认日志出现飞书 WebSocket connected 和 Gateway 平台计数,并确认不再反复重连。
  • 分享日志前删除 token、ticket、access_key、App ID、App Secret 以及带连接参数的完整 WebSocket URL。

参考资料


创建时间:2026-07-23 · 本文档由 Hermes 用户排查并记录