Files
hermes-agent/tools/process_registry_output_log.py
Teknium 1237193f46 fix(windows): dirs made while elevated stay usable; an unusable token-lock dir parks once
An elevated Hermes (install, `hermes update` as admin, a gateway started from an
admin prompt) creating a dir with mode=0o700 gets CPython's protected
SYSTEM + Administrators + OWNER RIGHTS DACL, owned by Administrators. The same
user's non-elevated Desktop/gateway/CLI then gets WinError 5 inside it (#139845).

- hermes_dir_acl.make_user_dir: Path.mkdir that, only when the process is
  elevated on Windows, adds an inheriting full-control ACE for the token's user
  SID to every dir it created and to an existing leaf (repairs a dir an earlier
  elevated run left). No-op off Windows / unelevated. Routed through it: the
  gateway lock dir (scoped token locks, host rendezvous, update host
  obligation), bot mailbox, gateway.lock homes, pending_messages, media/attachment
  roots, process logs, spill dirs, vault, outbox, backups, home skeleton and
  mkdir_under_hermes_home (logs).
- W2: acquire_scoped_lock raises ScopedLockDirUnusable when this token may not
  use gateway-locks (or read a stale lock in it), logged WARNING once per dir with
  the repair (`icacls DIR /reset /t`). _acquire_platform_lock parks the adapter
  as non-retryable `lock_dir_unusable` naming the dir and the fix, instead of a
  raw PermissionError that Telegram retried forever.
- Builds on @KoNit-K's update-time ACL repair (#139870), which re-grants the
  whole home after an elevated update.

No Windows host was available: the ACL behavior is covered by
tests/test_hermes_dir_acl_windows.py (Windows CI) and needs a live retest.

Refs #134889 (W8, W2)
Refs #139845
Refs #140337
2026-10-17 08:03:52 -07:00

136 lines
5.7 KiB
Python

"""File-backed output for background processes a registered worker starts.
A managed worker is a per-turn interpreter. A stdout pipe whose read end that worker's reader
thread owns closes when the worker exits, so the child's next write raises SIGPIPE
(``broken_pipe``) and everything it printed is gone. Under a worker the child writes to a
profile-scoped log instead and its shell records the exit status beside it (the contract the
env-backend wrapper already uses), so whichever interpreter adopts the checkpoint entry next
(the session's next worker, the owner at startup) tails the same file and collects the exit.
"""
import codecs
from contextlib import suppress
import logging
import os
from pathlib import Path
import time
from typing import Optional
from hermes_dir_acl import make_user_dir
from hermes_constants import get_hermes_home
logger = logging.getLogger("tools.process_registry")
_TAIL_SECONDS = 0.2
# After the process ended: an orphaned grandchild may keep appending; stop draining after this.
_FINAL_DRAIN_MAX_BYTES = 1 << 20
def output_dir() -> Path:
return get_hermes_home() / "logs" / "process-output"
def worker_output_log(session_id: str) -> Optional[Path]:
"""The log a worker-spawned process writes to; None outside a registered worker."""
from agent.runtime_session_store import is_worker_process
if not is_worker_process():
return None
directory = output_dir()
make_user_dir(directory, mode=0o700, parents=True, exist_ok=True)
from tools.process_registry_results import RESULT_RETENTION_SECONDS
# Only a recorded exit retires a log: a quiet process can run longer than the retention.
cutoff = time.time() - RESULT_RETENTION_SECONDS
for exited in directory.glob("proc_*.exit"):
with suppress(FileNotFoundError):
if exited.stat().st_mtime < cutoff:
exited.with_suffix(".log").unlink(missing_ok=True)
exited.unlink()
return directory / f"{session_id}.log"
def adoptable_log(path) -> str:
"""A checkpointed log path, only when it names this profile's output directory."""
log = Path(str(path or ""))
return str(log) if path and log.parent == output_dir() and log.suffix == ".log" else ""
def open_output_log(log: Path) -> int:
"""Owner-only append descriptor for the child's stdout+stderr (the parent closes its copy)."""
return os.open(log, os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_BINARY", 0), 0o600)
def _exit_path(log) -> Path:
return Path(log).with_suffix(".exit")
def record_exit_command(log: Path, command: str) -> str:
"""Prefix ``command`` so the shell writes its exit status beside the log: an interpreter
that adopts the process after the spawner exited has no Popen to wait on."""
import shlex
target = shlex.quote(_exit_path(log).as_posix())
return f"__hermes_exit_file={target}; trap 'printf \"%s\\n\" \"$?\" > \"$__hermes_exit_file\"' EXIT\n{command}"
def recorded_exit_code(log) -> Optional[int]:
try:
return int(_exit_path(log).read_text(encoding="utf-8-sig").strip())
except (OSError, ValueError):
return None
class ProcessOutputLogMixin:
"""The subclass supplies ``_ingest_output``, ``_clean_shell_noise``, ``_finish_reader``,
``_finish_exited`` and ``_detached_host_fate``. See ProcessRegistry."""
def _log_reader_loop(self, session) -> None:
"""Tail ``session.output_log`` until its process ends. The spawner decides the exit from
its Popen; an adopter (no Popen) from the recorded status, else ``lost`` (-1)."""
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
proc = session.process
# poll()/wait() reconcile asks this reader to finish instead of reading a pipe itself.
session._reader_selectable = True
head_noise = True
def ingest(text: str) -> None:
nonlocal head_noise
if head_noise: # ``bash -lic`` startup warnings, as the pipe reader strips them
text = self._clean_shell_noise(text)
head_noise = not text.strip()
self._ingest_output(session, text)
def running() -> bool:
if session._reader_finish_requested.is_set():
return False
if proc is not None:
return proc.poll() is None
return self._detached_host_fate(session.pid, session.host_start_time) == "running"
try:
with open(session.output_log, "rb") as stream:
drained = 0
while True:
# Decided BEFORE the read: once the process is gone every byte it wrote is readable.
alive = running()
chunk = stream.read(65536)
if chunk:
if text := decoder.decode(chunk):
ingest(text)
drained += 0 if alive else len(chunk)
if drained < _FINAL_DRAIN_MAX_BYTES:
continue
if not alive:
break
time.sleep(_TAIL_SECONDS)
except OSError as exc:
logger.warning("Process output log %s unreadable: %s", session.output_log, exc)
if proc is not None:
self._finish_reader(session, decoder, ingest, "Log", proc.wait, lambda: proc.returncode)
return
if tail := decoder.decode(b"", final=True):
ingest(tail)
code = recorded_exit_code(session.output_log)
if code is None:
# SIGKILL / exec past the trap: the spawner is gone and no status was recorded.
self._finish_exited(session, -1, reason="lost", source="backend_lost")
else:
self._finish_exited(session, code)