抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >
海灵 · 文章导读
00:00 / 00:00
听一听这篇文章

先说结论

在魔搭创空间部署 openclaw_computer Hermes 的完整记录:从 Docker 进桌面,到 Tailscale、 消息网关、多平台身份、Dashboard 访问鉴权、浏览器登录态持久化,以及重启后会丢什么。

前言

封面

把 openclaw_computer 的 Hermes 镜像丢进魔搭创空间,中间踩过不少坑。本文按实际使用顺序写:先部署,再连消息和本机访问,最后是数据与重启。

镜像:

ghcr.io/tunmax/openclaw_computer:hermes_latest

国内站 modelscope.cn 和国际站 modelscope.ai 步骤相同,不要混的只有:

  • 登录站点与 Access Token(中外 Token 不互通)
  • 运行域名后缀以页面为准(常见 .ms.show / .ms.fun)

准备

  • 魔搭账号(国内或国际选一个)
  • Access Token:头像 → 访问令牌
  • 需要本机直连时:Tailscale 账号 + Auth Key
  • 硬件先用免费 CPU 即可

创建创空间

创建示意

  1. 登录魔搭 → 创空间 → 新建
  2. SDK 选 Docker,建议私有,硬件免费 CPU
  3. 空间地址形如 …/studios/<用户名>/<空间名>

Dockerfile

最小:能进桌面

FROM ghcr.io/tunmax/openclaw_computer:hermes_latest

EXPOSE 7860

ENTRYPOINT ["/entrypoint.sh"]

完整:SSH / Tailscale / 面板 / 网关 / 浏览器

需要本机访问面板、自动消息网关、桌面与 Agent 共用一套浏览器登录态时,用文末 附录完整 Dockerfile(建议上传文件,勿网页半截粘贴)。

完整版大致会:

  • 用自定义 hermes-boot.sh 启动,最后再 exec 官方入口起 noVNC 桌面
  • 起 Tailscale、sshd、Hermes Dashboard(含网页 Chat)、Gateway 与 watchdog
  • 起 workspace 上的持久 Chrome 目录 + CDP(创空间无 Docker,做不了官方 Camofox 持久化时的变通)
  • 把桌面「谷歌浏览器」包装脚本指到同一套 profile

配置

创空间「密钥 / 变量」与容器内 ~/.hermes/.env 是两套入口;消息通道也可用:

hermes gateway setup

最少

类型 名字 说明
密钥 MODELSCOPE_API_KEY 与当前站点配套的 ms- Token
变量 ROOT_PASSWD 桌面 / SSH 密码

进阶常用

类型 名字 说明
密钥 TS_AUTHKEY Tailscale
变量 TS_HOSTNAME 节点名
变量 TS_EXTRA_ARGS 如 --accept-dns=false --accept-routes --reset
变量 ENABLE_SSH 1
变量 ENABLE_HERMES_GATEWAY 1,自动跑消息网关
变量 ENABLE_WORKSPACE_LINKS 1
变量 ENABLE_HERMES_DASHBOARD_TUI 默认 1,网页 Chat

消息放行(很重要)

只设微信/飞书自己的 *_ALLOW_ALL_USERS 不够。网关还有总开关。日志里若出现 Unauthorized user 或反复要配对码,检查:

GATEWAY_ALLOW_ALL_USERS=true

个人自用可再配合:

WEIXIN_DM_POLICY=open
WEIXIN_ALLOW_ALL_USERS=true
FEISHU_ALLOW_ALL_USERS=true

生产环境更稳妥是白名单,不要全局 open。

网页搜索不稳时,可给 FIRECRAWL_API_KEY / TAVILY_API_KEY 等(见官方 web 文档),并设 web.search_backend。

改完密钥或策略后 重启 gateway(以及需要时重启 dashboard)才生效。


部署

保存 Dockerfile → 配置密钥/变量 → 运行/部署 → 等 Running。
首次拉镜像可能很久。私有空间打开独立域名要先登录,403 不等于部署失败。


桌面与面板

桌面

创空间页进入应用,桌面是 KDE + noVNC。

容器内可打开 Hermes 管理面板;Tailscale 通了以后本机也可访问。
较新的 Hermes 若要把 Dashboard 绑到非 127.0.0.1(例如给 Tailscale 上其他机器用),必须先配置 Dashboard 登录鉴权,否则进程会直接拒绝启动,表现为端口拒绝连接。

在 config.yaml 中配置例如:

dashboard:
basic_auth:
username: hermes
password_hash: <用官方方式生成的哈希>

哈希可用文档提示的方式生成,例如(在 Hermes 安装目录、能 import 插件时):

python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('你的密码'))"

然后再:

hermes dashboard --tui --host 0.0.0.0 --port 9119 --no-open --insecure

浏览器访问时用你设的用户名密码。
鉴权写在 config 里会跟着 workspace 走;进程本身重启后要靠 boot 再拉起来。

网页 Chat 是 Dashboard 的 Chat 标签(需 --tui / 相关环境变量)。侧边有 Chat 即可对话。


本机经 Tailscale 访问

访问关系

面板

完整镜像 + TS_AUTHKEY 后,本机 tailscale status 应能看到创空间节点。访问示例:

桌面:  http://<节点IP或主机名>:7860/vnc.html
面板: http://<节点IP或主机名>:9119/ # 需 Dashboard 已启动且鉴权已配
SSH: ssh root@<节点IP>

创空间里 Tailscale 经常是 userspace-networking。
系统自带 ping 100.x 丢包 很常见,不能用来判断「没进尾网」。优先:

tailscale status
tailscale ping <节点>

容器里同理:用 tailscale ping,不要只看系统 ping。

若 22 / 桌面端口通、只有面板端口拒绝:多半是 Dashboard 没起来,或没配 basic_auth 导致绑 0.0.0.0 失败,而不是 Tailscale 坏了。


消息通道

微信、飞书等靠 hermes gateway 进程。完整镜像会自动起 gateway,并用 watchdog 保活。
聊天里出现 Gateway shutting down 表示网关进程在关,看日志或等它拉起。

配置平台:

hermes gateway setup

微信

无官方 Bot API,稳定性一般弱于官方 bot 平台。
若策略是 pairing,未批准用户会收到配对码,管理员执行:

hermes pairing list
hermes pairing approve weixin <CODE>

配对名单空了 和 bot 登录文件还在 可以同时发生:机器不是整机变新,只是「谁能用 bot」丢了。
迁移/Redeploy 时注意带上 pairing 相关文件,或改用 open + GATEWAY_ALLOW_ALL_USERS。

飞书

官方 bot:长连接 websocket 最省事。
注意:

  • 不能主动私聊用户(用户要先发消息)
  • 群里通常要 @ 机器人
  • 提示 No home channel 时,在希望收 cron/跨平台通知的会话里发 /sethome(或文档中的 /set-home);可忽略,不影响普通聊天
  • 换新应用:setup 或改 FEISHU_APP_ID / FEISHU_APP_SECRET 后重启 gateway
  • 若日志出现 extra_ua_tags 一类 TypeError,是适配器与 lark_oapi 版本参数不匹配,需升级 SDK 或给适配器做兼容,与「是否新用户」无关

多入口是不是同一个人

同一 Hermes、同一默认 profile 下,长期记忆可共用;会话上下文按微信 / 飞书 / 网页分开。
不会自动把三个入口合成一条连续对话。


浏览器登录态(创空间变通)

官方 Camofox + browser.camofox.managed_persistence 依赖另起 Camofox/Docker 服务。
很多创空间容器 没有 Docker,原样做官方持久化不现实。

可行变通:

  1. 把 Chrome 用户目录放在持久盘,例如 /mnt/workspace/.../browser-profile
  2. 启动时带上远程调试端口,供 Hermes browser.cdp_url 连接
  3. 桌面点开的 Chrome 包装脚本也指向同一套 profile,避免「桌面登录」和「Agent 浏览器」各用各的

要点:

  • 只改 managed_persistence: true 而没有 Camofox 服务 → 无效
  • profile 必须在 workspace,不要放 /tmp
  • Redeploy 后 目录还在,进程要再起;boot 里应尽量自动拉起 CDP

用户自己在桌面登录时,可让 Agent 用「可见窗口 + 持久脚本」打开浏览器,不要在聊天里发密码。


重启会丢什么

内容 重启 gateway 创空间 Redeploy
配置、密钥、SOUL、子 profile 一般保留 一般保留(workspace / Secrets)
Dashboard 鉴权配置 保留 保留
消息通道登录文件 一般保留 一般保留;token 可能过期
用户配对批准名单 通常保留 迁移不完整时可能空
正在跑的 gateway/dashboard/chrome 会断 会断,靠 boot/watchdog 再起
workspace 业务文件 / 浏览器 profile 保留 保留
/tmp、系统层 apt install 可能还在 常丢

给 Agent 装软件:长期能力放 workspace(如 bin / tools / venvs);系统包装进 Dockerfile,不要只靠会话里 apt。


数据目录

/mnt/workspace 同一空间重启通常还在,删空间则无。
用户目录可软链到 workspace;不要把整个 .hermes 乱软链。
业务重要文件放 workspace 固定目录。


踩坑摘要

  • 中外 Token 混用 → 401
  • 三行 Dockerfile 却要 SSH/网关/外网面板 → 用完整版
  • 系统 ping 不通 100.x → 改用 tailscale ping / HTTP
  • 桌面通、面板端口拒绝 → Dashboard 未起或未配 basic_auth
  • 平台已 open 仍要配对 → 查 GATEWAY_ALLOW_ALL_USERS
  • 微信突然要码 → 批准名单空了或策略是 pairing
  • 飞书 No home channel → /sethome,可忽略
  • 飞书 extra_ua_tags → SDK/适配器兼容
  • 创空间外网抓取不稳 → 配搜索 API 或换数据源

总结

创空间跑 Hermes:Docker 镜像、密钥、部署、进桌面。
要本机面板和消息通道,用完整 boot,并理解:

  1. 消息靠 gateway;放行要同时看平台策略和 GATEWAY_ALLOW_ALL_USERS
  2. 外网访问 Dashboard 需要 鉴权,否则新版本拒绝绑定
  3. 多平台共享记忆、不共享会话
  4. 持久数据与浏览器 profile 放 workspace;无 Docker 时用 Chrome 目录 + CDP 变通
  5. 进程会随重启消失,配置与文件可以留下

参考


附录:完整 Dockerfile 示例

整文件上传到创空间根目录。密钥用平台 Secrets/Variables 或 hermes gateway setup,不要写进镜像。

FROM ghcr.io/tunmax/openclaw_computer:hermes_latest
ARG TS_VERSION=1.98.9
RUN set -eux; \
curl -fsSL "https://pkgs.tailscale.com/stable/tailscale_${TS_VERSION}_amd64.tgz" -o /tmp/tailscale.tgz; \
tar -xzf /tmp/tailscale.tgz -C /tmp; \
install -m 0755 /tmp/tailscale_${TS_VERSION}_amd64/tailscale /usr/local/bin/tailscale; \
install -m 0755 /tmp/tailscale_${TS_VERSION}_amd64/tailscaled /usr/local/bin/tailscaled; \
rm -rf /tmp/tailscale.tgz /tmp/tailscale_${TS_VERSION}_amd64; \
mkdir -p /var/lib/tailscale /var/run/tailscale /root/bz-startup /usr/local/lib/hermes-startup /var/run/sshd /usr/local/share/hermes; \
export DEBIAN_FRONTEND=noninteractive; \
if [ -f /etc/apt/sources.list ]; then \
sed -i 's|mirrors.tuna.tsinghua.edu.cn|mirrors.aliyun.com|g' /etc/apt/sources.list || true; \
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list || true; \
fi; \
(apt-get update -y || true); \
(apt-get install -y --no-install-recommends openssh-server rsync || true); \
rm -rf /var/lib/apt/lists/*; \
ssh-keygen -A || true
RUN if [ -f /entrypoint.sh ]; then sed -i 's|hermes dashboard|env HERMES_DASHBOARD_TUI=1 hermes dashboard --tui|g' /entrypoint.sh || true; fi
RUN cat > /usr/local/lib/hermes-startup/main.sh <<'HERMES_MAIN_EOF'
#!/bin/bash
set -u
TS_AUTHKEY="${TS_AUTHKEY:-${TAILSCALE_AUTHKEY:-}}"
TS_HOSTNAME="${TS_HOSTNAME:-ms-hermes}"
TS_STATE_DIR="${TS_STATE_DIR:-/mnt/workspace/tailscale}"
TS_SOCK="${TS_SOCK:-/var/run/tailscale/tailscaled.sock}"
TS_EXTRA_ARGS="${TS_EXTRA_ARGS:---accept-dns=false --accept-routes --reset}"
ROOT_PASSWD="${ROOT_PASSWD:-123456}"
mkdir -p "$TS_STATE_DIR" 2>/dev/null || true
[ -n "$ROOT_PASSWD" ] && echo "root:${ROOT_PASSWD}" | chpasswd 2>/dev/null || true
[ -z "$TS_AUTHKEY" ] && exit 0
command -v tailscaled >/dev/null 2>&1 || exit 0
if [ ! -S "$TS_SOCK" ]; then
if [ -e /dev/net/tun ]; then
nohup tailscaled --state="${TS_STATE_DIR}/tailscaled.state" --socket="$TS_SOCK" --port=41641 >"${TS_STATE_DIR}/tailscaled.log" 2>&1 &
else
nohup tailscaled --tun=userspace-networking --state="${TS_STATE_DIR}/tailscaled.state" --socket="$TS_SOCK" --port=41641 >"${TS_STATE_DIR}/tailscaled.log" 2>&1 &
fi
sleep 3
fi
# shellcheck disable=SC2086
tailscale --socket="$TS_SOCK" up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME" $TS_EXTRA_ARGS || true
exit 0

HERMES_MAIN_EOF
RUN install -m 0755 /usr/local/lib/hermes-startup/main.sh /root/bz-startup/main.sh
RUN cat > /usr/local/bin/hermes-boot.sh <<'HERMES_BOOT_EOF'
#!/bin/bash
# 纯网页 noVNC + Tailscale + SSH
# 启动后把 /root 关键目录软链到 /mnt/workspace,习惯写 root 也不丢数据
set -u
log(){ echo "[hermes-boot] $*" | tee -a /var/log/hermes-boot.log; }

REAL="/usr/local/lib/hermes-startup/main.sh"
WRAPPER="/root/bz-startup/main.sh"
ROOT_PASSWD="${ROOT_PASSWD:-123456}"
ENABLE_SSH="${ENABLE_SSH:-1}"
SSH_PORT="${SSH_PORT:-22}"
# 1=启用 /root 关键目录 -> /mnt/workspace 软链
ENABLE_WORKSPACE_LINKS="${ENABLE_WORKSPACE_LINKS:-1}"
WS_ROOT="${WS_ROOT:-/mnt/workspace/root}"
# 等待 workspace 挂载的最长时间(秒)
WS_WAIT_SECS="${WS_WAIT_SECS:-120}"

TS_AUTHKEY="${TS_AUTHKEY:-${TAILSCALE_AUTHKEY:-}}"
TS_HOSTNAME="${TS_HOSTNAME:-ms-hermes}"
TS_STATE_DIR="${TS_STATE_DIR:-/mnt/workspace/tailscale}"
TS_SOCK="${TS_SOCK:-/var/run/tailscale/tailscaled.sock}"
TS_VERSION="${TS_VERSION:-1.98.9}"
TS_EXTRA_ARGS="${TS_EXTRA_ARGS:---accept-dns=false --accept-routes --reset}"
# 1=开机自动拉起 Hermes Gateway(微信/消息通道依赖它)
ENABLE_HERMES_GATEWAY="${ENABLE_HERMES_GATEWAY:-1}"
# Gateway 健康检查间隔(秒)
HERMES_GATEWAY_WATCH_SECS="${HERMES_GATEWAY_WATCH_SECS:-45}"
# 1=Dashboard 开启网页 Chat 标签(hermes dashboard --tui / HERMES_DASHBOARD_TUI=1)
ENABLE_HERMES_DASHBOARD_TUI="${ENABLE_HERMES_DASHBOARD_TUI:-1}"

mkdir -p /root/bz-startup /usr/local/lib/hermes-startup /var/lib/tailscale /var/run/tailscale \
/var/run/sshd /tmp /var/log 2>/dev/null || true

if [ -f "$REAL" ]; then
install -m 0755 "$REAL" "$WRAPPER" 2>/dev/null || cp -f "$REAL" "$WRAPPER" 2>/dev/null || true
chmod +x "$REAL" "$WRAPPER" 2>/dev/null || true
fi

port_open() {
local p="$1"
if command -v ss >/dev/null 2>&1; then ss -lnt 2>/dev/null | grep -qE ":${p}\\b" && return 0; fi
if command -v netstat >/dev/null 2>&1; then netstat -lnt 2>/dev/null | grep -qE ":${p}\\b" && return 0; fi
timeout 1 bash -c "echo > /dev/tcp/127.0.0.1/${p}" 2>/dev/null && return 0
return 1
}

sync_root_pass() {
if [ -n "${ROOT_PASSWD}" ] && command -v chpasswd >/dev/null 2>&1; then
echo "root:${ROOT_PASSWD}" | chpasswd 2>/dev/null || true
log "root password synced"
fi
}

stop_xrdp_if_any() {
pkill -9 -x xrdp >/dev/null 2>&1 || true
pkill -9 -x xrdp-sesman >/dev/null 2>&1 || true
pkill -9 -f '/usr/sbin/xrdp' >/dev/null 2>&1 || true
pkill -9 -f 'xrdp-sesman' >/dev/null 2>&1 || true
}

# ---------- /root 关键目录持久化到 /mnt/workspace ----------
wait_workspace() {
local i
for i in $(seq 1 "$WS_WAIT_SECS"); do
if [ -d /mnt/workspace ] && touch /mnt/workspace/.ws_write_test 2>/dev/null; then
rm -f /mnt/workspace/.ws_write_test 2>/dev/null || true
log "workspace ready at ${i}s: /mnt/workspace"
return 0
fi
sleep 1
done
log "WARN: /mnt/workspace not ready after ${WS_WAIT_SECS}s, skip home links"
return 1
}

link_one_dir() {
# $1 = relative path under /root e.g. Desktop or .hermes
local name="$1"
local src="/root/${name}"
local dst="${WS_ROOT}/${name}"
local parent

mkdir -p "$(dirname "$dst")" 2>/dev/null || true

mkdir -p "$dst" 2>/dev/null || true

# 已是正确软链则跳过
if [ -L "$src" ]; then
local cur
cur="$(readlink "$src" 2>/dev/null || true)"
if [ "$cur" = "$dst" ]; then
return 0
fi
rm -f "$src" 2>/dev/null || true
fi

# 源是真实目录:把内容合并进 workspace,再换成软链
if [ -d "$src" ] && [ ! -L "$src" ]; then
if command -v rsync >/dev/null 2>&1; then
rsync -a "$src"/ "$dst"/ 2>/dev/null || true
else
cp -a "$src"/. "$dst"/ 2>/dev/null || true
fi
rm -rf "$src" 2>/dev/null || true
elif [ -e "$src" ] || [ -L "$src" ]; then
# 普通文件/错误软链
if [ -f "$src" ] && [ ! -e "$dst" ]; then
cp -a "$src" "$dst" 2>/dev/null || true
fi
rm -rf "$src" 2>/dev/null || true
fi

parent="$(dirname "$src")"
mkdir -p "$parent" 2>/dev/null || true
ln -sfn "$dst" "$src"
log "link /root/${name} -> ${dst}"
}

setup_workspace_home_links() {
if [ "$ENABLE_WORKSPACE_LINKS" != "1" ]; then
log "ENABLE_WORKSPACE_LINKS!=1, skip"
return 0
fi
wait_workspace || return 0
mkdir -p "$WS_ROOT" 2>/dev/null || true

# 关键用户数据目录(可按需加)
# 注意:
# - 不要链整个 /root
# - 不要链 .hermes / .local(内含 hermes-agent 安装与二进制;软链断会直接 500)
# - Hermes 业务数据可另链子目录(见下)
local dirs=(
Desktop
Documents
Downloads
.openclaw
.ssh
.claude
.codex
)

# 重要:不要把 /root/.hermes 或其 sessions/config/data 等子目录软链出去。
# Hermes ensure_hermes_home() 会对这些路径 mkdir,遇到 symlink 会 FileExistsError -> API 500。
# 若历史上已错误软链,这里强制改回真实目录。
if [ -d /root/.hermes ] && [ ! -L /root/.hermes ]; then
local hd p tgt tmp
for hd in config data sessions logs memory workspace; do
p="/root/.hermes/${hd}"
if [ -L "$p" ]; then
tgt="$(readlink "$p" || true)"
log "unsymlink hermes data path ${p} -> ${tgt}"
tmp="${p}.real.$$"
mkdir -p "$tmp"
if [ -d "$tgt" ]; then
if command -v rsync >/dev/null 2>&1; then
rsync -a "$tgt"/ "$tmp"/ 2>/dev/null || true
else
cp -a "$tgt"/. "$tmp"/ 2>/dev/null || true
fi
fi
rm -f "$p"
mv "$tmp" "$p"
chmod 700 "$p" 2>/dev/null || true
elif [ ! -d "$p" ]; then
[ -e "$p" ] && rm -rf "$p"
mkdir -p "$p"
chmod 700 "$p" 2>/dev/null || true
fi
done
fi

local d
for d in "${dirs[@]}"; do
link_one_dir "$d" || log "WARN link failed: $d"
done

# 可选:让部分程序读 $HOME 时也落在 workspace
# 不强制 export 给整个 entrypoint 全局,避免搞坏系统脚本;写进 profile
if ! grep -q 'MODELSCOPE_WORKSPACE_HOME' /root/.bashrc 2>/dev/null; then
cat >> /root/.bashrc <<'EOF'

# >>> MODELSCOPE_WORKSPACE_HOME >>>
# 关键目录已软链到 /mnt/workspace/root;交互 shell 可选强化
export WORKSPACE_HOME=/mnt/workspace/root
# <<< MODELSCOPE_WORKSPACE_HOME <<<
EOF
fi

log "workspace home links done under ${WS_ROOT}"
ls -la /root 2>/dev/null | head -n 40 | while read -r L; do log " $L"; done || true
}

start_tailscale() {
if ! command -v tailscaled >/dev/null 2>&1; then
curl -fsSL "https://pkgs.tailscale.com/stable/tailscale_${TS_VERSION}_amd64.tgz" -o /tmp/ts.tgz || return 0
tar -xzf /tmp/ts.tgz -C /tmp || return 0
install -m 0755 /tmp/tailscale_${TS_VERSION}_amd64/tailscale /usr/local/bin/tailscale
install -m 0755 /tmp/tailscale_${TS_VERSION}_amd64/tailscaled /usr/local/bin/tailscaled
rm -rf /tmp/ts.tgz /tmp/tailscale_${TS_VERSION}_amd64
fi
[ -z "$TS_AUTHKEY" ] && { log "no TS_AUTHKEY, skip tailscale"; return 0; }
mkdir -p "$TS_STATE_DIR" 2>/dev/null || true
if ! [ -S "$TS_SOCK" ]; then
if [ -e /dev/net/tun ]; then
nohup tailscaled --state="${TS_STATE_DIR}/tailscaled.state" --socket="$TS_SOCK" --port=41641 \
>"${TS_STATE_DIR}/tailscaled.log" 2>&1 &
else
nohup tailscaled --tun=userspace-networking --state="${TS_STATE_DIR}/tailscaled.state" \
--socket="$TS_SOCK" --port=41641 >"${TS_STATE_DIR}/tailscaled.log" 2>&1 &
fi
for _ in $(seq 1 40); do [ -S "$TS_SOCK" ] && break; sleep 1; done
fi
# shellcheck disable=SC2086
tailscale --socket="$TS_SOCK" up --authkey="$TS_AUTHKEY" --hostname="$TS_HOSTNAME" $TS_EXTRA_ARGS || true
IP4="$(tailscale --socket="$TS_SOCK" ip -4 2>/dev/null | head -n1 || true)"
[ -n "$IP4" ] && log "tailnet IPv4=${IP4} hostname=${TS_HOSTNAME}"
}







# Persistent Chrome (workspace profile + CDP) for Hermes browser tools / desktop login
# Hermes Dashboard (web UI + Chat). Requires dashboard.basic_auth when binding non-loopback.
start_hermes_dashboard() {
local want_tui=1
if [ "${ENABLE_HERMES_DASHBOARD_TUI:-1}" = "0" ]; then
want_tui=0
fi

local H=""
if [ -x /root/.hermes/hermes-agent/venv/bin/hermes ]; then
H=/root/.hermes/hermes-agent/venv/bin/hermes
elif [ -x /root/.local/bin/hermes ]; then
H=/root/.local/bin/hermes
elif command -v hermes >/dev/null 2>&1; then
H=$(command -v hermes)
fi
if [ -z "$H" ]; then
log "hermes binary not found, skip dashboard"
return 0
fi

export PATH="/root/.hermes/hermes-agent/venv/bin:/root/.local/bin:${PATH:-}"
export HERMES_HOME="${HERMES_HOME:-/root/.hermes}"
if [ -f /root/.hermes/.env ]; then
set -a
# shellcheck disable=SC1091
. /root/.hermes/.env 2>/dev/null || true
set +a
fi

if port_open 9119; then
log "dashboard already on :9119"
return 0
fi

pkill -f "hermes dashboard" >/dev/null 2>&1 || true
sleep 1

# Bind 0.0.0.0 so Tailscale clients can reach it.
# New Hermes requires dashboard.basic_auth (or other auth) for non-loopback binds.
if [ "$want_tui" = "1" ]; then
export HERMES_DASHBOARD_TUI=1
log "starting hermes dashboard --tui on 0.0.0.0:9119"
nohup "$H" dashboard --tui --host 0.0.0.0 --port 9119 --no-open --insecure \
>>/tmp/hermes-dashboard.log 2>&1 < /dev/null &
else
unset HERMES_DASHBOARD_TUI 2>/dev/null || true
log "starting hermes dashboard on 0.0.0.0:9119"
nohup "$H" dashboard --host 0.0.0.0 --port 9119 --no-open --insecure \
>>/tmp/hermes-dashboard.log 2>&1 < /dev/null &
fi

local i
for i in $(seq 1 45); do
if port_open 9119; then
log "dashboard OK :9119 tui=${want_tui}"
return 0
fi
sleep 2
done
log "dashboard FAIL; see /tmp/hermes-dashboard.log"
tail -n 30 /tmp/hermes-dashboard.log 2>/dev/null || true
}

ensure_persist_browser() {
local launcher="/mnt/workspace/hailing/bin/hermes-chrome-persist"
local profile="/mnt/workspace/hailing/browser-profile"
local desk_dir desktop_file
mkdir -p /mnt/workspace/hailing/bin "$profile" /tmp /var/log 2>/dev/null || true
if [ -f /usr/local/share/hermes/hermes-chrome-persist ]; then
install -m 0755 /usr/local/share/hermes/hermes-chrome-persist "$launcher" 2>/dev/null || cp -f /usr/local/share/hermes/hermes-chrome-persist "$launcher"
chmod +x "$launcher" 2>/dev/null || true
fi
mkdir -p /root/.hermes 2>/dev/null || true
ln -sfn "$profile" /root/.hermes/chrome-debug 2>/dev/null || true
for desk_dir in /root/Desktop /mnt/workspace/root/Desktop /usr/share/applications; do
mkdir -p "$desk_dir" 2>/dev/null || true
desktop_file="$desk_dir/Hermes-Chrome-Persist.desktop"
cat > "$desktop_file" <<'DESK'
[Desktop Entry]
Version=1.0
Type=Application
Name=Hermes Chrome (Persist)
Name[zh_CN]=Hermes 持久浏览器
Comment=Chrome with workspace profile + CDP for Hermes
Exec=env DISPLAY=:1 HERMES_BROWSER_HEADED=1 /mnt/workspace/hailing/bin/hermes-chrome-persist %U
Icon=google-chrome
Terminal=false
Categories=Network;WebBrowser;
StartupNotify=true
DESK
chmod +x "$desktop_file" 2>/dev/null || true
done
if [ -f /usr/share/applications/google-chrome.desktop ]; then
mkdir -p /root/.local/share/applications /mnt/workspace/root/.local/share/applications 2>/dev/null || true
for desk_dir in /root/.local/share/applications /mnt/workspace/root/.local/share/applications; do
cat > "$desk_dir/google-chrome.desktop" <<'DESK'
[Desktop Entry]
Version=1.0
Type=Application
Name=Google Chrome
Name[zh_CN]=谷歌浏览器(持久)
GenericName=Web Browser
Comment=Hermes persistent profile
Exec=env DISPLAY=:1 HERMES_BROWSER_HEADED=1 /mnt/workspace/hailing/bin/hermes-chrome-persist %U
Terminal=false
Icon=google-chrome
Categories=Network;WebBrowser;
MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme-handler/https;
StartupNotify=true
DESK
chmod +x "$desk_dir/google-chrome.desktop" 2>/dev/null || true
done
fi
if [ -x "$launcher" ]; then
HERMES_BROWSER_HEADED=0 "$launcher" >/var/log/hermes-chrome-persist.boot.log 2>&1 || true
if port_open 9222; then log "persist chrome CDP OK :9222"; else log "persist chrome CDP not ready yet"; fi
else
log "WARN: missing $launcher"
fi
}

start_sshd() {
[ "$ENABLE_SSH" = "1" ] || return 0
mkdir -p /var/run/sshd /etc/ssh 2>/dev/null || true
if ! command -v sshd >/dev/null 2>&1 && ! [ -x /usr/sbin/sshd ]; then
export DEBIAN_FRONTEND=noninteractive
if [ -f /etc/apt/sources.list ]; then
sed -i 's|mirrors.tuna.tsinghua.edu.cn|mirrors.aliyun.com|g' /etc/apt/sources.list || true
sed -i 's|deb.debian.org|mirrors.aliyun.com|g' /etc/apt/sources.list || true
fi
apt-get update -y >/tmp/ssh-apt.log 2>&1 || true
apt-get install -y --no-install-recommends openssh-server >/tmp/ssh-apt.log 2>&1 || true
fi
[ -f /etc/ssh/ssh_host_rsa_key ] || ssh-keygen -A >/tmp/ssh-keygen.log 2>&1 || true
if [ -f /etc/ssh/sshd_config ]; then
sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config
sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config
sed -i "s/^#\?Port .*/Port ${SSH_PORT}/" /etc/ssh/sshd_config
grep -q '^PermitRootLogin' /etc/ssh/sshd_config || echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config
grep -q '^PasswordAuthentication' /etc/ssh/sshd_config || echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config
fi
pkill -9 -x sshd >/dev/null 2>&1 || true
sleep 1
if [ -x /usr/sbin/sshd ]; then
/usr/sbin/sshd >>/var/log/sshd.log 2>&1 || true
sleep 1
if ! port_open "$SSH_PORT"; then
nohup /usr/sbin/sshd -D >>/var/log/sshd.log 2>&1 &
sleep 1
fi
fi
if port_open "$SSH_PORT"; then log "sshd OK :${SSH_PORT}"; else log "sshd FAIL"; tail -n 20 /var/log/sshd.log 2>/dev/null || true; fi
}


# 从 PID1 / 当前环境把推理密钥同步进 ~/.hermes/.env(平台 Secrets 注入后,Hermes 才能读到)
sync_hermes_api_keys_from_env() {
python3 - <<'PY' 2>/dev/null || true
from pathlib import Path
import os
env_path = Path("/root/.hermes/.env")
env_path.parent.mkdir(parents=True, exist_ok=True)
# prefer live env (Studio secrets), fall back to pid1
def from_pid1(name: str):
try:
for x in Path("/proc/1/environ").read_bytes().split(b"\x00"):
if x.startswith(name.encode() + b"="):
return x.split(b"=", 1)[1].decode("utf-8", "replace")
except Exception:
return ""
return ""
keys = {}
for name in (
"MODELSCOPE_API_KEY",
"STEPFUN_API_KEY",
"STEP_PLAN_API_KEY",
"JIEKANS_API_KEY",
"OPENAI_API_KEY",
"CUSTOM_API_KEY",
"OPENROUTER_API_KEY",
"DEEPSEEK_API_KEY",
):
v = (os.environ.get(name) or from_pid1(name) or "").strip()
if v:
keys[name] = v
if not keys:
raise SystemExit(0)
text = env_path.read_text(encoding="utf-8", errors="replace") if env_path.exists() else ""
lines = text.splitlines()
out, seen = [], set()
for ln in lines:
if not ln.strip() or ln.strip().startswith("#") or "=" not in ln:
out.append(ln)
continue
k, _ = ln.split("=", 1)
if k in keys:
out.append(f"{k}={keys[k]}")
seen.add(k)
else:
out.append(ln)
for k, v in keys.items():
if k not in seen:
out.append(f"{k}={v}")
env_path.write_text("\n".join(out) + "\n", encoding="utf-8")
try:
env_path.chmod(0o600)
except Exception:
pass
print("synced", ",".join(sorted(keys)))
PY
}

hermes_bin() {
if [ -x /root/.hermes/hermes-agent/venv/bin/hermes ]; then
echo /root/.hermes/hermes-agent/venv/bin/hermes
return 0
fi
if [ -x /root/.local/bin/hermes ]; then
echo /root/.local/bin/hermes
return 0
fi
if command -v hermes >/dev/null 2>&1; then
command -v hermes
return 0
fi
return 1
}

gateway_running() {
local H
H="$(hermes_bin 2>/dev/null || true)"
if [ -n "$H" ] && "$H" gateway status 2>/dev/null | grep -q 'Gateway is running'; then
return 0
fi
# fallback: process table
ps -eo args 2>/dev/null | grep -q '[h]ermes gateway run' && return 0
return 1
}

start_hermes_gateway() {
if [ "$ENABLE_HERMES_GATEWAY" != "1" ]; then
log "ENABLE_HERMES_GATEWAY!=1, skip gateway"
return 0
fi
local H
H="$(hermes_bin 2>/dev/null || true)"
if [ -z "$H" ]; then
log "hermes binary not found yet, skip gateway start"
return 0
fi
if gateway_running; then
log "Hermes gateway already running"
return 0
fi
sync_hermes_api_keys_from_env || true
# wait a bit for ~/.hermes to exist after image restore
local i
for i in $(seq 1 30); do
if [ -d /root/.hermes ]; then break; fi
sleep 2
done
export PATH="/root/.hermes/hermes-agent/venv/bin:/root/.local/bin:${PATH:-}"
export HERMES_HOME="/root/.hermes"
export HERMES_ACCEPT_HOOKS="${HERMES_ACCEPT_HOOKS:-1}"
# pull common keys into process env for this start
if [ -f /root/.hermes/.env ]; then
set -a
# shellcheck disable=SC1091
. /root/.hermes/.env 2>/dev/null || true
set +a
fi
mkdir -p /var/log /tmp 2>/dev/null || true
log "starting Hermes gateway (auto)"
# setsid: detach from boot parent so supervisor SIGTERM on child groups is less likely to chain-kill forever;
# watcher below will restart if it dies.
nohup setsid "$H" gateway run --accept-hooks >>/var/log/hermes-gateway-auto.log 2>&1 < /dev/null &
sleep 4
if gateway_running; then
log "Hermes gateway OK (auto-start)"
else
log "Hermes gateway start may have failed; see /var/log/hermes-gateway-auto.log"
tail -n 30 /var/log/hermes-gateway-auto.log 2>/dev/null | while read -r L; do log "gateway: $L"; done || true
fi
}

# 后台守护:挂了就拉起(创空间 supervisor 偶尔会误杀手动 gateway)
watch_hermes_gateway() {
if [ "$ENABLE_HERMES_GATEWAY" != "1" ]; then return 0; fi
local n=0
while true; do
sleep "${HERMES_GATEWAY_WATCH_SECS}"
n=$((n + 1))
if ! gateway_running; then
log "watchdog: gateway not running (tick=${n}), restarting"
start_hermes_gateway
fi
# 顺手保证 dashboard 代理在
start_hermes_dashboard || true
done
}

(
sleep 2
sync_root_pass
stop_xrdp_if_any
setup_workspace_home_links
start_tailscale
start_sshd
start_hermes_dashboard || true
ensure_persist_browser || true
# 等 entrypoint 把 Hermes 目录/二进制准备好再启 gateway
(
sleep 25
start_hermes_gateway
# 再巩固几次(restore 可能较晚)
for j in 20 40 80; do
sleep "$j"
start_hermes_gateway
done
) >>/var/log/hermes-boot.log 2>&1 &
# 长期 watchdog:必须用独立 setsid 进程,不能挂在本 subshell 里
# (subshell 在下面 re-link 循环结束后退出,会把 & 后台函数一起带走)
if [ "$ENABLE_HERMES_GATEWAY" = "1" ]; then
if [ -x /usr/local/bin/hermes-gateway-watchdog.sh ]; then
pkill -f '/usr/local/bin/hermes-gateway-watchdog.sh' >/dev/null 2>&1 || true
sleep 1
# 独立会话,避免被 boot 子 shell 退出带走
nohup setsid /usr/local/bin/hermes-gateway-watchdog.sh \
>>/var/log/hermes-gateway-watchdog.log 2>&1 < /dev/null &
log "started hermes-gateway-watchdog.sh pid=$!"
else
log "WARN: missing /usr/local/bin/hermes-gateway-watchdog.sh"
fi
fi
# restore 常在启动后写回真实目录,周期性强制再链
for i in 5 10 20 35 55 90 120 180; do
sleep "$i"
setup_workspace_home_links
if ! port_open "$SSH_PORT"; then start_sshd; fi
start_hermes_dashboard || true
ensure_persist_browser || true
# 若独立 watchdog 挂了,补拉一次
if [ "$ENABLE_HERMES_GATEWAY" = "1" ] && [ -x /usr/local/bin/hermes-gateway-watchdog.sh ]; then
if ! pgrep -f '/usr/local/bin/hermes-gateway-watchdog.sh' >/dev/null 2>&1; then
nohup setsid /usr/local/bin/hermes-gateway-watchdog.sh \
>>/var/log/hermes-gateway-watchdog.log 2>&1 < /dev/null &
log "re-started hermes-gateway-watchdog.sh"
fi
fi
stop_xrdp_if_any
log "re-applied workspace links after sleep ${i}s"
done
) >>/var/log/hermes-boot.log 2>&1 &

if [ -x "$REAL" ]; then
nohup bash -c "sleep 4; exec '$REAL'" >>/var/log/hermes-startup.log 2>&1 &
fi

(
sleep 45
echo "===== hermes-boot.log ====="
tail -n 120 /var/log/hermes-boot.log 2>/dev/null || true
echo "===== /root symlinks ====="
ls -la /root 2>/dev/null | head -n 50 || true
echo "ports novnc=$(port_open 7860 && echo up || echo down) vnc=$(port_open 5901 && echo up || echo down) rdp=$(port_open 3389 && echo up || echo down) ssh=$(port_open $SSH_PORT && echo up || echo down)"
) &

# 镜像默认入口:TigerVNC + noVNC
exec /entrypoint.sh

HERMES_BOOT_EOF
RUN cat > /usr/local/bin/hermes-gateway-watchdog.sh <<'HERMES_WATCH_EOF'
#!/bin/bash
# Hermes gateway watchdog — keep messaging gateway alive (for Studio/Docker).
# Readers: this is started by hermes-boot.sh when ENABLE_HERMES_GATEWAY=1.
# Manual: nohup setsid /usr/local/bin/hermes-gateway-watchdog.sh >>/var/log/hermes-gateway-watchdog.log 2>&1 &
set -u
LOG=/var/log/hermes-gateway-watchdog.log
mkdir -p /var/log /tmp 2>/dev/null || true
log(){ echo "[$(date '+%F %T')] $*" | tee -a "$LOG"; }

export PATH="/root/.hermes/hermes-agent/venv/bin:/root/.local/bin:/usr/local/bin:${PATH:-}"
export HERMES_HOME="${HERMES_HOME:-/root/.hermes}"
export HERMES_ACCEPT_HOOKS="${HERMES_ACCEPT_HOOKS:-1}"

hermes_bin(){
if [ -x /root/.hermes/hermes-agent/venv/bin/hermes ]; then
echo /root/.hermes/hermes-agent/venv/bin/hermes
return 0
fi
if [ -x /root/.local/bin/hermes ]; then
echo /root/.local/bin/hermes
return 0
fi
if command -v hermes >/dev/null 2>&1; then
command -v hermes
return 0
fi
return 1
}

gateway_running(){
local H
H="$(hermes_bin 2>/dev/null || true)"
if [ -n "$H" ] && "$H" gateway status 2>/dev/null | grep -q 'Gateway is running'; then
return 0
fi
# process table fallback (argv may be python .../hermes gateway)
if ps -eo args 2>/dev/null | grep -E 'hermes([ -]|-agent.*/hermes).*gateway( run)?|bin/hermes gateway' | grep -v grep | grep -v watchdog >/dev/null 2>&1; then
return 0
fi
return 1
}

sync_keys(){
python3 - <<'PY' 2>/dev/null || true
from pathlib import Path
import os
env_path = Path("/root/.hermes/.env")
env_path.parent.mkdir(parents=True, exist_ok=True)

def pid1(name: str) -> str:
try:
for x in Path("/proc/1/environ").read_bytes().split(b"\0"):
if x.startswith(name.encode() + b"="):
return x.split(b"=", 1)[1].decode("utf-8", "replace")
except Exception:
return ""
return ""

keys = {}
for n in [
"MODELSCOPE_API_KEY",
"STEPFUN_API_KEY",
"STEP_PLAN_API_KEY",
"JIEKANS_API_KEY",
"OPENAI_API_KEY",
"CUSTOM_API_KEY",
"OPENROUTER_API_KEY",
"DEEPSEEK_API_KEY",
"TS_AUTHKEY",
"TAILSCALE_AUTHKEY",
]:
v = (os.environ.get(n) or pid1(n) or "").strip()
if v:
keys[n] = v
if not keys:
raise SystemExit(0)
text = env_path.read_text(encoding="utf-8", errors="replace") if env_path.exists() else ""
lines = text.splitlines()
out, seen = [], set()
for ln in lines:
if not ln.strip() or ln.strip().startswith("#") or "=" not in ln:
out.append(ln)
continue
k, _ = ln.split("=", 1)
if k in keys:
out.append(f"{k}={keys[k]}")
seen.add(k)
else:
out.append(ln)
for k, v in keys.items():
if k not in seen:
out.append(f"{k}={v}")
env_path.write_text("\n".join(out) + "\n", encoding="utf-8")
try:
env_path.chmod(0o600)
except Exception:
pass
print("synced", ",".join(sorted(keys)))
PY
}

start_gateway(){
local H
H="$(hermes_bin 2>/dev/null || true)"
if [ -z "$H" ]; then
log "no hermes binary yet"
return 1
fi
if [ ! -d /root/.hermes ]; then
log "~/.hermes missing"
return 1
fi
sync_keys
export PATH="/root/.hermes/hermes-agent/venv/bin:/root/.local/bin:/usr/local/bin:${PATH:-}"
export HERMES_HOME=/root/.hermes
export HERMES_ACCEPT_HOOKS="${HERMES_ACCEPT_HOOKS:-1}"
if [ -f /root/.hermes/.env ]; then
set -a
# shellcheck disable=SC1091
. /root/.hermes/.env 2>/dev/null || true
set +a
fi
log "starting gateway via $H"
nohup setsid "$H" gateway run --accept-hooks >>/var/log/hermes-gateway-auto.log 2>&1 < /dev/null &
sleep 5
if gateway_running; then
log "gateway OK"
return 0
fi
log "gateway FAIL"
tail -n 30 /var/log/hermes-gateway-auto.log 2>/dev/null || true
return 1
}

port_open() {
local p="$1"
if command -v ss >/dev/null 2>&1; then ss -lnt 2>/dev/null | grep -qE ":${p}\\b" && return 0; fi
if command -v netstat >/dev/null 2>&1; then netstat -lnt 2>/dev/null | grep -qE ":${p}\\b" && return 0; fi
timeout 1 bash -c "echo > /dev/tcp/127.0.0.1/${p}" 2>/dev/null && return 0
return 1
}


WATCH_SECS="${HERMES_GATEWAY_WATCH_SECS:-45}"
log "watchdog start (interval=${WATCH_SECS}s)"
if gateway_running; then
log "gateway already running"
else
start_gateway || true
fi

while true; do
sleep "$WATCH_SECS"
if ! gateway_running; then
log "watchdog: gateway down, restarting"
start_gateway || true
fi
done

HERMES_WATCH_EOF
RUN cat > /usr/local/share/hermes/hermes-chrome-persist <<'HERMES_CHROME_EOF'
#!/bin/bash
# Persistent Chrome for Hermes on ModelScope Studio (no Docker/Camofox).
# Profile: /mnt/workspace/hailing/browser-profile
# CDP: 127.0.0.1:9222
set -u
PROFILE_DIR="${HERMES_BROWSER_PROFILE:-/mnt/workspace/hailing/browser-profile}"
CDP_PORT="${HERMES_BROWSER_CDP_PORT:-9222}"
# auto: headed if DISPLAY set, else headless; 1=force headed; 0=force headless
HEADED_MODE="${HERMES_BROWSER_HEADED:-auto}"
LOG="${HERMES_BROWSER_LOG:-/var/log/hermes-chrome-persist.log}"
URL="${1:-about:blank}"

mkdir -p "$PROFILE_DIR" "$(dirname "$LOG")" /mnt/workspace/hailing/bin 2>/dev/null || true

CHROME=""
for c in google-chrome-stable google-chrome chromium chromium-browser; do
if command -v "$c" >/dev/null 2>&1; then
CHROME=$(command -v "$c")
break
fi
done
if [ -z "$CHROME" ]; then
echo "chrome not found" >&2
exit 1
fi

use_headed=0
if [ "$HEADED_MODE" = "1" ]; then
use_headed=1
elif [ "$HEADED_MODE" = "0" ]; then
use_headed=0
elif [ -n "${DISPLAY:-}" ]; then
use_headed=1
fi

port_open() {
local p="$1"
if command -v ss >/dev/null 2>&1; then
ss -lnt 2>/dev/null | grep -qE ":${p}\\b" && return 0
fi
if command -v netstat >/dev/null 2>&1; then
netstat -lnt 2>/dev/null | grep -qE ":${p}\\b" && return 0
fi
return 1
}

# If CDP already up with this port, just open a new tab/window when headed request
if port_open "$CDP_PORT"; then
echo "CDP already up on :${CDP_PORT}"
if [ "$use_headed" = "1" ] && [ -n "${DISPLAY:-}" ]; then
# open URL in existing profile without fighting debug port
nohup "$CHROME" --user-data-dir="$PROFILE_DIR" --no-sandbox --disable-dev-shm-usage \
"$URL" >>"$LOG" 2>&1 < /dev/null &
echo "opened URL in existing profile: $URL"
fi
exit 0
fi

ARGS=(
--user-data-dir="$PROFILE_DIR"
--remote-debugging-port="$CDP_PORT"
--remote-debugging-address=127.0.0.1
--no-first-run
--no-default-browser-check
--no-sandbox
--disable-dev-shm-usage
--disable-gpu
--window-size=1400,900
)
if [ "$use_headed" = "1" ]; then
export DISPLAY="${DISPLAY:-:1}"
else
ARGS+=(--headless=new)
fi

nohup "$CHROME" "${ARGS[@]}" "$URL" >>"$LOG" 2>&1 < /dev/null &
echo "started chrome pid=$! profile=$PROFILE_DIR cdp=$CDP_PORT headed=$use_headed url=$URL"

for i in $(seq 1 30); do
if port_open "$CDP_PORT"; then
echo "CDP ready http://127.0.0.1:${CDP_PORT}"
exit 0
fi
sleep 1
done
echo "CDP port not open; see $LOG" >&2
tail -n 40 "$LOG" 2>/dev/null || true
exit 1

HERMES_CHROME_EOF
RUN cat > /usr/local/share/hermes/google-chrome-wrapper <<'HERMES_CHROME_WRAP_EOF'
#!/bin/bash
# Desktop Chrome wrapper: always use Hermes persistent workspace profile.
# Same profile as Hermes browser tools (CDP 9222).
set -u
PROFILE_DIR="${HERMES_BROWSER_PROFILE:-/mnt/workspace/hailing/browser-profile}"
CDP_PORT="${HERMES_BROWSER_CDP_PORT:-9222}"
LAUNCHER="/mnt/workspace/hailing/bin/hermes-chrome-persist"
mkdir -p "$PROFILE_DIR" 2>/dev/null || true

# Prefer launcher when available (ensures CDP + consistent flags)
if [ -x "$LAUNCHER" ]; then
export DISPLAY="${DISPLAY:-:1}"
export HERMES_BROWSER_HEADED="${HERMES_BROWSER_HEADED:-1}"
# pass URL/args through as first URL if looks like url, else open about:blank + args ignored for simplicity
if [ "$#" -gt 0 ]; then
exec "$LAUNCHER" "$@"
else
exec "$LAUNCHER" about:blank
fi
fi

# Fallback: direct chrome with same profile (no CDP guarantee)
CHROME="/usr/bin/google-chrome-stable"
[ -x "$CHROME" ] || CHROME="/usr/bin/google-chrome"
exec "$CHROME" \
--user-data-dir="$PROFILE_DIR" \
--remote-debugging-port="$CDP_PORT" \
--remote-debugging-address=127.0.0.1 \
--no-first-run \
--no-default-browser-check \
--no-sandbox \
--disable-dev-shm-usage \
--disable-gpu \
--test-type \
"$@"

HERMES_CHROME_WRAP_EOF
RUN install -m 0755 /usr/local/share/hermes/hermes-chrome-persist /usr/local/bin/hermes-chrome-persist && \
install -m 0755 /usr/local/share/hermes/google-chrome-wrapper /usr/local/bin/google-chrome-wrapper && \
mkdir -p /mnt/workspace/hailing/bin /mnt/workspace/hailing/browser-profile && \
cp -f /usr/local/share/hermes/hermes-chrome-persist /mnt/workspace/hailing/bin/hermes-chrome-persist && \
chmod +x /mnt/workspace/hailing/bin/hermes-chrome-persist /usr/local/bin/hermes-chrome-persist /usr/local/bin/google-chrome-wrapper

RUN cat > /usr/local/bin/hailing-register-secret <<'HERMES_REG_EOF'
#!/usr/bin/env python3
"""Register an account secret for 海灵: write ~/.hermes/.env + accounts.md index.

Usage examples:
hailing-register-secret --name github-work --env GITHUB_TOKEN_WORK --value 'ghp_xxx' \\
--type api_token --username myuser --purpose '工作仓库' --url 'https://github.com'

hailing-register-secret --name site-foo --vault-only --username u --purpose '某站' \\
--url 'https://example.com' --note '密码用户稍后提供'

Never prints the secret value back.
"""
from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path

ENV_PATH = Path("/root/.hermes/.env")
ACCOUNTS = Path("/mnt/workspace/hailing/secrets/accounts.md")
WS_ENV = Path("/mnt/workspace/root/.hermes/.env")


def valid_env_name(name: str) -> bool:
return bool(re.fullmatch(r"[A-Z][A-Z0-9_]{1,63}", name))


def valid_short_name(name: str) -> bool:
return bool(re.fullmatch(r"[a-z][a-z0-9_-]{1,47}", name))


def upsert_env(key: str, value: str) -> None:
ENV_PATH.parent.mkdir(parents=True, exist_ok=True)
text = ENV_PATH.read_text(encoding="utf-8", errors="replace") if ENV_PATH.exists() else ""
lines = text.splitlines()
out, found = [], False
for ln in lines:
if ln.startswith(f"{key}="):
out.append(f"{key}={value}")
found = True
else:
out.append(ln)
if not found:
if out and out[-1].strip():
out.append("")
out.append(f"# hailing auto-registered")
out.append(f"{key}={value}")
ENV_PATH.write_text("\n".join(out) + "\n", encoding="utf-8")
try:
ENV_PATH.chmod(0o600)
except Exception:
pass
# mirror if workspace hermes exists
if WS_ENV.parent.exists():
try:
WS_ENV.write_text(ENV_PATH.read_text(encoding="utf-8"), encoding="utf-8")
WS_ENV.chmod(0o600)
except Exception:
pass


def upsert_accounts(
name: str,
*,
typ: str,
env_key: str | None,
vault: str | None,
username: str,
purpose: str,
url: str,
note: str,
) -> None:
ACCOUNTS.parent.mkdir(parents=True, exist_ok=True)
if not ACCOUNTS.exists():
ACCOUNTS.write_text("# 账号索引(无完整 secret)\n\n", encoding="utf-8")

read_via = f"env:{env_key}" if env_key else (f"vault:{vault}" if vault else "unknown")
section = (
f"## {name}\n"
f"- 类型: {typ}\n"
f"- 用户名: {username or '(未填)'}\n"
f"- 登录/控制台: {url or '(未填)'}\n"
f"- 用途: {purpose or '(未填)'}\n"
f"- 读取方式: {read_via}\n"
f"- 备注: {note or '由海灵自动登记;勿在回复中打印 secret'}\n"
)

text = ACCOUNTS.read_text(encoding="utf-8", errors="replace")
# replace existing ## name block
pattern = re.compile(
rf"(?ms)^## {re.escape(name)}\n.*?(?=^## |\Z)"
)
if pattern.search(text):
text = pattern.sub(section + "\n", text)
else:
text = text.rstrip() + "\n\n" + section + "\n"
ACCOUNTS.write_text(text, encoding="utf-8")


def main() -> int:
p = argparse.ArgumentParser(description="Register secret for 海灵")
p.add_argument("--name", required=True, help="short name e.g. github-work")
p.add_argument("--type", dest="typ", default="api_token", choices=["api_token", "password", "oauth", "other"])
p.add_argument("--env", dest="env_key", default="", help="ENV var name e.g. GITHUB_TOKEN_WORK")
p.add_argument("--value", default="", help="secret value (api token/password)")
p.add_argument("--vault", default="", help="vault entry name if not using env")
p.add_argument("--vault-only", action="store_true", help="only write index, no .env value")
p.add_argument("--username", default="")
p.add_argument("--purpose", default="")
p.add_argument("--url", default="")
p.add_argument("--note", default="")
args = p.parse_args()

name = args.name.strip().lower()
if not valid_short_name(name):
print("ERROR: --name must be like github-work (lowercase, start letter)", file=sys.stderr)
return 2

env_key = (args.env_key or "").strip().upper()
value = args.value
vault = (args.vault or "").strip()

if args.vault_only:
if not vault:
vault = name
upsert_accounts(
name,
typ=args.typ,
env_key=None,
vault=vault,
username=args.username,
purpose=args.purpose,
url=args.url,
note=args.note or "仅索引;密文不在 env",
)
print(f"OK index-only name={name} vault={vault}")
return 0

if not env_key:
# derive from name
env_key = re.sub(r"[^a-z0-9]+", "_", name).upper().strip("_")
if not env_key[0].isalpha():
env_key = "ACC_" + env_key
if not valid_env_name(env_key):
print("ERROR: invalid env name", env_key, file=sys.stderr)
return 2
if not value:
print("ERROR: --value required unless --vault-only", file=sys.stderr)
return 2

upsert_env(env_key, value)
upsert_accounts(
name,
typ=args.typ,
env_key=env_key,
vault=None,
username=args.username,
purpose=args.purpose,
url=args.url,
note=args.note,
)
# success without echoing secret
print(f"OK registered name={name} env={env_key} value_len={len(value)} index=accounts.md")
return 0


if __name__ == "__main__":
raise SystemExit(main())

HERMES_REG_EOF

RUN chmod +x /usr/local/bin/hermes-boot.sh /usr/local/lib/hermes-startup/main.sh /root/bz-startup/main.sh \
/usr/local/bin/hermes-gateway-watchdog.sh /usr/local/bin/hermes-chrome-persist \
/usr/local/bin/google-chrome-wrapper /usr/local/bin/hailing-register-secret 2>/dev/null || true
EXPOSE 7860 22 9119
ENTRYPOINT ["/usr/local/bin/hermes-boot.sh"]

评论




站点访问量 Loading… 站点访客数 Loading…