feat(docker): install opt-in dependencies into PM generations on the volume

The image refused every lazy install (HERMES_DISABLE_LAZY_INSTALLS=1), so
edge-tts and the other opt-in SDKs could never be installed at runtime. PM
never writes the sealed /opt/hermes/.venv: it builds a generation under
$HERMES_HOME/installs and commits it in facts.json there, which already
survives container recreates and image updates. Drop the refusal.

Surviving updates means a new image boots under a selection resolved against
the previous image's lock. refresh_dependencies() re-resolves the recorded
extras and plugins against the current inputs; if that fails (offline), it
deselects the generation so the image's own environment boots, keeping the
extras recorded for the next boot or install. stage2 runs it as hermes before
any service starts, then collects generations nothing selects any more, since
nothing else collects them automatically and each one is a full venv.
This commit is contained in:
ethernet
2026-09-24 12:40:17 -04:00
parent 689ec30f34
commit 72df5aa60e
7 changed files with 82 additions and 21 deletions

View File

@@ -451,10 +451,10 @@ ENV HERMES_WEB_DIST=/opt/hermes/hermes_cli/web_dist
ENV HERMES_TUI_DIR=/opt/hermes/ui-tui
ENV HERMES_HOME=/opt/data
ENV HERMES_WRITE_SAFE_ROOT=/opt/data
ENV HERMES_DISABLE_LAZY_INSTALLS=1
# Lazy installs are fully disabled in the published image (see
# HERMES_DISABLE_LAZY_INSTALLS above): the venv is sealed and opt-in backend
# SDKs are not installed at runtime.
# Opt-in backend SDKs install on first use into PM dependency generations under
# /opt/data/installs (the sealed /opt/hermes/.venv is never written); stage2
# re-resolves them against each new image. security.allow_lazy_installs: false
# turns this off.
# Xfce, dbus and the display-allocation lock need one; containers have no logind
# to create /run/user/<uid>. The default fallback ($HOME/.cache) is the /opt/data

View File

@@ -257,13 +257,11 @@ fi
# --- Immutable install tree ---
# Do not chown runtime code or dependency trees under $INSTALL_DIR back to the
# hermes user. Hosted/container instances keep mutable state under
# $HERMES_HOME (/opt/data) and run with PYTHONDONTWRITEBYTECODE plus
# HERMES_DISABLE_LAZY_INSTALLS=1. Keeping /opt/hermes root-owned and
# non-writable prevents an agent session from self-modifying the installed
# source, venv, TUI bundle, or node_modules and bricking the gateway.
#
# Lazy installs are fully disabled at runtime (HERMES_DISABLE_LAZY_INSTALLS=1,
# see the Dockerfile), so no writable lazy-install target is provisioned here.
# $HERMES_HOME (/opt/data) and run with PYTHONDONTWRITEBYTECODE. Keeping
# /opt/hermes root-owned and non-writable prevents an agent session from
# self-modifying the installed source, venv, TUI bundle, or node_modules and
# bricking the gateway. On-demand dependency installs go to PM generations
# under $HERMES_HOME/installs, never into this tree.
# Always reset ownership of $HERMES_HOME/profiles to hermes on every
# boot. Profile dirs and files can land owned by root when commands
@@ -647,6 +645,27 @@ if [ -f "$HERMES_HOME/config.yaml" ]; then
|| echo "[stage2] Warning: docker_config_migrate.py failed; continuing"
fi
# --- Refresh the dependency generation for this image ---
# Opt-in dependencies (lazy extras, plugin deps) live in PM generations on the
# volume, selected by $HERMES_HOME/installs/*/facts.json. An image upgrade
# replaces uv.lock under that durable selection, so re-resolve it here, before
# any supervised service boots onto a generation built for the previous image.
# On failure (e.g. offline) PM falls back to the image's own environment and
# keeps the extras recorded for the next boot or install. Then collect the
# generations nothing selects any more: no service holds a lease yet, and
# collect_generations keeps anything younger than a day.
s6-setuidgid hermes "$INSTALL_DIR/.venv/bin/python" -c '
from pathlib import Path
from hermes_cli.runtime_state import collect_generations
from pm.environments import install_state_dir
from pm.recovery import refresh_dependencies
from pm.runtime import collect_runtime_generations
root = Path("'"$INSTALL_DIR"'")
print("[stage2] dependency environment:", refresh_dependencies(root))
removed = collect_generations(root) + collect_runtime_generations(install_state_dir(root) / "pm-runtime")
print("[stage2] collected", len(removed), "unused dependency generations")
' || echo "[stage2] Warning: dependency refresh failed; continuing"
# auth.json: bootstrap from env on first boot only. Same semantics as the
# pre-s6 entrypoint — the [ ! -f ] guard is critical to avoid clobbering
# rotated refresh tokens on container restart.

View File

@@ -56,3 +56,38 @@ def repair_dependencies(project_root: Path) -> None:
raise InstallError("venv", "recovery root does not match this PM installation")
with contextlib.redirect_stdout(sys.stderr):
sync_venv(repair=True)
def refresh_dependencies(project_root: Path) -> str:
"""Re-resolve the durable selection against inputs an external update replaced.
A container image swaps the code and lock under a selection recorded on the data volume; a
generation resolved against the previous lock must never boot the new code. Rebuilds the
recorded extras and plugins, or on failure boots the image's own environment while keeping
them recorded, so the next boot or install rebuilds them. Returns what happened.
"""
from hermes_cli.runtime_state import runtime_lock
from pm.client import sync_venv
from pm.environments import runtime_facts_path
from pm.install import venv_is_current
from pm.lock import Facts
from pm.paths import repo_root
root = Path(project_root).resolve()
if root != repo_root().resolve():
raise InstallError("venv", "refresh root does not match this PM installation")
if not runtime_facts_path(root).is_file():
return "base"
if venv_is_current(project_root=root):
return "current"
try:
with contextlib.redirect_stdout(sys.stderr):
sync_venv(explicit=True)
return "rebuilt"
except Exception as exc:
print(f"dependency refresh failed: {exc}", file=sys.stderr)
with runtime_lock(root, timeout=None):
facts = Facts(runtime_facts_path(root), strict=True)
fact = facts.get("venv") or {}
facts.record_state("venv", fact.get("stamp") or "stale", list(fact.get("extras") or []))
return "fallback"

View File

@@ -852,7 +852,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us
| `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false` — allow tools to fetch localhost/private-network URLs. Off by default in gateway mode. |
| `HERMES_REDACT_SECRETS` | `true`/`false` — control secret redaction in tool output, logs, and chat responses (default: `true`). |
| `HERMES_WRITE_SAFE_ROOT` | Optional directory prefix that **hard-blocks** `write_file`/`patch` writes outside the listed roots (no approval prompt). Supports multiple directories separated by `os.pathsep` (`:` on Unix, `;` on Windows). See [HERMES_WRITE_SAFE_ROOT](#hermes_write_safe_root) below. |
| `HERMES_DISABLE_LAZY_INSTALLS` | Internal PM policy used by the official Docker image and tests. Truthy values refuse on-demand installation rather than redirecting it to a lazy-packages overlay. It overrides the user-facing `security.allow_lazy_installs` setting. Do not put it in `.env`. |
| `HERMES_DISABLE_LAZY_INSTALLS` | Internal PM policy used by tests and install probes. Truthy values refuse on-demand installation. It overrides the user-facing `security.allow_lazy_installs` setting. Do not put it in `.env`. |
| `HERMES_DISABLE_FILE_STATE_GUARD` | Set to `1` to turn off the "file changed since you read it" guard on `patch`/`write_file`. |
| `HERMES_BUNDLED_SKILLS` | Comma-separated override for the list of bundled skills loaded at startup. |
| `HERMES_OPTIONAL_SKILLS` | Comma-separated list of optional-skill names to auto-install on first run. |

View File

@@ -534,11 +534,15 @@ Chromium is staged through PM, not `npx playwright install`. The build records
its resolved executable in `/etc/hermes/agent-browser-executable-path`.
`PLAYWRIGHT_BROWSERS_PATH` names `/opt/hermes/tools`, outside the data mount.
The image disables on-demand dependency installation with its internal
`HERMES_DISABLE_LAZY_INSTALLS` policy. Changing `security.allow_lazy_installs`
alone does not override that image policy. The old `lazy-packages` overlay is
not used. Build additional required dependencies into a derived image, or run
an independent tool in a separate environment/service.
Opt-in backend SDKs (Edge TTS, Firecrawl, Exa, platform adapters, plugin
dependencies) install on first use into PM dependency generations under
`/opt/data/installs`, so they survive container recreation and image updates.
The image's own `/opt/hermes/.venv` is never modified. On each boot the
container re-resolves the recorded selection against the new image's lock
before services start; if that fails (for example offline), it boots the
image's own environment and keeps the recorded extras for the next boot or
install. Set `security.allow_lazy_installs: false` to refuse on-demand
installs. The old `lazy-packages` overlay is not used.
Image provenance lives at `/etc/hermes/image-provenance.json`, outside both the
source and data mounts. The build stamp lives at `/opt/hermes/install-stamp.json`.

View File

@@ -568,7 +568,7 @@ Graph 事件(Teams 会议、日历、聊天等)的入站变更通知监听
| `HERMES_ALLOW_PRIVATE_URLS` | `true`/`false`——允许工具获取 localhost/私有网络 URL。gateway 模式下默认关闭。 |
| `HERMES_REDACT_SECRETS` | `true`/`false`——控制工具输出、日志和聊天响应中的密钥脱敏(默认:`true`)。 |
| `HERMES_WRITE_SAFE_ROOT` | 可选目录前缀,**硬阻止** `write_file`/`patch` 写入列出的根目录之外的路径(无审批提示)。支持多个目录,使用 `os.pathsep` 分隔(Unix 为 `:`,Windows 为 `;`)。详见下方 [HERMES_WRITE_SAFE_ROOT](#hermes_write_safe_root)。 |
| `HERMES_DISABLE_LAZY_INSTALLS` | 官方 Docker 镜像设置的内部按需安装禁用策略。仅更改 `security.allow_lazy_installs` 不能覆盖镜像策略;不要在 `.env` 中手动设置。 |
| `HERMES_DISABLE_LAZY_INSTALLS` | 测试和安装探针使用的内部按需安装禁用策略,优先于 `security.allow_lazy_installs`;不要在 `.env` 中手动设置。 |
| `HERMES_DISABLE_FILE_STATE_GUARD` | 设为 `1` 可关闭 `patch`/`write_file` 上的"文件自上次读取后已更改"保护。 |
| `HERMES_CORE_TOOLS` | 规范核心工具列表的逗号分隔覆盖(高级;极少需要)。 |
| `HERMES_BUNDLED_SKILLS` | 启动时加载的内置技能列表的逗号分隔覆盖。 |

View File

@@ -286,9 +286,12 @@ Chromium 由 PM 准备,不使用 `npx playwright install`。
实际可执行路径记录在 `/etc/hermes/agent-browser-executable-path`。
`PLAYWRIGHT_BROWSERS_PATH` 指向 `/opt/hermes/tools`,不在数据卷内。
镜像通过内部 `HERMES_DISABLE_LAZY_INSTALLS` 策略关闭按需安装。
仅修改 `security.allow_lazy_installs` 不能覆盖它。新增所需依赖应烘焙进派生镜像,
或作为独立工具放在单独环境或服务中。旧 `lazy-packages` overlay 不再使用。
可选后端 SDK(Edge TTS、Firecrawl、Exa、平台适配器、插件依赖)在首次使用时安装到
`/opt/data/installs` 下的 PM 依赖代,容器重建和镜像更新后仍然保留;镜像自带的
`/opt/hermes/.venv` 永不修改。每次启动时,容器在服务启动前按新镜像的锁文件重新解析
已记录的选择;若失败(例如离线),则使用镜像自带环境启动,并保留已记录的 extras,
留待下次启动或安装时重建。设置 `security.allow_lazy_installs: false` 可拒绝按需安装。
旧 `lazy-packages` overlay 不再使用。
构建来源记录在 `/etc/hermes/image-provenance.json`,构建戳记位于 `/opt/hermes/install-stamp.json`。
没有戳记的本地构建报告未知版本,不猜测提交。`hermes update` 不修改镜像所有的代码,应用更新需替换镜像。