fix(cli): attribute a scrubbed-env dashboard to its owner's home

`_hermes_home_for_pid` fell back to the INSPECTING process's `Path.home()`
when the target's environment carried neither HERMES_HOME nor HOME, which
is the normal shape for a systemd/launchd unit with a scrubbed
environment. The dashboard was then attributed to whichever user ran the
command, so `hermes update` and `--stop` read the wrong profile root and
could act on the wrong backend.

A process without HOME resolves its own default home from the password
database, so resolve the target's owner the same way (psutil, then the
/proc owner) before falling back. An unreadable owner or entry keeps the
previous behaviour rather than resolving to nothing.

Fixes #116906
This commit is contained in:
rodricksz4h5
2026-09-20 15:47:17 +05:30
committed by Teknium
parent f2f70bed7a
commit b3ae818261
2 changed files with 52 additions and 2 deletions

View File

@@ -123,6 +123,29 @@ def _pid_environ(pid: int) -> dict[str, str] | None:
return env
def _pid_passwd_home(pid: int) -> str | None:
"""Login home of the user *pid* runs as, from the password database (psutil, then /proc).
A service unit with a scrubbed environment exports no ``HOME``; the target resolves its own
default home through ``Path.home()``, which falls back to this entry. The inspecting process's
home belongs to a different user and must never stand in for it. ``None`` when the owner or the
entry is unreadable, leaving the caller its existing fallback.
"""
uid: int | None = None
with contextlib.suppress(Exception):
import psutil
uid = psutil.Process(pid).uids().real
if uid is None:
with contextlib.suppress(OSError):
uid = os.stat(f"/proc/{pid}").st_uid
if uid is None:
return None
with contextlib.suppress(Exception):
import pwd
return pwd.getpwuid(uid).pw_dir or None
return None
def _hermes_home_for_pid(pid: int) -> str | None:
"""The Hermes home *pid* runs on, tri-state: ``None`` ONLY when its environment is unreadable
(another user, hardened ``/proc``) — callers spare those, never guess.
@@ -131,7 +154,8 @@ def _hermes_home_for_pid(pid: int) -> str | None:
exec-time env + argv (``hermes -p X serve`` rewrites ``HERMES_HOME`` in ``os.environ`` AFTER
startup, which ``/proc/<pid>/environ`` never reflects): a profile-shaped ``HERMES_HOME``
without a flag is the home; otherwise the root is ``HERMES_HOME`` (its grandparent when
profile-shaped) or the platform default of the process's own ``HOME`` / ``LOCALAPPDATA``, and
profile-shaped) or the platform default of the process's own ``HOME`` / ``LOCALAPPDATA``
(its owner's password-database home when a scrubbed unit environment exports neither), and
the profile is the ``--profile``/``-p`` flag, else the root's sticky ``active_profile`` unless
the process has a fixed identity (supervised child, post-swap updater, Desktop SSH backend).
"""
@@ -152,7 +176,7 @@ def _hermes_home_for_pid(pid: int) -> str | None:
base = Path(local_appdata) if local_appdata else Path(env.get("USERPROFILE") or Path.home()) / "AppData" / "Local"
default_home = base / "hermes"
else:
default_home = Path(env.get("HOME") or Path.home()) / ".hermes"
default_home = Path(env.get("HOME") or _pid_passwd_home(pid) or Path.home()) / ".hermes"
root = profile_root_for_env_home(env_home, default_home)
fixed_identity = any(env.get(k) for k in ("HERMES_SUPERVISED_CHILD", "HERMES_S6_SUPERVISED_CHILD",
"HERMES_GATEWAY_EXTERNAL_SUPERVISOR")) or "--ssh-session-token-file" in argv

View File

@@ -14,6 +14,7 @@ Acceptance from #90471:
"""
import subprocess
import sys
from pathlib import Path
from unittest import mock
import pytest
@@ -258,6 +259,31 @@ class TestHermesHomeForPid:
assert dashboard_procs._hermes_home_for_pid(2) == f"{home}/.hermes/profiles/work"
assert dashboard_procs._pids_owned_by_hermes_home([1, 2], f"{home}/.hermes") == [1]
# REGRESSION (#116906): a systemd/launchd unit with a scrubbed environment exports no HOME.
# The target resolves its own default home from the password database, so attributing it to
# the INSPECTING process's home named another user's directory — and `hermes update` /
# `--stop` then acted on the wrong profile root.
def _posix_scrubbed_unit(self, monkeypatch, tmp_path, passwd_home):
"""A unit whose environment carries neither HOME nor HERMES_HOME, on the POSIX branch."""
monkeypatch.setattr(dashboard_procs.sys, "platform", "linux")
monkeypatch.setattr(dashboard_procs, "_pid_environ", lambda pid: {})
monkeypatch.setattr(dashboard_procs, "_pid_passwd_home", lambda pid: passwd_home)
monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "inspecting-user"))
from hermes_cli import main_dashboard
monkeypatch.setattr(main_dashboard, "_dashboard_cmdline_for_pid", lambda pid: ["hermes", "serve"])
def test_scrubbed_unit_env_resolves_to_the_owners_passwd_home(self, monkeypatch, tmp_path):
service_home = tmp_path / "hermes-service"
self._posix_scrubbed_unit(monkeypatch, tmp_path, str(service_home))
assert Path(dashboard_procs._hermes_home_for_pid(1)) == service_home / ".hermes"
def test_unreadable_passwd_entry_keeps_the_existing_fallback(self, monkeypatch, tmp_path):
"""No owner, no entry: the previous behaviour stands rather than resolving to nothing."""
self._posix_scrubbed_unit(monkeypatch, tmp_path, None)
assert Path(dashboard_procs._hermes_home_for_pid(1)) == tmp_path / "inspecting-user" / ".hermes"
def test_root_shaped_hermes_home_follows_the_flag_and_the_sticky_active_profile(self, monkeypatch, tmp_path):
"""Mirror ``_apply_profile_override``: an exported root ``HERMES_HOME`` is the root, not the
home — ``-p work`` and ``hermes profile use work`` both land in ``<root>/profiles/work``."""