Docker environment detection, update handling, config loading

NousResearch/hermes-agent · cluster 2305 · agent

Data as of

5 open items. Fix merged in #2328. Check it covers your case.

The short answer

3open issues
2open PRs
32items grouped
Known?
Yes. 32 related items grouped here, 8 issues already closed.
Fix event
A fix was merged to main in PR #2328. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PRs #2328, #35139, #37085, #109173. That is separate from the fix event.
Still open
3 issues and 2 PRs.

These items address overlapping concerns: correctly detecting Docker vs. non-Docker environments for update behavior, surfacing appropriate guidance instead of errors when updates are attempted inside containers, and loading platform/webhook configuration from config.yaml into the gateway and extra fields.

Open issues3

  • Issue#7070Consider git-free installation and version-based auto-update
  • Issue#10290[Feature]: Migrate hermes agent to newer version of hermes agent repo
  • Issue#45792[Bug]: Hermes Agent inside docker doesn't understand its environment

Open PRs2

  • PR#20506fix(gateway/config): merge top-level platform keys into extra (#20501)
  • PR#45811fix(prompt): surface the Hermes config/state directory in environment hints

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #2328 fix(gateway): load platforms section from config.yaml for webhook routes

    merged to main

    files: gateway/config.py

    Show the diff
    • gateway/config.py
      @@ -455,11 +455,27 @@ def load_gateway_config() -> GatewayConfig:
                           "pair",
                       )
       
      -            # Bridge per-platform settings from config.yaml into gw_data
      +            # Merge platforms section from config.yaml into gw_data so that
      +            # nested keys like platforms.webhook.extra.routes are loaded.
      +            yaml_platforms = yaml_cfg.get("platforms")
                   platforms_data = gw_data.setdefault("platforms", {})
                   if not isinstance(platforms_data, dict):
                       platforms_data = {}
                       gw_data["platforms"] = platforms_data
      +            if isinstance(yaml_platforms, dict):
      +                for plat_name, plat_block in yaml_platforms.items():
      +                    if not isinstance(plat_block, dict):
      +                        continue
      +                    existing = platforms_data.get(plat_name, {})
      +                    if not isinstance(existing, dict):
      +                        existing = {}
      +                    # Deep-merge extra dicts so gateway.json defaults survive
      +                    merged_extra = {**existing.get("extra", {}), **plat_block.get("extra", {})}
      +                    merged = {**existing, **plat_block}
      +                    if merged_extra:
      +                        merged["extra"] = merged_extra
      +                    platforms_data[plat_name] = merged
      +                gw_data["platforms"] = platforms_data
                   for plat in Platform:
                       if plat == Platform.LOCAL:
                           continue
  • #35139 fix(cli): don't treat any container as the Docker image for updates

    merged to main

    files: hermes_cli/config.py, tests/hermes_cli/test_pip_install_detection.py

    Show the diff
    • hermes_cli/config.py
      @@ -285,9 +285,22 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
           Resolution order:
           1. Stamped ``~/.hermes/.install_method`` file (written by installers)
           2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
      -    3. Container detection (/.dockerenv, /run/.containerenv, cgroup)
      -    4. .git directory presence -> 'git'
      -    5. Fallback -> 'pip'
      +    3. .git directory presence -> 'git'
      +    4. Fallback -> 'pip'
      +
      +    Note: running inside a container is NOT treated as "docker" on its own.
      +    The two supported install paths both self-identify via the
      +    ``.install_method`` stamp (caught by step 1), so neither relies on
      +    container detection here:
      +      - the curl installer (scripts/install.sh, the README/website install
      +        command) git-clones the repo and stamps ``git``;
      +      - the published ``nousresearch/hermes-agent`` image stamps ``docker``
      +        at boot via ``docker/stage2-hook.sh``.
      +    An unsupported manual install dropped into a container (no stamp) was
      +    wrongly classified as the published image by bare container detection,
      +    so ``hermes update`` bailed with "doesn't apply inside the Docker
      +    container". Without that fallback such installs fall through to the
      +    ``.git``/pip checks and behave like any off-path install. See issue #34397.
           """
           stamp = get_hermes_home() / ".install_method"
           try:
      @@ -299,9 +312,6 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
           managed = get_managed_system()
           if managed:
               return managed.lower().replace(" ", "-")
      -    from hermes_constants import is_container
      -    if is_container():
      -        return "docker"
           if project_root is None:
               project_root = Path(__file__).parent.parent.resolve()
           if (project_root / ".git").is_dir():
    • tests/hermes_cli/test_pip_install_detection.py
      @@ -48,12 +48,32 @@ def test_stamp_file_takes_precedence(tmp_path):
               assert detect_install_method(project_root=tmp_path) == "docker"
       
       
      -def test_docker_detected_via_dockerenv(tmp_path):
      +def test_container_without_stamp_is_not_docker(tmp_path):
      +    """An unstamped install in a generic container must NOT be flagged as docker.
      +
      +    Regression for issue #34397. The two supported installs both stamp
      +    ``.install_method`` (the curl installer -> ``git``, covered by
      +    ``test_stamp_file_takes_precedence``; the published image -> ``docker``),
      +    so neither hits this path. An unsupported manual install dropped into a
      +    container has no stamp and was wrongly classified as the published Docker
      +    image, so ``hermes update`` refused to run. With a ``.git`` checkout it
      +    must resolve to ``git``.
      +    """
      +    (tmp_path / ".git").mkdir()
           with patch("hermes_cli.config.get_managed_system", return_value=None), \
                patch("hermes_cli.config.get_hermes_home", return_value=tmp_path), \
                patch("hermes_constants.is_container", return_value=True):
               from hermes_cli.config import detect_install_method
      -        assert detect_install_method(project_root=tmp_path) == "docker"
      +        assert detect_install_method(project_root=tmp_path) == "git"
      +
      +
      +def test_container_pip_install_without_stamp_is_pip(tmp_path):
      +    """Container + no .git + no stamp -> pip, not docker (issue #34397)."""
      +    with patch("hermes_cli.config.get_managed_system", return_value=None), \
      +         patch("hermes_cli.config.get_hermes_home", return_value=tmp_path), \
      +         patch("hermes_constants.is_container", return_value=True):
      +        from hermes_cli.config import detect_install_method
      +        assert detect_install_method(project_root=tmp_path) == "pip"
       
       
       def test_recommended_update_command_docker():
  • #36263 fix(dashboard): guard update endpoint in Docker with structured guidance (salvage #34831)

    merged to main

    files: hermes_cli/web_server.py, tests/hermes_cli/test_web_server.py, web/src/lib/api.ts

  • #37085 fix(dashboard): surface Docker update guidance instead of generic failure (#34347)

    merged to main

    files: web/src/contexts/SystemActions.tsx

    Show the diff
    • web/src/contexts/SystemActions.tsx
      @@ -71,10 +71,28 @@ export function SystemActionsProvider({
             try {
               if (action === "restart") {
                 await api.restartGateway();
      +          setActiveAction(action);
               } else {
      -          await api.updateHermes();
      +          const resp = await api.updateHermes();
      +          // In a Docker install the image is immutable, so `hermes update`
      +          // can't apply — the endpoint returns 200 with a structured
      +          // {ok:false, error:"docker_update_unsupported", message, update_command}
      +          // envelope instead of spawning the action (see #34347 / #36263).
      +          // Surface that guidance to the user rather than starting the poll,
      +          // which would otherwise report a generic "failed (exit 1)".
      +          if (!resp.ok && resp.error === "docker_update_unsupported") {
      +            const cmd = resp.update_command ? `  ${resp.update_command}` : "";
      +            setToast({
      +              type: "success",
      +              message:
      +                (resp.message ??
      +                  "Updates don't apply inside Docker — re-pull the image instead.") +
      +                cmd,
      +            });
      +            return;
      +          }
      +          setActiveAction(action);
               }
      -        setActiveAction(action);
             } catch (err) {
               const detail = err instanceof Error ? err.message : String(err);
               setToast({
  • #48188 fix(update): scope install-method stamp to the code tree, not $HERMES_HOME

    merged to main

    files: Dockerfile, docker/stage2-hook.sh, hermes_cli/config.py, scripts/install.sh, tests/hermes_cli/test_pip_install_detection.py, tests/test_install_sh_install_method_stamp.py, tests/tools/test_dockerfile_immutable_install.py, tests/tools/test_stage2_hook_install_method_stamp.py

    Show the diff
    • Dockerfile
      @@ -206,9 +206,16 @@ USER root
       RUN mkdir -p /opt/hermes/bin && \
           cp /opt/hermes/docker/hermes-exec-shim.sh /opt/hermes/bin/hermes && \
           chmod 0755 /opt/hermes/bin/hermes && \
      +    printf 'docker\n' > /opt/hermes/.install_method && \
           chown -R root:root /opt/hermes && \
           chmod -R a+rX /opt/hermes && \
           chmod -R a-w /opt/hermes
      +# The ``.install_method`` stamp is baked next to the running code (the install
      +# tree), NOT into $HERMES_HOME. $HERMES_HOME (/opt/data) is a shared data
      +# volume that is commonly bind-mounted from the host and even shared with a
      +# host-side Desktop/CLI install; stamping it at boot used to clobber that
      +# host install's marker and wrongly block its ``hermes update``. A code-scoped
      +# stamp is read first by detect_install_method() and is immune to the share.
       # Start as root so the s6-overlay stage2 hook can usermod/groupmod and chown
       # the data volume. Each supervised service then drops to the hermes user via
       # `s6-setuidgid hermes` in its run script. If HERMES_UID is unset, services
    • docker/stage2-hook.sh
      @@ -291,13 +291,25 @@ as_hermes mkdir -p \
           "$HERMES_HOME/pairing" \
           "$HERMES_HOME/platforms/pairing"
       
      -# --- Install-method stamp (read by detect_install_method() in hermes status) ---
      -# Preserved from the tini-era entrypoint (PR #27843). Must be written as
      -# the hermes user so ownership matches the file's documented owner.
      -# tee is invoked directly via s6-setuidgid (no `sh -c` wrapper) for the
      -# same shell-metacharacter safety described above.
      -printf 'docker\n' | as_hermes tee "$HERMES_HOME/.install_method" >/dev/null \
      -    || true
      +# --- Install-method stamp ---
      +# The 'docker' stamp is baked into the immutable install tree at
      +# /opt/hermes/.install_method (see Dockerfile), NOT written here into
      +# $HERMES_HOME. detect_install_method() reads the code-scoped stamp first.
      +#
      +# Why we no longer stamp $HERMES_HOME: it is a shared DATA volume, commonly
      +# bind-mounted from the host (~/.hermes:/opt/data) and sometimes shared with a
      +# host-side Desktop/CLI install. Stamping 'docker' here clobbered that host
      +# install's marker, so its in-app updater read 'docker' and refused to run
      +# 'hermes update'. To heal homes already poisoned by older images, remove a
      +# stale 'docker' stamp from $HERMES_HOME if one is present (the host install's
      +# own installer re-creates its code-scoped stamp; a genuine container relies on
      +# the baked /opt/hermes stamp, so deleting the data-dir copy is safe).
      +if [ -f "$HERMES_HOME/.install_method" ]; then
      +    stamped="$(tr -d '[:space:]' < "$HERMES_HOME/.install_method" 2>/dev/null || true)"
      +    if [ "$stamped" = "docker" ]; then
      +        rm -f "$HERMES_HOME/.install_method" 2>/dev/null || true
      +    fi
      +fi
       
       # --- Seed config files (only on first boot) ---
       seed_one() {
    • hermes_cli/config.py
      @@ -350,52 +350,124 @@ def get_managed_update_command() -> Optional[str]:
           return None
       
       
      +def _install_method_project_root(project_root: Optional[Path] = None) -> Path:
      +    """Resolve the directory that holds the *running code* (the install tree).
      +
      +    This is the parent of ``hermes_cli/`` — i.e. the git checkout for source
      +    installs, ``/opt/hermes`` inside the published image, the venv's
      +    site-packages root for pip installs. It is a property of the running
      +    interpreter, NOT of ``$HERMES_HOME``, which is why a code-scoped stamp
      +    here is immune to two installs sharing one data directory.
      +    """
      +    if project_root is not None:
      +        return project_root
      +    return Path(__file__).parent.parent.resolve()
      +
      +
       def detect_install_method(project_root: Optional[Path] = None) -> str:
           """Detect how Hermes was installed: 'docker', 'nixos', 'homebrew', 'git', or 'pip'.
       
           Resolution order:
      -    1. Stamped ``~/.hermes/.install_method`` file (written by installers)
      -    2. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
      -    3. .git directory presence -> 'git'
      -    4. Fallback -> 'pip'
      +    1. Code-scoped stamp ``<install tree>/.install_method`` (next to the
      +       running code) — the authoritative marker.
      +    2. Legacy home-scoped stamp ``$HERMES_HOME/.install_method`` — read for
      +       backward compatibility, but a ``docker`` value is IGNORED when we are
      +       not actually running inside a container (see below).
      +    3. HERMES_MANAGED env / .managed marker (NixOS, Homebrew)
      +    4. .git directory presence -> 'git'
      +    5. Fallback -> 'pip'
      +
      +    Why the stamp is code-scoped, not home-scoped (issue: shared ``~/.hermes``)
      +    --------------------------------------------------------------------------
      +    The install method describes *the binary that is running*, but
      +    ``$HERMES_HOME`` is a shared DATA directory — the Docker docs deliberately
      +    bind-mount it (``~/.hermes:/opt/data``) so config/sessions/memory persist
      +    and can be shared with a host-side Desktop/CLI install. When a
      +    containerised gateway and a host install share one ``$HERMES_HOME``, a
      +    home-scoped stamp is a single slot describing two different installs:
      +    the container stamps ``docker`` on every boot, the host install then reads
      +    ``docker`` and ``hermes update`` refuses to run ("doesn't apply inside the
      +    Docker container") even though the host binary is a perfectly updatable
      +    git/pip install. Scoping the stamp to the install tree gives each install
      +    its own truthful marker.
      +
      +    Self-healing for already-poisoned homes: a legacy ``docker`` value in the
      +    home-scoped stamp is only honoured when we are genuinely in a container.
      +    On a host install that read a contaminating ``docker`` stamp, we fall
      +    through to managed/.git/pip detection instead — so existing shared-home
      +    setups recover without the user touching anything.
       
           Note: running inside a container is NOT treated as "docker" on its own.
      -    The two supported install paths both self-identify via the
      -    ``.install_method`` stamp (caught by step 1), so neither relies on
      -    container detection here:
      +    The supported installs self-identify via the code-scoped stamp:
             - the curl installer (scripts/install.sh, the README/website install
      -        command) git-clones the repo and stamps ``git``;
      -      - the published ``nousresearch/hermes-agent`` image stamps ``docker``
      -        at boot via ``docker/stage2-hook.sh``.
      -    An unsupported manual install dropped into a container (no stamp) was
      -    wrongly classified as the published image by bare container detection,
      -    so ``hermes update`` bailed with "doesn't apply inside the Docker
      -    container". Without that fallback such installs fall through to the
      -    ``.git``/pip checks and behave like any off-path install. See issue #34397.
      +        command) git-clones the repo and stamps ``git`` next to the code;
      +      - the published ``nousresearch/hermes-agent`` image bakes a ``docker``
      +        stamp into ``/opt/hermes`` at build time.
      +    An unsupported manual install dropped into a container (no stamp) falls
      +    through to the ``.git``/pip checks and behaves like any off-path install.
      +    See issue #34397.
           """
      -    stamp = get_hermes_home() / ".install_method"
      +    root = _install_method_project_root(project_root)
      +
      +    # 1. Code-scoped stamp — authoritative, immune to shared $HERMES_HOME.
           try:
      -        method = stamp.read_text(encoding="utf-8").strip().lower()
      +        method = (root / ".install_method").read_text(encoding="utf-8").strip().lower()
               if method:
                   return method
           except OSError:
               pass
      +
      +    # 2. Legacy home-scoped stamp — back-compat. Ignore a ``docker`` value
      +    #    when we are not actually containerised: that is the signature of a
      +    #    host install whose shared $HERMES_HOME was stamped by a co-located
      +    #    container, and honouring it wrongly blocks ``hermes update``.
      +    try:
      +        method = (
      +            (get_hermes_home() / ".install_method")
      +            .read_text(encoding="utf-8")
      +            .strip()
      +            .lower()
      +        )
      +        if method and not (method == "docker" and not _running_in_container()):
      +            return method
      +    except OSError:
      +        pass
      +
           managed = get_managed_system()
           if managed:
               return managed.lower().replace(" ", "-")
      -    if project_root is None:
      -        project_root = Path(__file__).parent.parent.resolve()
      -    if (project_root / ".git").is_dir():
      +    if (root / ".git").is_dir():
               return "git"
           return "pip"
       
       
      -def stamp_install_method(method: str) -> None:
      -    """Write the install method to ~/.hermes/.install_method."""
      -    stamp = get_hermes_home() / ".install_method"
      +def _running_in_container() -> bool:
      +    """Thin wrapper around ``hermes_constants.is_container`` (import-safe)."""
      +    try:
      +        from hermes_constants import is_container
      +
      +        return is_container()
      +    except Exception:
      +        return False
      +
      +
      +def stamp_install_method(method: str, project_root: Optional[Path] = None) -> None:
      +    """Write the install method next to the running code (code-scoped stamp).
      +
      +    The stamp lives in the install tree (``<install tree>/.install_method``),
      +    not in ``$HERMES_HOME``, so that two installs sharing one data directory
      +    do not overwrite each other's marker. See ``detect_install_method`` for
      +    the full rationale.
      +
      +    Best-effort: if the install tree is read-only (e.g. the immutable
      +    ``/opt/hermes`` in the published image, which instead bakes the stamp at
      +    build time) the write silently no-ops and detection falls back to its
      +    other signals.
      +    """
      +    root = _install_method_project_root(project_root)
           try:
      -        stamp.parent.mkdir(parents=True, exist_ok=True)
      -        stamp.write_text(method + "\n", encoding="utf-8")
      +        root.mkdir(parents=True, exist_ok=True)
      +        (root / ".install_method").write_text(method + "\n", encoding="utf-8")
           except OSError:
               pass
       
    • scripts/install.sh
      @@ -2732,7 +2732,12 @@ run_stage_body() {
                   detect_os
                   resolve_install_layout
                   print_success
      -            echo "git" > "$HERMES_HOME/.install_method"
      +            # Code-scoped stamp: write next to the install tree, not into
      +            # $HERMES_HOME. $HERMES_HOME is a shared data dir (it can be
      +            # bind-mounted into a Docker gateway too), so a stamp there gets
      +            # clobbered by the container's 'docker' stamp and wrongly blocks
      +            # 'hermes update' on this host install. See detect_install_method().
      +            echo "git" > "$INSTALL_DIR/.install_method"
                   ;;
               *)
                   log_error "Unknown stage: $stage"
      @@ -2811,7 +2816,12 @@ main() {
       
           print_success
       
      -    echo "git" > "$HERMES_HOME/.install_method"
      +    # Code-scoped stamp: write next to the install tree, not into $HERMES_HOME.
      +    # $HERMES_HOME is a shared data dir (it can be bind-mounted into a Docker
      +    # gateway too), so a stamp there gets clobbered by the container's 'docker'
      +    # stamp and wrongly blocks 'hermes update' on this host install.
      +    # See detect_install_method().
      +    echo "git" > "$INSTALL_DIR/.install_method"
       }
       
       if [ "$MANIFEST_MODE" = true ]; then
    • tests/hermes_cli/test_pip_install_detection.py
      @@ -48,6 +48,97 @@ def test_stamp_file_takes_precedence(tmp_path):
               assert detect_install_method(project_root=tmp_path) == "docker"
       
       
      +def test_code_scoped_stamp_wins_over_home_stamp(tmp_path):
      +    """The stamp next to the running code is authoritative over $HERMES_HOME.
      +
      +    Models a host git install whose $HERMES_HOME is shared with (and stamped
      +    'docker' by) a co-located container. The code-scoped stamp must win so the
      +    host install is correctly identified as 'git' and 'hermes update' works.
      +    """
      +    code = tmp_path / "code"
      +    home = tmp_path / "home"
      +    code.mkdir()
      +    home.mkdir()
      +    (code / ".install_method").write_text("git\n")
      +    (home / ".install_method").write_text("docker\n")  # container contamination
      +    with patch("hermes_cli.config.get_managed_system", return_value=None), \
      +         patch("hermes_cli.config.get_hermes_home", return_value=home):
      +        from hermes_cli.config import detect_install_method
      +        assert detect_install_method(project_root=code) == "git"
      +
      +
      +def test_home_docker_stamp_ignored_when_not_containerized(tmp_path):
      +    """A 'docker' home stamp is ignored on a host (non-container) install.
      +
      +    Self-heal path for homes already poisoned by an older image that wrote
      +    'docker' into the shared $HERMES_HOME. With no code-scoped stamp, a host
      +    git checkout must fall through to '.git' detection rather than honour the
      +    contaminating 'docker' value and refuse to update.
      +    """
      +    code = tmp_path / "code"
      +    home = tmp_path / "home"
      +    code.mkdir()
      +    home.mkdir()
      +    (code / ".git").mkdir()
      +    (home / ".install_method").write_text("docker\n")
      +    with patch("hermes_cli.config.get_managed_system", return_value=None), \
      +         patch("hermes_cli.config.get_hermes_home", return_value=home), \
      +         patch("hermes_cli.config._running_in_container", return_value=False):
      +        from hermes_cli.config import detect_install_method
      +        assert detect_install_method(project_root=code) == "git"
      +
      +
      +def test_home_docker_stamp_honored_inside_container(tmp_path):
      +    """A 'docker' home stamp is still honoured when genuinely containerized.
      +
      +    Back-compat: an older published image that only ever wrote the home-scoped
      +    stamp (no baked code stamp) must still resolve to 'docker' so the update
      +    path keeps directing the user to ``docker pull``.
      +    """
      +    code = tmp_path / "code"
      +    home = tmp_path / "home"
      +    code.mkdir()
      +    home.mkdir()
      +    (home / ".install_method").write_text("docker\n")
      +    with patch("hermes_cli.config.get_managed_system", return_value=None), \
      +         patch("hermes_cli.config.get_hermes_home", return_value=home), \
      +         patch("hermes_cli.config._running_in_container", return_value=True):
      +        from hermes_cli.config import detect_install_method
      +        assert detect_install_method(project_root=code) == "docker"
      +
      +
      +def test_home_non_docker_stamp_still_honored_for_backcompat(tmp_path):
      +    """Legacy non-'docker' home stamps (e.g. 'git') are still respected.
      +
      +    Only the 'docker' value carries the cross-contamination risk, so a host
      +    install that historically stamped 'git'/'pip' into $HERMES_HOME keeps
      +    resolving from there when no code-scoped stamp exists yet.
      +    """
      +    code = tmp_path / "code"
      +    home = tmp_path / "home"
      +    code.mkdir()
      +    home.mkdir()
      +    (home / ".install_method").write_text("git\n")
      +    with patch("hermes_cli.config.get_managed_system", return_value=None), \
      +         patch("hermes_cli.config.get_hermes_home", return_value=home), \
      +         patch("hermes_cli.config._running_in_container", return_value=False):
      +        from hermes_cli.config import detect_install_method
      +        assert detect_install_method(project_root=code) == "git"
      +
      +
      +def test_stamp_install_method_writes_code_scoped(tmp_path):
      +    """stamp_install_method writes next to the code, not into $HERMES_HOME."""
      +    code = tmp_path / "code"
      +    home = tmp_path / "home"
      +    code.mkdir()
      +    home.mkdir()
      +    with patch("hermes_cli.config.get_hermes_home", return_value=home):
      +        from hermes_cli.config import stamp_install_method
      +        stamp_install_method("pip", project_root=code)
      +    assert (code / ".install_method").read_text().strip() == "pip"
      +    assert not (home / ".install_method").exists()
      +
      +
       def test_container_without_stamp_is_not_docker(tmp_path):
           """An unstamped install in a generic container must NOT be flagged as docker.
       
    • tests/test_install_sh_install_method_stamp.py
      @@ -0,0 +1,40 @@
      +"""Contract test: install.sh stamps the install method next to the code tree
      +($INSTALL_DIR), not into the shared $HERMES_HOME.
      +
      +Background (shared-$HERMES_HOME bug)
      +------------------------------------
      +$HERMES_HOME is a data directory users frequently bind-mount into a Docker
      +gateway as well (``~/.hermes:/opt/data``). The published image stamps 'docker'
      +there on boot, so if install.sh had written its 'git' marker into the same
      +$HERMES_HOME the two installs would fight over one slot — and the container,
      +booting last, would win and wrongly make the host install look like 'docker'
      +(blocking ``hermes update``).
      +
      +The fix: detect_install_method() reads a CODE-scoped stamp first, and the
      +installer writes ``git`` into $INSTALL_DIR (the git checkout, e.g.
      +``~/.hermes/hermes-agent``), which is unique to this install and immune to the
      +shared data dir.
      +"""
      +from __future__ import annotations
      +
      +import re
      +from pathlib import Path
      +
      +REPO_ROOT = Path(__file__).resolve().parent.parent
      +INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
      +
      +
      +def test_install_sh_stamps_code_tree_not_home() -> None:
      +    text = INSTALL_SH.read_text()
      +
      +    # Stamps the code tree.
      +    assert text.count('echo "git" > "$INSTALL_DIR/.install_method"') >= 1, (
      +        "install.sh must stamp $INSTALL_DIR/.install_method (code-scoped)"
      +    )
      +
      +    # Never stamps the shared data dir.
      +    assert not re.search(r'>\s*"\$HERMES_HOME/\.install_method"', text), (
      +        "install.sh must not stamp $HERMES_HOME/.install_method — that data "
      +        "dir may be shared with a Docker gateway whose 'docker' stamp would "
      +        "clobber it and block host-side `hermes update`"
      +    )
    • tests/tools/test_dockerfile_immutable_install.py
      @@ -59,3 +59,28 @@ def test_dockerfile_does_not_chown_install_trees_to_hermes() -> None:
                   "runtime install trees under /opt/hermes must stay immutable; "
                   f"found forbidden pattern {pattern!r}"
               )
      +
      +
      +def test_dockerfile_bakes_code_scoped_install_method_stamp() -> None:
      +    """The 'docker' install-method stamp is baked next to the code.
      +
      +    detect_install_method() reads the code-scoped stamp
      +    (/opt/hermes/.install_method) first; baking it at build time keeps the
      +    published image self-identifying as 'docker' WITHOUT writing into the
      +    shared $HERMES_HOME data volume (which a host install may also use).
      +    It must live inside the immutable block so the runtime user can't alter it.
      +    """
      +    text = _dockerfile_text()
      +    assert "printf 'docker\\n' > /opt/hermes/.install_method" in text
      +
      +    immutable_block = re.search(
      +        r"RUN mkdir -p /opt/hermes/bin && \\\n"
      +        r"(?:.*\\\n)+?"
      +        r"\s+chmod -R a-w /opt/hermes",
      +        text,
      +    )
      +    assert immutable_block, "immutable block must exist"
      +    assert ".install_method" in immutable_block.group(0), (
      +        "the code-scoped install-method stamp must be baked inside the "
      +        "immutable /opt/hermes block"
      +    )
    • tests/tools/test_stage2_hook_install_method_stamp.py
      @@ -0,0 +1,61 @@
      +"""Contract test: the s6-overlay stage2 hook must NOT stamp the install method
      +into the shared $HERMES_HOME, and must heal a stale 'docker' stamp left there
      +by older images.
      +
      +Background (shared-$HERMES_HOME bug)
      +------------------------------------
      +$HERMES_HOME (/opt/data) is a DATA volume that users commonly bind-mount from
      +the host (``~/.hermes:/opt/data``) and sometimes share with a host-side
      +Desktop/CLI install. Older images wrote ``printf 'docker' > $HERMES_HOME/.install_method``
      +at boot, which clobbered the host install's own marker — so the host's in-app
      +updater read 'docker' and refused to run ``hermes update`` ("doesn't apply
      +inside the Docker container").
      +
      +The fix scopes the stamp to the install tree (baked at
      +``/opt/hermes/.install_method`` in the Dockerfile, read first by
      +``detect_install_method``). stage2 must therefore:
      +
      +  * NOT write the 'docker' stamp into $HERMES_HOME any more, and
      +  * proactively remove a stale 'docker' stamp from $HERMES_HOME so homes
      +    already poisoned by an older image self-heal on the next boot.
      +"""
      +from __future__ import annotations
      +
      +import re
      +from pathlib import Path
      +
      +import pytest
      +
      +REPO_ROOT = Path(__file__).resolve().parents[2]
      +STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh"
      +
      +
      [redacted:email](scope="module")
      +def stage2_text() -> str:
      +    if not STAGE2_HOOK.exists():
      +        pytest.skip("docker/stage2-hook.sh not present in this checkout")
      +    return STAGE2_HOOK.read_text()
      +
      +
      +def test_stage2_does_not_write_install_method_into_home(stage2_text: str) -> None:
      +    # No write/tee of the home-scoped install-method stamp anywhere.
      +    assert not re.search(
      +        r"(tee|>)\s*\"?\$HERMES_HOME/\.install_method", stage2_text
      +    ), (
      +        "stage2 must not stamp $HERMES_HOME/.install_method — that data dir "
      +        "may be shared with a host install whose marker would be clobbered"
      +    )
      +
      +
      +def test_stage2_heals_stale_docker_home_stamp(stage2_text: str) -> None:
      +    # It must remove a stale 'docker' stamp from $HERMES_HOME so already
      +    # poisoned shared homes recover.
      +    assert 'rm -f "$HERMES_HOME/.install_method"' in stage2_text, (
      +        "stage2 must remove a stale 'docker' stamp from $HERMES_HOME to heal "
      +        "homes poisoned by older images"
      +    )
      +    # The removal must be guarded on the value being 'docker' so we never
      +    # delete a legitimately-different stamp a user/host install put there.
      +    assert re.search(r'\[\s*"\$stamped"\s*=\s*"docker"\s*\]', stage2_text), (
      +        "the stale-stamp removal must be guarded on the value == 'docker'"
      +    )
  • #109173 Top-level platform keys like platforms.webhook.port reach the adapter (#10206, #20501)

    merged to main

    files: gateway/config.py, gateway/config_loader.py, tests/gateway/test_config.py, website/docs/user-guide/messaging/webhooks.md

13 PRs closed without merging

From the close comments: done elsewhere 5 · duplicate 3 · merged via another change 3 · PR volume cleanup 1 · closed without comment 1

  • #2307 fix(gateway): bridge webhook extra.routes from config.yaml to gateway config · done elsewhere → #2328
  • #2308 fix: Webhook route docs/config mismatch · merged via another change → #2328
  • #2310 feat(config): add support for nested platform mapping in config.yaml · closed without comment
  • #10211 fix(config): forward unknown platform keys into config.extra · duplicate → #10453
  • #10219 fix(gateway): pass unknown platform config keys to extra · duplicate → #10453
Show 8 more
  • #10453 fix(gateway): preserve top-level platform config keys · PR volume cleanup
  • #20528 fix: read top-level api_server config fields into extra · done elsewhere
  • #21114 fix(gateway): honor top-level platform config extras · duplicate → #20506, #10453
  • #34426 fix(cli): scope docker install detection to the official image · done elsewhere → c1b2d09, #35139, #34397
  • #34831 fix: guard dashboard update in Docker · merged via another change → #36263, #34347, #35835
  • #35034 fix(cli): check .git before stamp file in detect_install_method · done elsewhere → 4440d77, #48188
  • #35339 fix(dashboard): guard update endpoint in Docker with structured guidance · merged via another change → #36263, #34831
  • #36254 fix(config): ignore stale docker install-method stamp when .git exists (#35835) · done elsewhere → 4440d77, #48188
Closed issues · 8
  • Closed#2305[Bug]: Webhook route docs/config mismatch
  • Closed#10206[Bug]: platforms.webhook.port in config.yaml does not work
  • Closed#20501platforms.api_server config values (port, host, key) silently ignored when not nested under 'extra'
  • Closed#34347WebUI "Update" button surfaces raw SystemExit when run in Docker context
  • Closed#34397[Bug]: Hermes dont discriminate between running in a docker container and being the docker image.
  • Closed#35026[Bug]: linux: update fails
  • Closed#35835[Bug]: hermes update doesn't apply inside the Docker container.
  • Closed#50620[Bug]: Inside Docker Fails to Configuration sub component settings
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.06: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#2328, #35139, #36263, #37085, #48188, #109173
Closed issues
8
Attempts
13 PRs closed without merging
Fix link checked
12 Sep 2026

For your agent

The same facts, typed.