本指南面向能够管理 Linux 账户和私有 GitHub 仓库的自托管 Hermes Agent 操作人员。目标明确:创建完整 Hermes 用户数据归档,在离开主机前加密,确认已到达指定私有远程仓库,再在另一台一次性测试机器获取并导入。这套文档流程对应 Hermes CLI 的指定提交 86fa9e6,核对日期为 2026 年 10 月 19 日。BIG CHANGE 使用不执行真实操作的本地测试数据检查了示例的 shell 语法和控制流程;未执行真实 Hermes 备份、age 加密、GitHub 推送、定时触发或恢复。只有亲自完成以下全部检查后,才能认为该备份已在你的安装中得到验证。

重大变化

  • 发生了什么变化: Hermes 完整备份命令能将工作数据收集到 ZIP,无代理 cron 模式能运行经过审查的脚本。两者结合,操作人员可以建立定时加密副本,无须让 AI 模型执行备份。
  • 为什么重要: 操作人员可分别检查三个事实:Hermes 完成归档、密文到达指定私有仓库、全新安装能导入它。每项检查捕捉不同故障。归档仍不包含应用本身及部分机器本地数据。
  • 需要关注: 实际决策在于归档及不断增长的 Git 历史是否仍足够小,适合这条有限路线。恢复演练失败或触及任一大小停止条件时,应先制定其他存储方案,再依赖定时任务。

1. 准备主机、密钥和仓库

使用 Linux、Bash、GNU stat -c 和 du -sb,另需已安装的 Hermes CLI、Git、GitHub CLI(gh)、age、unzip、sha256sum、SSH 及用于 cron 的活跃 Hermes 网关。确认已安装的 hermes backup、hermes import 和 hermes cron create --help 与固定提交的 CLI 参考、常见问题及cron 手册相符。确定网关所属的操作系统用户和 HERMES_HOME。所有主机配置及首次手动备份均以该用户执行。示例假设使用专用且干净的 Git 克隆和 main 分支。

Hermes 完整备份包含数据根目录下的配置、凭据、记忆、技能、会话及配置档。它不包含 Hermes 代码、依赖和运行时下载、历史备份、检查点、浏览器凭据配置档、SQLite 辅助文件及部分机器本地文件。也不会收集主机其他位置的无关文件。不要使用 --quick 来实现这里的完整数据备份。检查边界之外的重要内容并另行保护。即使目标仓库为私有,ZIP 仍包含秘密。

在受保护的位置生成 age 身份,即解密归档所需的私钥;位置必须在 HERMES_HOME、暂存目录和 Git 克隆之外。将该身份的恢复副本独立存储。输出的接收者 age1… 是公开信息,应放入配置;私有身份不能放入。遵循 age 使用参考。

Terminal
umask 077
install -d -m 700 /PRIVATE-KEY-STORE
age-keygen -o /PRIVATE-KEY-STORE/hermes-backup-identity.txt
age-keygen -y /PRIVATE-KEY-STORE/hermes-backup-identity.txt

创建私有仓库,仅用于此次备份;检查访问者,为运行 Hermes cron 的同一操作系统用户配置非交互式 gh 读取及 Git SSH 写入。令牌和 SSH 私钥必须位于 Hermes 数据、暂存区及 Git 之外。用另一账户交互登录并不能证明定时访问可用。运行示例前替换大写占位符。如果创建仓库时已生成 README 或 main 分支,应检查并跳过冲突的初始化命令。

Terminal
gh repo create ACCOUNT/PRIVATE-REPOSITORY --private --clone
gh repo view ACCOUNT/PRIVATE-REPOSITORY --json isPrivate --jq .isPrivate
cd PRIVATE-REPOSITORY
git remote set-url origin git@github.com:ACCOUNT/PRIVATE-REPOSITORY.git
git remote get-url --all origin
git remote get-url --push --all origin
git branch -M main
printf '%s\n' 'Encrypted Hermes backup storage; ciphertext and checksum only.' > README.md
git add README.md
git commit -m 'Initialize private backup repository'
git push -u origin HEAD:main
git ls-remote --heads origin main

gh repo create --clone 可能根据本地偏好选择 HTTPS;上面明确的 SSH 远程地址才是本流程的目标。仅当 isPrivate 返回 true、协作者符合预期、有效抓取和推送 URL 各自均等于唯一已审查 SSH URL,且定时任务用户可成功远程访问时,才继续。Git 远程规则允许 URL 重写、额外目的地及单独的 pushurl;下方脚本每次都检查原始值与有效值。

保存接下来两个代码块前,先离开克隆目录。在 Hermes 数据根、Git 克隆、暂存目录及密钥存储之外创建私有配置目录。将 hermes-github-backup.sh 和 hermes-github-backup.conf 保存于此;安装命令会将它们复制到所属配置档。专用 Git 克隆必须保持干净。

Terminal
install -d -m 700 /ABSOLUTE/PRIVATE-SETUP
cd /ABSOLUTE/PRIVATE-SETUP

2. 安装已审查的备份脚本

将下面完整 Bash 代码块保存为 hermes-github-backup.sh,放在该私有配置目录。将第二个代码块保存为旁边的 hermes-github-backup.conf,并将所有占位符替换为绝对路径或公开接收者。这是需本地审查的 shell 配置,载入前应检查,权限仅限所有者。不得包含密码、令牌或私有 age 身份。脚本是完整建议示例;语法及不执行真实操作的控制流程已检查,但仍须在自己的安装中调整和测试。它产生的 latest.zip.age 是加密归档,即密文。

Terminal
#!/usr/bin/env bash
# Documentation-based Linux sample for an operator to review and adapt.
# Place with hermes-github-backup.conf under the owning HERMES_HOME/scripts/.
# Requires GNU stat -c and du -sb. Git Bash was used only for inert fixtures.
set -Eeuo pipefail
umask 077

script_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
owning_home=$(CDPATH= cd -- "$script_dir/.." && pwd -P)
if [[ -n ${HERMES_HOME:-} && $(CDPATH= cd -- "$HERMES_HOME" && pwd -P) != "$owning_home" ]]; then
  printf 'Backup stopped: cron profile and script directory differ.\n' >&2
  exit 1
fi
export HERMES_HOME="$owning_home"

config="$script_dir/hermes-github-backup.conf"
[[ -f "$config" ]] || { printf 'Backup stopped: reviewed configuration missing.\n' >&2; exit 1; }
# This file is local, owner-reviewed shell configuration. It must contain no secret.
# shellcheck source=/dev/null
source "$config"
: "${HERMES_BIN:?set an absolute Hermes executable path}"
: "${REPO_DIR:?set an absolute private clone path}"
: "${STAGING_PARENT:?set an absolute private staging path}"
: "${AGE_RECIPIENT:?set an age public recipient}"
: "${EXPECTED_REMOTE:?set the exact private GitHub SSH remote}"
: "${BRANCH:=main}"

fail() { printf 'Backup stopped: %s.\n' "$1" >&2; exit 1; }
for path in "$HERMES_BIN" "$REPO_DIR" "$STAGING_PARENT"; do
  [[ $path == /* ]] || fail 'a configured path is not absolute'
done
[[ -x "$HERMES_BIN" ]] || fail 'Hermes executable unavailable'
[[ $AGE_RECIPIENT == age1* ]] || fail 'age public recipient invalid'
[[ $EXPECTED_REMOTE == git@github.com:*/*.git ]] || fail 'remote is not the reviewed GitHub SSH URL'
[[ $BRANCH =~ ^[a-zA-Z0-9._/-]+$ ]] || fail 'branch name invalid'
[[ -d "$STAGING_PARENT" ]] || fail 'staging directory unavailable'
stage_parent=$(CDPATH= cd -- "$STAGING_PARENT" && pwd -P)
[[ $stage_parent != "$owning_home" && $stage_parent != "$owning_home"/* ]] || fail 'staging is inside Hermes data'

# Atomic, nonblocking whole-flow lock: keep it through push and remote readback.
# A stale directory after a crash requires operator inspection; never steal it.
lockdir="$stage_parent/.hermes-github-backup-flow.lock"
mkdir -- "$lockdir" 2>/dev/null || fail 'another backup flow holds the lock or a stale lock needs review'
work=''
cleanup() {
  if [[ -n $work ]]; then
    rm -f -- "$work/hermes.zip" "$work/latest.zip.age" "$work/latest.sha256" "$work/backup.log"
    rmdir -- "$work" 2>/dev/null || true
  fi
  rmdir -- "$lockdir" 2>/dev/null || true
}
trap cleanup EXIT

[[ -d "$REPO_DIR/.git" ]] || fail 'Git repository unavailable'
repo=$(CDPATH= cd -- "$REPO_DIR" && pwd -P)
[[ $repo != "$owning_home" && $repo != "$owning_home"/* ]] || fail 'repository is inside Hermes data'
[[ $stage_parent != "$repo" && $stage_parent != "$repo"/* ]] || fail 'staging is inside Git repository'
[[ $repo != "$stage_parent"/* ]] || fail 'Git repository is inside staging'

command -v age >/dev/null || fail 'age unavailable'
command -v gh >/dev/null || fail 'GitHub CLI unavailable for private visibility check'
command -v unzip >/dev/null || fail 'zip verifier unavailable'
command -v git >/dev/null || fail 'Git unavailable'
remote_repo=${EXPECTED_REMOTE#git@github.com:}
remote_repo=${remote_repo%.git}
visibility=$(gh repo view "$remote_repo" --json isPrivate --jq .isPrivate 2>/dev/null) || fail 'GitHub repository visibility unreadable'
[[ $visibility == true ]] || fail 'GitHub repository is not confirmed private'
raw_fetch_urls=$(git -C "$repo" config --get-all remote.origin.url) || fail 'Git fetch URL missing'
[[ $raw_fetch_urls == "$EXPECTED_REMOTE" ]] || fail 'Git has multiple or unexpected raw fetch URLs'
raw_push_urls=$(git -C "$repo" config --get-all remote.origin.pushurl || true)
[[ -z $raw_push_urls ]] || fail 'separate Git pushurl requires independent review'
effective_fetch_urls=$(git -C "$repo" remote get-url --all origin) || fail 'effective fetch URL unreadable'
effective_push_urls=$(git -C "$repo" remote get-url --push --all origin) || fail 'effective push URL unreadable'
[[ $effective_fetch_urls == "$EXPECTED_REMOTE" ]] || fail 'Git fetch URL rewrite or extra destination'
[[ $effective_push_urls == "$EXPECTED_REMOTE" ]] || fail 'Git push URL rewrite or extra destination'
[[ $(git -C "$repo" branch --show-current) == "$BRANCH" ]] || fail 'local branch changed'
[[ -z $(git -C "$repo" status --porcelain --untracked-files=all) ]] || fail 'repository has unrelated changes'

# No interactive prompt is possible in an unattended Hermes cron tick.
export GIT_TERMINAL_PROMPT=0
export GIT_SSH_COMMAND='ssh -o BatchMode=yes'
remote_head=$(git -C "$repo" ls-remote --heads origin "$BRANCH" | awk '{print $1}')
[[ -n $remote_head && $remote_head == "$(git -C "$repo" rev-parse HEAD)" ]] || fail 'remote unreadable or branch has changed'

work=$(mktemp -d "$stage_parent/hermes-backup.XXXXXXXX") || fail 'private staging creation failed'

if "$HERMES_BIN" backup -o "$work/hermes.zip" >"$work/backup.log" 2>&1; then
  :
else
  backup_rc=$?
  fail "Hermes full backup returned $backup_rc; temporary zip/log will be removed, so diagnose with a separate controlled manual run"
fi
[[ -s "$work/hermes.zip" ]] || fail 'Hermes archive absent or empty'
unzip -tq "$work/hermes.zip" >"$work/backup.log" 2>&1 || fail 'zip integrity check failed'
age -r "$AGE_RECIPIENT" -o "$work/latest.zip.age" "$work/hermes.zip" >"$work/backup.log" 2>&1 || fail 'archive encryption failed'
[[ -s "$work/latest.zip.age" ]] || fail 'encrypted archive absent or empty'

# Deliberately below GitHub's 50 MiB warning. Git history retains every version.
cipher_bytes=$(stat -c %s "$work/latest.zip.age")
(( cipher_bytes <= 40000000 )) || fail 'encrypted archive exceeds this small-repository recipe'
git_bytes=$(du -sb "$repo/.git" | awk '{print $1}')
(( git_bytes + cipher_bytes + 10000000 <= 500000000 )) || fail 'repository history approaches the review limit'
hash=$(sha256sum "$work/latest.zip.age" | awk '{print $1}')
printf '%s  latest.zip.age\n' "$hash" >"$work/latest.sha256"

cp -- "$work/latest.zip.age" "$repo/latest.zip.age"
cp -- "$work/latest.sha256" "$repo/latest.sha256"
git -C "$repo" add -- latest.zip.age latest.sha256
staged=$(git -C "$repo" diff --cached --name-only)
[[ $staged == $'latest.sha256\nlatest.zip.age' ]] || fail 'staged paths are not the two reviewed files'
git -C "$repo" diff --cached --check >/dev/null || fail 'staged content check failed'
git -C "$repo" commit -m "Encrypted Hermes backup $(date -u +%Y-%m-%dT%H:%M:%SZ)" >/dev/null || fail 'local backup commit failed'
run_head=$(git -C "$repo" rev-parse HEAD)
(( $(du -sb "$repo/.git" | awk '{print $1}') <= 500000000 )) || fail 'Git history exceeds review limit before push'
[[ $(git -C "$repo" rev-parse HEAD) == "$run_head" ]] || fail 'local HEAD changed before push'
git -C "$repo" push origin "$run_head:refs/heads/$BRANCH" >/dev/null || fail 'remote push failed; inspect exact remote before retrying'
remote_head=$(git -C "$repo" ls-remote --heads origin "$BRANCH" | awk '{print $1}')
[[ $remote_head == "$run_head" ]] || fail 'this run commit not confirmed at remote'
printf 'Encrypted backup verified at private remote commit %s.\n' "$run_head"
Terminal
# No passwords, tokens, age identities or private keys belong here.
HERMES_BIN=/absolute/path/to/hermes
REPO_DIR=/absolute/path/to/dedicated-private-github-clone
STAGING_PARENT=/absolute/path/to/private-staging-outside-hermes-and-git
AGE_RECIPIENT=age1replace_with_operator_public_recipient
EXPECTED_REMOTE=git@github.com:ACCOUNT/PRIVATE-REPOSITORY.git
BRANCH=main

在 Hermes 数据根和 Git 克隆之外创建暂存目录。把已审查文件放入所属配置档的 scripts/ 目录。每次运行前,克隆应保持干净并与远程 main 对齐。脚本在推送和读回确认完成之前锁定整个流程;绝不抢占已持有或陈旧的锁。仅允许将 latest.zip.age 和 latest.sha256 作为新备份路径加入 Git 暂存。当密文超过 40,000,000 字节,或本地 .git 加预留空间接近 500,000,000 字节 时停止。这些是示例的保守限制,不是 GitHub 存储保证。

Terminal
export HERMES_HOME=/absolute/path/to/owning-hermes-data-root
install -d -m 700 /ABSOLUTE/PRIVATE-STAGING
install -d -m 700 "$HERMES_HOME/scripts"
install -m 700 hermes-github-backup.sh "$HERMES_HOME/scripts/hermes-github-backup.sh"
install -m 600 hermes-github-backup.conf "$HERMES_HOME/scripts/hermes-github-backup.conf"
"$HERMES_HOME/scripts/hermes-github-backup.sh"

手动运行应以零状态退出,在重新查询远程后输出本次运行创建的提交。确认 gh repo view 仍显示私有,检查克隆跟踪的路径,并在指定远程核对精确提交。本地归档或本地提交不等于异地结果;停止的运行不能记为成功。Hermes 表示,完整备份仅在所有选中文件都加入后返回 0;归档不完整时返回 1,即使 ZIP 已存在;其他备份运行时返回 2。脚本退出时删除临时 ZIP 和日志;普通删除并非安全擦除。文件被跳过时,使用独立、受控的手动 hermes backup -o /PRIVATE-DIAGNOSTIC/hermes.zip 调查,检查退出状态和报告,再决定如何保护遗漏内容。

3. 安排运行并检查首次到期任务

仅在手动结果通过后,从同一配置档使用 Hermes 文档中的 --no-agent --script 模式。它执行文件而不调用 LLM。明确选择频率及不含秘密的本地状态输出。检查网关操作系统身份、配置档和下次到期时间。Hermes 的脚本超时依次取自 HERMES_CRON_SCRIPT_TIMEOUT、cron.script_timeout_seconds,然后采用默认 3,600 秒。根据实际观察到的运行设置并核对合适数值;超时即失败。精简后的脚本环境不会自动接收 Hermes 管理的供应商凭据,因此在依赖定时任务前,应以实际任务用户验证无人值守 gh 和 SSH 访问。

Terminal
hermes cron create '0 3 * * *' --no-agent --script hermes-github-backup.sh --deliver local --name 'Encrypted Hermes backup'
hermes cron list
hermes cron status
hermes cron doctor

首次定时到期后,检查精确任务 ID 和运行记录、本地输出(默认配置档位于 ~/.hermes/cron/output/)、任何事件记录及指定远程的头部提交。确认网关持续可用,运行报告的是此次触发产生的远程提交。手动调用不能证明 cron 已运行。没有输出时应检查该任务及网关,而非创建第二个计划。状态消息不得包含 ZIP 内容、秘密或凭据。

4. 在全新机器验证获取与导入

使用另一台一次性 Linux 测试机器,不能带有生产 Hermes 配置档或网关。先安装兼容 Hermes 版本:归档是用户数据,不是应用或运行时镜像。为测试机提供独立授权的指定私有仓库读取访问,以及独立保存的 age 身份。确认克隆远程及所选提交。不要在源主机或现有配置档运行 hermes import。所有恢复的定时任务及外部操作都应保持停用,直到有人审查目的地并决定是否启用。

Terminal
umask 077
unset HERMES_HOME
git clone git@github.com:ACCOUNT/PRIVATE-REPOSITORY.git /PRIVATE-RECOVERY/hermes-backup-clone
cd /PRIVATE-RECOVERY/hermes-backup-clone
sha256sum -c latest.sha256
age --decrypt -i /PRIVATE-KEY-STORE/hermes-backup-identity.txt -o /PRIVATE-RECOVERY/hermes.zip latest.zip.age
unzip -tq /PRIVATE-RECOVERY/hermes.zip
hermes import /PRIVATE-RECOVERY/hermes.zip
hermes setup
hermes cron list

根据预期保护范围检查导入的配置档、技能、记忆、会话及 cron 定义。hermes setup 会检查供应商配置,但还应检查需调整的凭据及机器特定路径。记录真实结果。校验和一致、ZIP 可解密,单凭这些不能证明恢复可用。演练后按本地处理政策删除明文测试 ZIP;普通删除不保证安全擦除。

检查中止时

Hermes 备份不完整、ZIP 或加密出错、有效 Git URL 异常、仓库非私有或不可读、流程锁占用、大小限制、推送失败、远程提交不匹配或导入失败,都使本次运行未经确认。解决具体原因,重试前重新检查当前状态。推送结果不确定时先检查精确远程分支与提交。崩溃后锁仍存在时,应检查活动进程及本地和远程状态;绝不删除活动锁。如果私有 age 身份丢失,必须有有效独立副本才能解密。明文秘密进入 Git 时先轮换泄露凭据;后来删除文件不会清除早期历史或其他克隆。

GitHub 大文件指南在 50 MiB 时警告,并阻止超过 100 MiB 的普通 Git 文件;同时指出 Git 并非为备份工具设计。示例刻意在这些限制以下停止。每次变化的密文都保留在 Git 历史,即使 latest.zip.age 被替换。不要默默提高 40 MB/500 MB 停止限制、重写历史,或把私有设置当成加密。大小或恢复检查失败时,重新评估备份专用存储或另行审查的 LFS 方案。账户计划、存储、网络和主机费用仍适用;无代理脚本本身不需要 LLM 调用。未测量本流程的时长、成功率或节省金额。

另一个范围更小的 Hermes 定时任务可参阅如何设置基于技能的周期性 Hermes 任务并验证首次运行。其报告检查不能替代加密归档及恢复演练。

来源与延伸阅读