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:
@@ -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
|
||||
|
||||
@@ -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``."""
|
||||
|
||||
Reference in New Issue
Block a user