diff --git a/Dockerfile b/Dockerfile index 6963e25bd0..795edbf198 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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/. The default fallback ($HOME/.cache) is the /opt/data diff --git a/docker/stage2-hook.sh b/docker/stage2-hook.sh index cff30f19a1..48eb36f5a9 100755 --- a/docker/stage2-hook.sh +++ b/docker/stage2-hook.sh @@ -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. diff --git a/pm/recovery.py b/pm/recovery.py index a4dfac4ad1..fa0ba47b34 100644 --- a/pm/recovery.py +++ b/pm/recovery.py @@ -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" diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index be79f57408..1e01e81322 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -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. | diff --git a/website/docs/user-guide/docker.md b/website/docs/user-guide/docker.md index 1ae551ca88..9e0a18564f 100644 --- a/website/docs/user-guide/docker.md +++ b/website/docs/user-guide/docker.md @@ -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`. diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md index 1aabd85165..810202f363 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md @@ -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` | 启动时加载的内置技能列表的逗号分隔覆盖。 | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/docker.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/docker.md index da78c8215d..e75a0f44c1 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/docker.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/docker.md @@ -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` 不修改镜像所有的代码,应用更新需替换镜像。