refactor(hclib): shared roots — hermes_constants, hermes_logging, utils, _subprocess_compat compaction and dedupe
This commit is contained in:
@@ -1,29 +1,12 @@
|
||||
"""Windows subprocess compatibility helpers.
|
||||
|
||||
Hermes is developed on Linux / macOS and tested natively on Windows too.
|
||||
Several common subprocess patterns break silently-or-loudly on Windows:
|
||||
* ``["npm", "install", ...]`` — on Windows ``npm`` is ``npm.cmd``, a batch shim.
|
||||
``subprocess.Popen(["npm", ...])`` fails with WinError 193 ("not a valid Win32 application") because
|
||||
CreateProcessW can't run a ``.cmd`` file without ``shell=True`` or PATHEXT resolution.
|
||||
|
||||
* ``["npm", "install", ...]`` — on Windows ``npm`` is ``npm.cmd``, a batch
|
||||
shim. ``subprocess.Popen(["npm", ...])`` fails with WinError 193
|
||||
("not a valid Win32 application") because CreateProcessW can't run a
|
||||
``.cmd`` file without ``shell=True`` or PATHEXT resolution.
|
||||
|
||||
* ``start_new_session=True`` — on POSIX, this maps to ``os.setsid()`` and
|
||||
actually detaches the child. On Windows it's silently ignored; the
|
||||
Windows equivalent is the ``CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW``
|
||||
creationflags bundle, which Python only applies when you pass it
|
||||
explicitly.
|
||||
|
||||
* Console-window flashes — every ``subprocess.Popen`` of a ``.exe`` on
|
||||
Windows spawns a cmd window briefly unless ``CREATE_NO_WINDOW`` is
|
||||
passed. Cosmetic but jarring for background daemons.
|
||||
|
||||
This module centralizes the platform-branching logic so the rest of the
|
||||
codebase doesn't sprinkle ``if sys.platform == "win32":`` everywhere.
|
||||
|
||||
**All helpers are no-ops on non-Windows** — calling them in Linux/macOS
|
||||
code paths is safe by design. That's the "do no damage on POSIX"
|
||||
guarantee.
|
||||
* ``start_new_session=True`` — on POSIX, this maps to ``os.setsid()`` and actually detaches the
|
||||
child. On Windows it's silently ignored; the Windows equivalent is the ``CREATE_NEW_PROCESS_GROUP |
|
||||
CREATE_NO_WINDOW`` creationflags bundle, which Python only applies when you pass it explicitly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -115,20 +98,10 @@ _WINDOWS_GATEWAY_BREAKAWAY_ENV = "_HERMES_GATEWAY_BREAKAWAY"
|
||||
def split_command_line(line: str) -> list[str]:
|
||||
"""Split a user-supplied command line into tokens, Windows-safely.
|
||||
|
||||
``shlex.split(line)`` (posix=True) treats every backslash as an escape
|
||||
character, so Windows paths are silently mangled: ``C:\\Users\\me\\out.txt``
|
||||
becomes ``C:Usersmeout.txt`` — no error, just a wrong path that then
|
||||
"succeeds" against a mangled relative filename (#83934) or makes a valid
|
||||
hook script report "not executable" (#78293).
|
||||
|
||||
On Windows this uses ``posix=False``, which preserves backslashes while
|
||||
still honoring double-quoted tokens ("path with spaces"). The trade-off
|
||||
is that posix=False keeps surrounding quotes on quoted tokens, so we
|
||||
strip one layer of matching double quotes per token — that matches how
|
||||
Windows command lines are conventionally parsed. On POSIX the behavior
|
||||
is exactly ``shlex.split``.
|
||||
|
||||
Raises ValueError for unbalanced quotes, same as ``shlex.split``.
|
||||
``shlex.split`` (posix=True) treats every backslash as an escape, silently mangling Windows
|
||||
paths (the separators vanish, leaving a wrong relative name). On Windows use ``posix=False``
|
||||
and strip one layer of matching double quotes per token; on POSIX this is exactly
|
||||
``shlex.split``. Raises ValueError on unbalanced quotes.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
import shlex
|
||||
@@ -153,34 +126,13 @@ def split_command_line(line: str) -> list[str]:
|
||||
def resolve_node_command(name: str, argv: Sequence[str]) -> list[str]:
|
||||
"""Resolve a Node-ecosystem command name to an absolute-path argv.
|
||||
|
||||
On Windows, commands like ``npm``, ``npx``, ``yarn``, ``pnpm``,
|
||||
``playwright``, ``prettier`` ship as ``.cmd`` files (batch shims).
|
||||
``subprocess.Popen(["npm", "install"])`` fails with WinError 193
|
||||
because CreateProcessW doesn't execute batch files directly.
|
||||
On Windows, commands like ``npm``, ``npx``, ``yarn``, ``pnpm``, ``playwright``, ``prettier``
|
||||
ship as ``.cmd`` files (batch shims). ``subprocess.Popen(["npm", "install"])`` fails with
|
||||
WinError 193 because CreateProcessW doesn't execute batch files directly.
|
||||
|
||||
``shutil.which(name)`` *does* resolve ``.cmd`` via PATHEXT and returns
|
||||
the fully-qualified path — which CreateProcessW accepts because the
|
||||
extension tells Windows to route through ``cmd.exe /c``.
|
||||
|
||||
On POSIX ``shutil.which`` also returns a fully-qualified path when
|
||||
found. That's a small change from bare-name resolution (the OS does
|
||||
its own PATH search) but functionally identical and has the side
|
||||
benefit of making the argv reproducible in logs.
|
||||
|
||||
Behavior when the command is not on PATH:
|
||||
- On Windows: return the bare name — caller can still try with
|
||||
``shell=True`` as a last resort, OR the subsequent Popen will
|
||||
raise FileNotFoundError with a readable error we want to surface.
|
||||
- On POSIX: same. Bare ``npm`` on a Linux box without npm installed
|
||||
fails the same way it did before this function existed.
|
||||
|
||||
Args:
|
||||
name: The command name to resolve (``npm``, ``npx``, ``node`` …).
|
||||
argv: The remaining arguments. Must NOT include ``name`` itself —
|
||||
this function builds the full argv list.
|
||||
|
||||
Returns:
|
||||
A list suitable for passing to subprocess.Popen/run/call.
|
||||
``shutil.which(name)`` *does* resolve ``.cmd`` via PATHEXT and returns the fully-qualified path
|
||||
— which CreateProcessW accepts because the extension tells Windows to route through ``cmd.exe
|
||||
/c``.
|
||||
"""
|
||||
resolved = shutil.which(name)
|
||||
if resolved:
|
||||
@@ -228,42 +180,14 @@ _CREATE_BREAKAWAY_FROM_JOB = 0x01000000
|
||||
|
||||
|
||||
def windows_detach_flags() -> int:
|
||||
"""Return Win32 creationflags that detach a child from the parent
|
||||
console and process group without leaving it console-less. 0 on
|
||||
non-Windows.
|
||||
"""Return Win32 creationflags detaching a child from the parent console/group; 0 elsewhere.
|
||||
|
||||
Pair with ``start_new_session=False`` (default) when calling
|
||||
subprocess.Popen — on POSIX use ``start_new_session=True`` instead,
|
||||
which maps to ``os.setsid()`` in the child.
|
||||
|
||||
Rationale:
|
||||
- ``CREATE_NEW_PROCESS_GROUP`` — child has its own process group so
|
||||
Ctrl+C in the parent console doesn't propagate.
|
||||
- ``CREATE_NO_WINDOW`` — the child gets its own fresh console that is
|
||||
never shown. This both detaches it from the parent's console
|
||||
lifetime (closing the launching terminal doesn't CTRL_CLOSE it) AND
|
||||
gives every console-subsystem descendant (git, gh, cmd, node, …) a
|
||||
console to inherit, so they don't allocate visible flashing ones.
|
||||
This deliberately replaces the old ``DETACHED_PROCESS`` approach:
|
||||
MSDN specifies CREATE_NO_WINDOW is *ignored* when combined with
|
||||
DETACHED_PROCESS, and a truly console-less daemon re-creates the
|
||||
per-descendant console-flash bug (#54220/#56747) at every spawn —
|
||||
see the note on ``_DETACHED_PROCESS`` above.
|
||||
- ``CREATE_BREAKAWAY_FROM_JOB`` — escape any job object the parent is
|
||||
in. Electron (Desktop app) and Tauri (bootstrap installer) wrap
|
||||
their children in job objects; without breakaway, those children
|
||||
die when the parent process exits even though they have their own
|
||||
console. This was the missing flag that made the post-update
|
||||
gateway respawn watcher silently die alongside the Tauri updater
|
||||
after the Electron Desktop's update flow finished.
|
||||
|
||||
If a process is in a job that disallows breakaway (rare —
|
||||
JOB_OBJECT_LIMIT_BREAKAWAY_OK isn't set), CreateProcess returns
|
||||
ERROR_ACCESS_DENIED. Python surfaces that as ``PermissionError``
|
||||
on the ``subprocess.Popen`` call. Callers in this codebase already
|
||||
wrap detached spawns in ``try/except OSError`` and fall back to a
|
||||
cmd.exe wrapper, so the breakaway-denied case degrades gracefully
|
||||
rather than crashing.
|
||||
Pair with the default ``start_new_session=False`` (POSIX uses ``start_new_session=True``).
|
||||
CREATE_NEW_PROCESS_GROUP stops Ctrl+C propagating; CREATE_NO_WINDOW gives the child a hidden
|
||||
console that descendants inherit, avoiding per-descendant console flashes (DETACHED_PROCESS
|
||||
would make CREATE_NO_WINDOW ignored and re-create that bug); CREATE_BREAKAWAY_FROM_JOB escapes
|
||||
Electron/Tauri job objects that would otherwise kill the child with the parent. A job that
|
||||
forbids breakaway yields PermissionError from Popen -- callers catch OSError and fall back.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return 0
|
||||
@@ -277,27 +201,8 @@ def windows_detach_flags() -> int:
|
||||
def windows_detach_flags_without_breakaway() -> int:
|
||||
"""Same as :func:`windows_detach_flags` minus ``CREATE_BREAKAWAY_FROM_JOB``.
|
||||
|
||||
The docstring on :func:`windows_detach_flags` notes that a process in
|
||||
a job which disallows breakaway (no ``JOB_OBJECT_LIMIT_BREAKAWAY_OK``)
|
||||
will see ``ERROR_ACCESS_DENIED`` from CreateProcess, surfacing as
|
||||
``OSError`` (``PermissionError``) on the ``subprocess.Popen`` call.
|
||||
Callers that want to recover — by retrying without the breakaway
|
||||
bit — can pair the two helpers symbolically rather than coding the
|
||||
``& ~0x01000000`` magic at every site:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
try:
|
||||
subprocess.Popen(argv, creationflags=windows_detach_flags(), …)
|
||||
except OSError:
|
||||
subprocess.Popen(
|
||||
argv,
|
||||
creationflags=windows_detach_flags_without_breakaway(),
|
||||
…,
|
||||
)
|
||||
|
||||
See ``gateway_windows.py::_spawn_detached`` for the canonical
|
||||
implementation of this pattern. Returns 0 on non-Windows.
|
||||
Retry with this when the breakaway variant raises OSError (job disallows breakaway), instead
|
||||
of hand-coding the bit mask at every site. Returns 0 on non-Windows.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return 0
|
||||
@@ -305,19 +210,11 @@ def windows_detach_flags_without_breakaway() -> int:
|
||||
|
||||
|
||||
def windows_hide_flags() -> int:
|
||||
"""Return Win32 creationflags that merely hide the child's console
|
||||
window without detaching the child. 0 on non-Windows.
|
||||
"""Return Win32 creationflags hiding the child's console without detaching it; 0 elsewhere.
|
||||
|
||||
Use for short-lived console apps spawned as part of a larger
|
||||
operation (``taskkill``, ``where``, version probes) where we want no
|
||||
flash but also want to collect stdout/exit code synchronously.
|
||||
|
||||
The difference from :func:`windows_detach_flags`: no
|
||||
``CREATE_NEW_PROCESS_GROUP`` / ``CREATE_BREAKAWAY_FROM_JOB`` — the
|
||||
child stays in the parent's process group and job so Ctrl+C and job
|
||||
teardown propagate normally, as a short-lived helper wants. Stdio
|
||||
handles are inherited either way, so ``capture_output=True`` works
|
||||
with both bundles.
|
||||
For short-lived helpers (``taskkill``, ``where``, version probes) run synchronously: no
|
||||
flash, but the child stays in the parent's process group and job so Ctrl+C and job teardown
|
||||
still propagate. Stdio is inherited, so ``capture_output=True`` works.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return 0
|
||||
@@ -325,25 +222,12 @@ def windows_hide_flags() -> int:
|
||||
|
||||
|
||||
def suppress_platform_ver_console() -> None:
|
||||
"""Stub out ``platform._syscmd_ver`` on Windows so it can never flash a
|
||||
console window. No-op on non-Windows.
|
||||
"""Stub ``platform._syscmd_ver`` on Windows so it never flashes a console. No-op elsewhere.
|
||||
|
||||
CPython's ``platform.win32_ver()`` — reached by ``platform.uname()``,
|
||||
``platform.version()``, and ``platform.platform()`` — unconditionally
|
||||
shells out ``cmd /c ver`` via ``subprocess.check_output(..., shell=True)``
|
||||
with no ``CREATE_NO_WINDOW``. From a windowless parent (the pythonw
|
||||
gateway and every kanban worker it spawns) that allocates a fresh
|
||||
*visible* console: one flashing ``cmd`` window per process, triggered by
|
||||
any dependency that merely touches ``platform.uname()`` at import time.
|
||||
|
||||
With ``_syscmd_ver`` stubbed to return its inputs, ``win32_ver()`` hits
|
||||
the documented ``ValueError`` fallback and reads the version from
|
||||
``sys.getwindowsversion().platform_version`` — same information, queried
|
||||
in-process, no subprocess, no window. Verified equivalent on
|
||||
CPython 3.11 (``platform()`` → ``Windows-10-10.0.xxxxx-SP0`` either way).
|
||||
|
||||
Call early, before heavyweight imports — the flash typically happens
|
||||
during a dependency's import, not from Hermes' own code.
|
||||
``platform.win32_ver()`` shells out ``cmd /c ver`` without CREATE_NO_WINDOW, so a windowless
|
||||
parent (pythonw gateway, kanban workers) flashes a visible cmd window whenever a dependency
|
||||
touches ``platform.uname()`` at import. With the stub, ``win32_ver()`` takes its documented
|
||||
fallback to ``sys.getwindowsversion()`` -- same data, in-process. Call before heavy imports.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return
|
||||
@@ -362,32 +246,10 @@ def suppress_platform_ver_console() -> None:
|
||||
|
||||
|
||||
def windows_detach_popen_kwargs() -> dict:
|
||||
"""Return a dict of Popen kwargs that detach a child on Windows and
|
||||
fall back to the POSIX equivalent (``start_new_session=True``) on
|
||||
Linux/macOS.
|
||||
"""Return Popen kwargs detaching a child on Windows, or ``start_new_session=True`` on POSIX.
|
||||
|
||||
Usage pattern:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
subprocess.Popen(
|
||||
argv,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
close_fds=True,
|
||||
**windows_detach_popen_kwargs(),
|
||||
)
|
||||
|
||||
This replaces the unsafe-on-Windows pattern:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
subprocess.Popen(..., start_new_session=True)
|
||||
|
||||
which silently fails to detach on Windows (the flag is accepted but
|
||||
has no effect — the child stays attached to the parent's console
|
||||
and dies when the console closes).
|
||||
Replaces bare ``start_new_session=True``, which is accepted but has no effect on Windows: the
|
||||
child stays attached to the parent console and dies when it closes.
|
||||
"""
|
||||
if IS_WINDOWS:
|
||||
return {"creationflags": windows_detach_flags()}
|
||||
@@ -404,38 +266,20 @@ def noninteractive_git_env(
|
||||
) -> dict[str, str]:
|
||||
"""Environment for *internal* git invocations that must never prompt.
|
||||
|
||||
Hermes shells out to git from many non-interactive contexts — MCP catalog
|
||||
installs, plugin install/update, profile distribution staging, worktree
|
||||
base fetches, desktop review-pane fetch/push. When the remote is private,
|
||||
misconfigured, or requires auth, git's default behavior is to prompt on
|
||||
the inherited terminal (or via an askpass helper), which silently hangs
|
||||
the operation until its timeout — or forever at call sites without one.
|
||||
Ported from openai/codex#34540 / #34612 ("detach non-interactive
|
||||
subprocesses from stdin"): a background tool invocation must fail fast
|
||||
with a readable error, not wait for input nobody can type.
|
||||
* ``GIT_TERMINAL_PROMPT=0`` — git fails with "terminal prompts disabled" instead of prompting
|
||||
for credentials. * ``GCM_INTERACTIVE=Never`` — Git Credential Manager (the default credential
|
||||
helper on Windows installs) never pops its own dialog.
|
||||
|
||||
Returns a copy of ``base`` (default ``os.environ``) with:
|
||||
Returns a copy of ``base`` (default ``os.environ``) with ``GIT_TERMINAL_PROMPT=0`` (fail instead
|
||||
of prompting), ``GCM_INTERACTIVE=Never`` (no Git Credential Manager dialog), and isolated git
|
||||
config: inherited ``GIT_CONFIG_*`` overrides, global/system config, pagers, editors, fsmonitor,
|
||||
external diff, and hooks are all disabled for the child so a user's repo/global config cannot
|
||||
hang or mutate Hermes's internal plumbing calls.
|
||||
|
||||
* ``GIT_TERMINAL_PROMPT=0`` — git fails with "terminal prompts disabled"
|
||||
instead of prompting for credentials.
|
||||
* ``GCM_INTERACTIVE=Never`` — Git Credential Manager (the default
|
||||
credential helper on Windows installs) never pops its own dialog.
|
||||
* isolated git config — inherited ``GIT_CONFIG_*`` overrides, global/system
|
||||
config, pagers, editors, fsmonitor, external diff, and hooks are disabled
|
||||
for the child process. A user's repo/global config should not be able to
|
||||
hang or mutate Hermes's internal plumbing calls.
|
||||
|
||||
``GIT_ASKPASS`` / ``SSH_ASKPASS`` are deliberately left alone: when the
|
||||
user has a *working* askpass helper or ssh-agent configured, auth should
|
||||
still succeed non-interactively. The env only disables paths that block
|
||||
on a human.
|
||||
|
||||
Pair with ``stdin=subprocess.DEVNULL`` so git (and any credential helper
|
||||
it spawns) also can't read the parent's inherited stdin.
|
||||
|
||||
This is for internal plumbing calls only — the agent-facing terminal tool
|
||||
has its own policy layer and user-visible PTY, where prompting can be
|
||||
legitimate.
|
||||
``GIT_ASKPASS`` / ``SSH_ASKPASS`` are deliberately left alone: when the user has a *working*
|
||||
askpass helper or ssh-agent configured, auth should still succeed non-interactively. The env
|
||||
only disables paths that block on a human. Pair with ``stdin=subprocess.DEVNULL``. Internal
|
||||
plumbing only — the agent-facing terminal tool has its own policy layer and visible PTY.
|
||||
"""
|
||||
env = dict(base if base is not None else os.environ)
|
||||
env["GIT_TERMINAL_PROMPT"] = "0"
|
||||
@@ -485,7 +329,6 @@ def noninteractive_git_env(
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
def _process_start_time(pid: int) -> int | None:
|
||||
"""Return the repository's stable process-start fingerprint, if available."""
|
||||
try:
|
||||
@@ -499,13 +342,9 @@ def _process_start_time(pid: int) -> int | None:
|
||||
def _text_names_hermes(text: str) -> bool:
|
||||
"""True when *text* names Hermes at a path-segment / token boundary.
|
||||
|
||||
A bare ``"hermes" in text`` substring test would also match unrelated
|
||||
processes whose paths merely contain the letters (``...\\shermesa\\...``),
|
||||
which is exactly the false-positive class this guard exists to prevent.
|
||||
Instead, split on path separators and whitespace and require a segment
|
||||
that *starts with* ``hermes`` (``hermes``, ``hermes.exe``, ``hermes_cli``,
|
||||
``hermes-agent``, ``hermes-runtime``) or the hidden-dir form
|
||||
``.hermes``/``.hermes-runtime``.
|
||||
A bare ``"hermes" in text`` substring test would also match unrelated processes whose paths
|
||||
merely contain the letters (``...\shermesa\...``), which is exactly the false-positive class
|
||||
this guard exists to prevent.
|
||||
"""
|
||||
for token in re.split(r"[\\/\s=,;\"']+", text.lower()):
|
||||
if token.startswith("hermes") or token.startswith(".hermes"):
|
||||
@@ -533,13 +372,9 @@ def pid_is_hermes(
|
||||
) -> bool:
|
||||
"""Return whether it is safe to use ``taskkill`` for *pid*.
|
||||
|
||||
The PID must be valid, currently exist, and identify a Hermes process. When
|
||||
the caller captured a start-time fingerprint before the destructive action,
|
||||
the live process must still have the same ``(pid, start_time)`` identity.
|
||||
Any ambiguity fails closed. Non-Windows callers have no ``taskkill`` path,
|
||||
so a valid PID with no (or a matching) explicit expectation is accepted
|
||||
there — but a caller-provided fingerprint that no longer matches is a
|
||||
recycled PID on every platform and is always refused.
|
||||
The PID must be valid, currently exist, and identify a Hermes process. When the caller captured
|
||||
a start-time fingerprint before the destructive action, the live process must still have the
|
||||
same ``(pid, start_time)`` identity. Any ambiguity fails closed.
|
||||
"""
|
||||
if not isinstance(pid, int) or isinstance(pid, bool) or pid <= 0:
|
||||
return False
|
||||
@@ -571,36 +406,11 @@ def pid_is_hermes(
|
||||
def kill_process_tree(proc: "subprocess.Popen") -> None:
|
||||
"""Best-effort terminate *proc* and its descendants on both platforms.
|
||||
|
||||
``proc.kill()`` alone only terminates the direct child. On Windows a
|
||||
suspended descendant (e.g. ``git.exe``) can survive holding duplicates of the
|
||||
captured pipe handles, which keeps the pipes from reaching EOF and leaks two
|
||||
reader threads + the process per fired timeout — ``taskkill /T /F`` takes the
|
||||
whole tree down so the bounded drain that follows can actually reach EOF.
|
||||
On POSIX the same class exists: killing the launcher leaves descendants
|
||||
(credential helpers, ``git-remote-https``, hook children) running and
|
||||
holding the pipe write ends. Callers spawn the child in its own process
|
||||
group (``process_group=0``, Python ≥3.11), so when — and only
|
||||
when — the child leads its own group (``pgid == pid``), the entire group is
|
||||
signalled with ``os.killpg``. The ownership check means a fallback spawn
|
||||
that shares our group can never cause us to kill unrelated processes.
|
||||
Ported from openai/codex#36793 ("Terminate timed-out Git process trees");
|
||||
generalized for the shell-hook runner via openai/codex#37527
|
||||
("Terminate timed-out hook process trees").
|
||||
``proc.kill()`` alone only terminates the direct child. On Windows a suspended descendant (e.g.
|
||||
|
||||
All failures are swallowed — this is cleanup on an already-failing path, and
|
||||
the caller's contract is to fail open. ``kill()`` can raise (access denied,
|
||||
already reaped); an unhandled raise here would escape the caller's ``except``
|
||||
handler and break that contract. The ``taskkill`` spawn itself cannot
|
||||
re-enter the deadlock class it fixes: it captures no pipes (DEVNULL), so its
|
||||
own timeout cleanup has no reader threads to join.
|
||||
|
||||
Delegates the tree-kill to :func:`agent.deadline.kill_process_tree`
|
||||
(#85125 4d) — same taskkill /T /F on Windows and killpg-when-leader on
|
||||
POSIX, plus a psutil descendant sweep that also reaches descendants that
|
||||
``setsid``'d into their own sessions. On any import/delegation failure it
|
||||
falls back to the original local implementation
|
||||
(:func:`_legacy_kill_process_tree`), so the fail-open contract holds even
|
||||
in stripped environments.
|
||||
All failures are swallowed — this is cleanup on an already-failing path, and the caller's
|
||||
contract is to fail open. ``kill()`` can raise (access denied, already reaped); an unhandled
|
||||
raise here would escape the caller's ``except`` handler and break that contract.
|
||||
"""
|
||||
try:
|
||||
from agent.deadline import kill_process_tree as _deadline_kill_tree
|
||||
@@ -620,9 +430,8 @@ def kill_process_tree(proc: "subprocess.Popen") -> None:
|
||||
def _legacy_kill_process_tree(proc: "subprocess.Popen") -> None:
|
||||
"""Pre-#85125 local tree-kill — fallback when agent.deadline is unavailable.
|
||||
|
||||
Kept verbatim so ``kill_process_tree`` can honor its swallow-everything
|
||||
contract even when the delegation path itself fails (partial install,
|
||||
import cycle during teardown).
|
||||
Kept verbatim so ``kill_process_tree`` can honor its swallow-everything contract even when the
|
||||
delegation path itself fails (partial install, import cycle during teardown).
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
# Group-kill first: verify the child actually leads its own process
|
||||
@@ -667,31 +476,9 @@ def bounded_probe_run(
|
||||
errors: str = "replace",
|
||||
env: "Mapping[str, str] | None" = None,
|
||||
) -> "subprocess.CompletedProcess[str] | None":
|
||||
"""Deadlock-safe ``subprocess.run(argv, capture_output=True, timeout=...)``
|
||||
for fail-open probe call sites. Returns a ``CompletedProcess`` when the
|
||||
child finished within *timeout* (any exit code), or ``None`` on spawn
|
||||
failure or timeout.
|
||||
|
||||
Why not ``subprocess.run``: on Windows, ``run()``'s post-timeout cleanup
|
||||
calls an *unbounded* ``communicate()`` after killing the direct child.
|
||||
Killing it can leave a descendant (``git.exe`` under a launcher shim,
|
||||
``conhost.exe`` under wmic/powershell) holding duplicates of the captured
|
||||
stdout/stderr handles, so the pipes never reach EOF and the reader-thread
|
||||
join blocks forever. The wmic / ``Get-CimInstance Win32_Process`` gateway
|
||||
scan hit exactly this during ``hermes update`` on slow-WMI machines
|
||||
(#87134); the git probes hit it first (#68609 / #66037).
|
||||
|
||||
The bounded flow: an explicit ``communicate(timeout)``, then on any
|
||||
failure a tree-kill (see :func:`kill_process_tree`) plus a bounded 1s
|
||||
post-kill drain; if the pipes are still held after that, they're abandoned
|
||||
(the orphaned reader threads are daemonic and cost nothing).
|
||||
|
||||
The spawn contract mirrors the ``run`` calls it replaces: PIPE/PIPE/DEVNULL,
|
||||
``text`` with UTF-8 decoding (*errors* configurable — the process scans use
|
||||
``"ignore"``), and the hidden-window ``creationflags`` on Windows only. On
|
||||
POSIX the child is placed in its own process group (``process_group=0``,
|
||||
Python ≥3.11) so timeout cleanup can take down descendants with the
|
||||
launcher instead of orphaning them.
|
||||
"""Deadlock-safe ``subprocess.run(argv, capture_output=True, timeout=...)`` for fail-open probe
|
||||
call sites. Returns a ``CompletedProcess`` when the child finished within *timeout* (any exit
|
||||
code), or ``None`` on spawn failure or timeout.
|
||||
"""
|
||||
_popen_kwargs: dict = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {"process_group": 0}
|
||||
try:
|
||||
@@ -724,50 +511,21 @@ def bounded_probe_run(
|
||||
|
||||
|
||||
def bounded_git_probe(argv: Sequence[str], *, timeout: float) -> str:
|
||||
"""Run a short, throwaway ``git`` probe and return stripped stdout, or ``""``
|
||||
on ANY failure (nonzero exit, timeout, spawn error, decode error).
|
||||
"""Run a short ``git`` probe and return stripped stdout, or ``""`` on ANY failure.
|
||||
|
||||
This is the shared, deadlock-safe replacement for
|
||||
``subprocess.run(["git", ...], timeout=...)`` at fail-open probe call sites
|
||||
(``tui_gateway.git_probe.run_git``, ``agent.coding_context._git``).
|
||||
Deadlock-safe replacement for ``subprocess.run(["git", ...], timeout=...)`` at fail-open
|
||||
probe sites. On Windows ``run()``'s post-timeout cleanup calls an unbounded ``communicate()``;
|
||||
a suspended descendant git.exe holding the pipe handles then blocks forever. Here: bounded
|
||||
``communicate``, then tree-kill plus a 1s drain, then abandon the pipes. Spawn contract
|
||||
matches ``run`` byte-for-byte; on POSIX the probe gets its own process group so cleanup
|
||||
also takes down credential helpers and remote helpers.
|
||||
|
||||
**Security (GHSA-7x36-8jrh-v4pw):** these probes run automatically against
|
||||
whatever directory the session sits in — the coding-workspace snapshot and
|
||||
the gateway project-tree build fire ``git status`` / ``git branch`` before
|
||||
any tool call, approval, or trust prompt. An index refresh executes the
|
||||
repository-configured ``core.fsmonitor`` program, and other config keys
|
||||
(hooks, pager, editor, credential helper) are execution sinks too. A repo
|
||||
delivered as files with its ``.git`` directory intact (a shared zip, sync
|
||||
folder, or USB stick — ``git clone`` never transfers ``.git/config``) would
|
||||
otherwise get host code execution as the user. Every probe now runs under
|
||||
:func:`noninteractive_git_env`, which pins those keys to inert values via
|
||||
``GIT_CONFIG_*`` and ignores global/system config. Diff-rendering callers
|
||||
additionally pass :data:`NO_DRIVER_DIFF_FLAGS` (attribute-scoped drivers
|
||||
can't be disabled through env overrides).
|
||||
|
||||
Why not ``subprocess.run``: on Windows, ``run()``'s post-timeout cleanup
|
||||
calls an *unbounded* ``communicate()`` after killing git. Killing the
|
||||
PATH-resolved launcher can leave a suspended descendant ``git.exe`` holding
|
||||
duplicates of the captured stdout/stderr handles, so the pipes never reach
|
||||
EOF and the reader-thread join blocks forever. On the Desktop agent-build
|
||||
path (``_start_agent_build → _session_info → branch() → run_git``) that turned
|
||||
an optional branch label into ``agent initialization timed out``
|
||||
(issues #68609 / #66037).
|
||||
|
||||
The bounded flow: an explicit ``communicate(timeout)``, then on any failure a
|
||||
tree-kill (see :func:`_kill_git_process_tree`) plus a bounded 1s post-kill
|
||||
drain; if the pipes are still held after that, they're abandoned (the orphaned
|
||||
reader threads are daemonic and cost nothing).
|
||||
|
||||
The normal-path spawn contract mirrors the previous ``run`` call byte-for-byte:
|
||||
PIPE/PIPE/DEVNULL, ``text`` with UTF-8 ``errors="replace"`` decoding, and the
|
||||
hidden-window ``creationflags`` on Windows only. On POSIX the probe is
|
||||
additionally placed in its own process group (``process_group=0``,
|
||||
Python ≥3.11) so timeout cleanup can take down descendants — credential
|
||||
helpers, ``git-remote-https``, hook children — with the launcher instead of
|
||||
orphaning them (see :func:`_kill_git_process_tree`; port of
|
||||
openai/codex#36793). ``process_group`` only changes which group the child
|
||||
belongs to; it does not detach the terminal or alter the fast path.
|
||||
Security (GHSA-7x36-8jrh-v4pw): these probes run automatically against whatever directory
|
||||
the session sits in, before any tool call or trust prompt, and an index refresh executes the
|
||||
repo-configured ``core.fsmonitor`` program (hooks/pager/editor/credential helper are sinks
|
||||
too). A repo delivered with its ``.git`` intact would get host code execution. Every probe
|
||||
therefore runs under :func:`noninteractive_git_env`; diff-rendering callers additionally pass
|
||||
:data:`NO_DRIVER_DIFF_FLAGS` (attribute-scoped drivers can't be disabled via env).
|
||||
"""
|
||||
result = bounded_probe_run(argv, timeout=timeout, env=noninteractive_git_env())
|
||||
if result is None or result.returncode != 0:
|
||||
|
||||
1198
hermes_constants.py
1198
hermes_constants.py
File diff suppressed because it is too large
Load Diff
@@ -1,30 +1,12 @@
|
||||
"""Centralized logging setup for Hermes Agent.
|
||||
|
||||
Provides a single ``setup_logging()`` entry point that both the CLI and
|
||||
gateway call early in their startup path. All log files live under
|
||||
``~/.hermes/logs/`` (profile-aware via ``get_hermes_home()``).
|
||||
Log files produced: agent.log — INFO+, all agent/tool/session activity (the main log) errors.log —
|
||||
WARNING+, errors and warnings only (quick triage) gateway.log — INFO+, gateway-only events (created
|
||||
when mode="gateway") gui.log — INFO+, dashboard/websocket/TUI-gateway events (created when
|
||||
mode="gui")
|
||||
|
||||
Log files produced:
|
||||
agent.log — INFO+, all agent/tool/session activity (the main log)
|
||||
errors.log — WARNING+, errors and warnings only (quick triage)
|
||||
gateway.log — INFO+, gateway-only events (created when mode="gateway")
|
||||
gui.log — INFO+, dashboard/websocket/TUI-gateway events
|
||||
(created when mode="gui")
|
||||
|
||||
All files use ``RotatingFileHandler`` with ``RedactingFormatter`` so
|
||||
secrets are never written to disk.
|
||||
|
||||
Component separation:
|
||||
gateway.log only receives records from ``gateway.*`` loggers —
|
||||
platform adapters, session management, slash commands, delivery.
|
||||
gui.log receives dashboard-side records from ``hermes_cli.web_server``,
|
||||
``hermes_cli.pty_bridge``, ``tui_gateway.*``, and ``uvicorn.*``.
|
||||
agent.log remains the catch-all (everything goes there).
|
||||
|
||||
Session context:
|
||||
Call ``set_session_context(session_id)`` at the start of a conversation
|
||||
and ``clear_session_context()`` when done. All log lines emitted on
|
||||
that thread will include ``[session_id]`` for filtering/correlation.
|
||||
All files use ``RotatingFileHandler`` with ``RedactingFormatter`` so secrets are never written to
|
||||
disk.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
@@ -69,7 +51,7 @@ else:
|
||||
from logging.handlers import RotatingFileHandler # noqa: E402
|
||||
|
||||
|
||||
from hermes_constants import get_config_path, get_hermes_home
|
||||
from hermes_constants import get_config_path, get_hermes_home, mkdir_under_hermes_home
|
||||
|
||||
# Sentinel to track whether setup_logging() has already run. The function
|
||||
# is idempotent — calling it twice is safe but the second call is a no-op
|
||||
@@ -89,12 +71,8 @@ _LOG_FORMAT_VERBOSE = "%(asctime)s - %(name)s - %(levelname)s%(session_tag)s - %
|
||||
def _safe_stderr(): # type: ignore[return]
|
||||
"""Return a stderr stream that tolerates Unicode on all platforms.
|
||||
|
||||
On Windows the console encoding is often a legacy MBCS codec
|
||||
(cp949, cp1252, …) that raises ``UnicodeEncodeError`` for characters
|
||||
like the em-dash (U+2014). We wrap ``sys.stderr`` in a
|
||||
``TextIOWrapper`` with ``errors='replace'`` so log lines are never
|
||||
lost — un-encodable characters are replaced with ``?`` instead of
|
||||
crashing the process.
|
||||
We wrap ``sys.stderr`` in a ``TextIOWrapper`` with ``errors='replace'`` so log lines are never
|
||||
lost — un-encodable characters are replaced with ``?`` instead of crashing the process.
|
||||
"""
|
||||
stream = sys.stderr
|
||||
encoding = getattr(stream, "encoding", None) or "utf-8"
|
||||
@@ -102,72 +80,47 @@ def _safe_stderr(): # type: ignore[return]
|
||||
if encoding.lower().replace("-", "") in ("utf8", "utf8surrogateescape"):
|
||||
return stream
|
||||
try:
|
||||
buf = getattr(stream, "buffer", None)
|
||||
if buf is not None:
|
||||
wrapped = io.TextIOWrapper(
|
||||
buf,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
# Prevent the wrapper from closing the underlying buffer
|
||||
# when it is garbage-collected.
|
||||
wrapped.close = lambda: None # type: ignore[assignment]
|
||||
return wrapped
|
||||
wrapped = io.TextIOWrapper(stream.buffer, encoding="utf-8", errors="replace", line_buffering=True)
|
||||
# Prevent the wrapper from closing the underlying buffer when it is garbage-collected.
|
||||
wrapped.close = lambda: None # type: ignore[assignment]
|
||||
return wrapped
|
||||
except Exception:
|
||||
pass
|
||||
# Best-effort: if wrapping fails, return the original stream.
|
||||
return stream
|
||||
|
||||
|
||||
_CONCURRENT_LOG_LOCK_TIMEOUT = "Cannot acquire lock after 20 attempts"
|
||||
return stream # best-effort: no buffer / wrapping failed -> original stream
|
||||
|
||||
|
||||
def _is_windows_concurrent_log_lock_timeout(exc: BaseException | None) -> bool:
|
||||
"""Return True for concurrent-log-handler's Windows lock timeout.
|
||||
|
||||
On Windows Desktop, slash-command workers and the gateway can all write to
|
||||
the same rotating log files. ``concurrent-log-handler`` serializes rollover
|
||||
with a cross-process lock, but when another process holds that lock too
|
||||
long it raises this RuntimeError. Logging failures should not escape into
|
||||
Desktop chat output.
|
||||
On Windows Desktop, slash-command workers and the gateway can all write to the same rotating log
|
||||
files. ``concurrent-log-handler`` serializes rollover with a cross-process lock, but when
|
||||
another process holds that lock too long it raises this RuntimeError. Logging failures should
|
||||
not escape into Desktop chat output.
|
||||
"""
|
||||
return (
|
||||
sys.platform == "win32"
|
||||
and isinstance(exc, RuntimeError)
|
||||
and _CONCURRENT_LOG_LOCK_TIMEOUT in str(exc)
|
||||
and "Cannot acquire lock after 20 attempts" in str(exc)
|
||||
)
|
||||
|
||||
|
||||
# Third-party loggers that are noisy at DEBUG/INFO level.
|
||||
_NOISY_LOGGERS = (
|
||||
"openai",
|
||||
"openai._base_client",
|
||||
"httpx",
|
||||
"httpcore",
|
||||
"asyncio",
|
||||
"hpack",
|
||||
"hpack.hpack",
|
||||
"grpc",
|
||||
"modal",
|
||||
"urllib3",
|
||||
"urllib3.connectionpool",
|
||||
"websockets",
|
||||
"charset_normalizer",
|
||||
"openai", "openai._base_client", "httpx", "httpcore", "asyncio", "hpack", "hpack.hpack",
|
||||
"grpc", "modal", "urllib3", "urllib3.connectionpool", "websockets", "charset_normalizer",
|
||||
"markdown_it",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
def _quiet_noisy_loggers() -> None:
|
||||
"""Pin noisy third-party loggers at WARNING."""
|
||||
for name in _NOISY_LOGGERS:
|
||||
logging.getLogger(name).setLevel(logging.WARNING)
|
||||
|
||||
|
||||
# Public session context API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def set_session_context(session_id: str) -> None:
|
||||
"""Set the session ID for the current thread.
|
||||
|
||||
All subsequent log records on this thread will include ``[session_id]``
|
||||
in the formatted output. Call at the start of ``run_conversation()``.
|
||||
"""
|
||||
"""Set the session ID for the current thread."""
|
||||
_session_context.session_id = session_id
|
||||
|
||||
|
||||
@@ -176,22 +129,14 @@ def clear_session_context() -> None:
|
||||
_session_context.session_id = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Record factory — injects session_tag into every LogRecord at creation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _install_session_record_factory() -> None:
|
||||
"""Replace the global LogRecord factory with one that adds ``session_tag``.
|
||||
|
||||
Unlike a ``logging.Filter`` on a handler or logger, the record factory
|
||||
runs for EVERY record in the process — including records that propagate
|
||||
from child loggers and records handled by third-party handlers. This
|
||||
guarantees ``%(session_tag)s`` is always available in format strings,
|
||||
eliminating the KeyError that would occur if a handler used our format
|
||||
without having a ``_SessionFilter`` attached.
|
||||
|
||||
Idempotent — checks for a marker attribute to avoid double-wrapping if
|
||||
the module is reloaded.
|
||||
Unlike a handler/logger ``Filter``, the record factory runs for EVERY record in the process,
|
||||
including propagated and third-party-handled ones, so ``%(session_tag)s`` is always available
|
||||
and never KeyErrors. Idempotent: a marker attribute prevents double-wrapping on reload.
|
||||
"""
|
||||
current_factory = logging.getLogRecordFactory()
|
||||
if getattr(current_factory, "_hermes_session_injector", False):
|
||||
@@ -220,16 +165,10 @@ def _install_session_record_factory() -> None:
|
||||
_install_session_record_factory()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _ComponentFilter(logging.Filter):
|
||||
"""Only pass records whose logger name starts with one of *prefixes*.
|
||||
|
||||
Used to route gateway-specific records to ``gateway.log`` while
|
||||
keeping ``agent.log`` as the catch-all.
|
||||
"""
|
||||
"""Only pass records whose logger name starts with one of *prefixes*."""
|
||||
|
||||
def __init__(self, prefixes: Sequence[str]) -> None:
|
||||
super().__init__()
|
||||
@@ -251,18 +190,11 @@ COMPONENT_PREFIXES = {
|
||||
"tools": ("tools",),
|
||||
"cli": ("hermes_cli", "cli"),
|
||||
"cron": ("cron",),
|
||||
"gui": (
|
||||
"hermes_cli.web_server",
|
||||
"hermes_cli.pty_bridge",
|
||||
"tui_gateway",
|
||||
"uvicorn",
|
||||
),
|
||||
"gui": ("hermes_cli.web_server", "hermes_cli.pty_bridge", "tui_gateway", "uvicorn"),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def setup_logging(
|
||||
*,
|
||||
@@ -275,41 +207,13 @@ def setup_logging(
|
||||
) -> Path:
|
||||
"""Configure the Hermes logging subsystem.
|
||||
|
||||
Safe to call multiple times — the second call is a no-op unless
|
||||
*force* is ``True``.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
hermes_home
|
||||
Override for the Hermes home directory. Falls back to
|
||||
``get_hermes_home()`` (profile-aware).
|
||||
log_level
|
||||
Minimum level for the ``agent.log`` file handler. Accepts any
|
||||
standard Python level name (``"DEBUG"``, ``"INFO"``, ``"WARNING"``).
|
||||
Defaults to ``"INFO"`` or the value from config.yaml ``logging.level``.
|
||||
max_size_mb
|
||||
Maximum size of each log file in megabytes before rotation.
|
||||
Defaults to 5 or the value from config.yaml ``logging.max_size_mb``.
|
||||
backup_count
|
||||
Number of rotated backup files to keep.
|
||||
Defaults to 3 or the value from config.yaml ``logging.backup_count``.
|
||||
mode
|
||||
Caller context: ``"cli"``, ``"gateway"``, ``"gui"``, ``"cron"``.
|
||||
When ``"gateway"``, an additional ``gateway.log`` file is created
|
||||
that receives only gateway-component records.
|
||||
When ``"gui"``, an additional ``gui.log`` file is created that
|
||||
receives dashboard and TUI-gateway component records.
|
||||
force
|
||||
Re-run setup even if it has already been called.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Path
|
||||
The ``logs/`` directory where files are written.
|
||||
Safe to call multiple times; the second call is a no-op unless *force* is ``True``. Level and
|
||||
rotation defaults come from config.yaml ``logging.*``. ``mode="gateway"`` adds ``gateway.log``
|
||||
(gateway components only) and ``mode="gui"`` adds ``gui.log`` (dashboard / TUI-gateway).
|
||||
Returns the ``logs/`` directory.
|
||||
"""
|
||||
global _logging_initialized
|
||||
home = hermes_home or get_hermes_home()
|
||||
from hermes_constants import mkdir_under_hermes_home
|
||||
log_dir = mkdir_under_hermes_home(home / "logs")
|
||||
|
||||
# Read config defaults (best-effort — config may not be loaded yet).
|
||||
@@ -325,48 +229,21 @@ def setup_logging(
|
||||
|
||||
root = logging.getLogger()
|
||||
|
||||
# --- agent.log (INFO+) — the main activity log -------------------------
|
||||
_add_rotating_handler(
|
||||
root,
|
||||
log_dir / "agent.log",
|
||||
level=level,
|
||||
max_bytes=max_bytes,
|
||||
backup_count=backups,
|
||||
formatter=RedactingFormatter(_LOG_FORMAT),
|
||||
# (filename, level, max_bytes, backup_count, component) — component gates on ``mode`` and
|
||||
# restricts the file to that component's logger prefixes.
|
||||
handler_specs = (
|
||||
("agent.log", level, max_bytes, backups, None),
|
||||
("errors.log", logging.WARNING, 2 * 1024 * 1024, 2, None),
|
||||
("gateway.log", logging.INFO, 5 * 1024 * 1024, 3, "gateway"),
|
||||
("gui.log", logging.INFO, 10 * 1024 * 1024, 5, "gui"),
|
||||
)
|
||||
|
||||
# --- errors.log (WARNING+) — quick triage log --------------------------
|
||||
_add_rotating_handler(
|
||||
root,
|
||||
log_dir / "errors.log",
|
||||
level=logging.WARNING,
|
||||
max_bytes=2 * 1024 * 1024,
|
||||
backup_count=2,
|
||||
formatter=RedactingFormatter(_LOG_FORMAT),
|
||||
)
|
||||
|
||||
# --- gateway.log (INFO+, gateway component only) ------------------------
|
||||
if mode == "gateway":
|
||||
for filename, lvl, size, count, component in handler_specs:
|
||||
if component is not None and mode != component:
|
||||
continue
|
||||
_add_rotating_handler(
|
||||
root,
|
||||
log_dir / "gateway.log",
|
||||
level=logging.INFO,
|
||||
max_bytes=5 * 1024 * 1024,
|
||||
backup_count=3,
|
||||
log_dir / filename, level=lvl, max_bytes=size, backup_count=count,
|
||||
formatter=RedactingFormatter(_LOG_FORMAT),
|
||||
log_filter=_ComponentFilter(COMPONENT_PREFIXES["gateway"]),
|
||||
)
|
||||
|
||||
# --- gui.log (INFO+, dashboard/tui-gateway components) -----------------
|
||||
if mode == "gui":
|
||||
_add_rotating_handler(
|
||||
root,
|
||||
log_dir / "gui.log",
|
||||
level=logging.INFO,
|
||||
max_bytes=10 * 1024 * 1024,
|
||||
backup_count=5,
|
||||
formatter=RedactingFormatter(_LOG_FORMAT),
|
||||
log_filter=_ComponentFilter(COMPONENT_PREFIXES["gui"]),
|
||||
log_filter=_ComponentFilter(COMPONENT_PREFIXES[component]) if component else None,
|
||||
)
|
||||
|
||||
if _logging_initialized and not force:
|
||||
@@ -376,28 +253,21 @@ def setup_logging(
|
||||
if root.level == logging.NOTSET or root.level > level:
|
||||
root.setLevel(level)
|
||||
|
||||
# Suppress noisy third-party loggers.
|
||||
for name in _NOISY_LOGGERS:
|
||||
logging.getLogger(name).setLevel(logging.WARNING)
|
||||
_quiet_noisy_loggers()
|
||||
|
||||
_logging_initialized = True
|
||||
return log_dir
|
||||
|
||||
|
||||
def setup_verbose_logging() -> None:
|
||||
"""Enable DEBUG-level console logging for ``--verbose`` / ``-v`` mode.
|
||||
|
||||
Called by ``AIAgent.__init__()`` when ``verbose_logging=True``.
|
||||
"""
|
||||
"""Enable DEBUG-level console logging for ``--verbose`` / ``-v`` mode."""
|
||||
from agent.redact import RedactingFormatter
|
||||
|
||||
root = logging.getLogger()
|
||||
|
||||
# Avoid adding duplicate stream handlers.
|
||||
for h in root.handlers:
|
||||
if isinstance(h, logging.StreamHandler) and not isinstance(h, RotatingFileHandler):
|
||||
if getattr(h, "_hermes_verbose", False):
|
||||
return
|
||||
if any(getattr(h, "_hermes_verbose", False) for h in root.handlers):
|
||||
return
|
||||
|
||||
handler = logging.StreamHandler(_safe_stderr())
|
||||
handler.setLevel(logging.DEBUG)
|
||||
@@ -409,40 +279,29 @@ def setup_verbose_logging() -> None:
|
||||
if root.level > logging.DEBUG:
|
||||
root.setLevel(logging.DEBUG)
|
||||
|
||||
# Keep third-party libraries at WARNING to reduce noise.
|
||||
for name in _NOISY_LOGGERS:
|
||||
logging.getLogger(name).setLevel(logging.WARNING)
|
||||
_quiet_noisy_loggers()
|
||||
# rex-deploy at INFO for sandbox status.
|
||||
logging.getLogger("rex-deploy").setLevel(logging.INFO)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _quietly(fn) -> None:
|
||||
"""Call *fn* (a ``close``/``stop`` bound method) swallowing errors — teardown must never raise."""
|
||||
try:
|
||||
fn()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class _ManagedRotatingFileHandler(RotatingFileHandler):
|
||||
"""RotatingFileHandler that ensures group-writable perms in managed mode
|
||||
AND survives external rotation.
|
||||
|
||||
Two responsibilities:
|
||||
|
||||
1. In managed mode (NixOS), the stateDir uses setgid (2770) so new files
|
||||
inherit the hermes group. However, both ``_open()`` (initial creation)
|
||||
and ``doRollover()`` create files via ``open()``, which uses the
|
||||
process umask — typically 0022, producing 0644. This subclass applies
|
||||
``chmod 0660`` after both operations so the gateway and interactive
|
||||
users can share log files.
|
||||
|
||||
2. ``RotatingFileHandler`` keeps an open file descriptor. If anything
|
||||
rotates the file *externally* (``logrotate``, manual ``mv``,
|
||||
another process rotating under us, a transient unlink), our fd
|
||||
keeps pointing at the renamed/unlinked inode and every subsequent
|
||||
write goes to ``gateway.log.1`` instead of ``gateway.log`` — silent
|
||||
log loss for the file every operator expects to read. Before each
|
||||
emit we ``stat`` ``baseFilename`` and compare it against the open
|
||||
stream's inode; on mismatch we reopen. This is the same pattern
|
||||
as stdlib ``WatchedFileHandler.reopenIfNeeded()``, adapted for
|
||||
rotating handlers.
|
||||
In managed mode (NixOS) the setgid stateDir needs group-readable files, but ``_open()`` and
|
||||
``doRollover()`` honor the umask (0644), so ``chmod 0660`` is applied after both. Also, a
|
||||
rotating handler holds an fd: if the file is rotated externally (logrotate, ``mv``) writes
|
||||
silently go to the old inode, so before each emit the path's inode is compared to the open
|
||||
stream's and the file reopened on mismatch (the ``WatchedFileHandler`` pattern).
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -451,8 +310,6 @@ class _ManagedRotatingFileHandler(RotatingFileHandler):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Snapshot the inode of the currently open stream so emit() can
|
||||
# detect external rotation without an extra fstat per write.
|
||||
self._stat_dev: Optional[int] = None
|
||||
self._stat_ino: Optional[int] = None
|
||||
self._record_stream_stat()
|
||||
|
||||
def _chmod_if_managed(self):
|
||||
@@ -462,62 +319,51 @@ class _ManagedRotatingFileHandler(RotatingFileHandler):
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _record_stream_stat(self) -> None:
|
||||
def _record_stream_stat(self, st: Optional[os.stat_result] = None) -> None:
|
||||
"""Snapshot dev/ino of ``baseFilename`` so we can detect external rotation."""
|
||||
try:
|
||||
st = os.stat(self.baseFilename)
|
||||
st = st or os.stat(self.baseFilename)
|
||||
self._stat_dev, self._stat_ino = st.st_dev, st.st_ino
|
||||
except OSError:
|
||||
self._stat_dev, self._stat_ino = None, None
|
||||
|
||||
def _reopen_stream(self, stat_result=None) -> None:
|
||||
"""Close the current stream and open ``baseFilename`` afresh (best-effort).
|
||||
|
||||
On failure the stream is left ``None`` so the next emit bails rather than writing to a
|
||||
stale inode.
|
||||
"""
|
||||
if self.stream is not None:
|
||||
_quietly(self.stream.close)
|
||||
self.stream = None # type: ignore[assignment]
|
||||
try:
|
||||
self.stream = self._open()
|
||||
except Exception:
|
||||
return
|
||||
self._record_stream_stat(stat_result)
|
||||
|
||||
def _reopen_if_externally_rotated(self) -> None:
|
||||
"""Reopen the stream when ``baseFilename`` no longer matches our fd.
|
||||
|
||||
Triggered when ``baseFilename`` was renamed (logrotate), unlinked,
|
||||
or replaced by a different inode. Silent + best-effort: any error
|
||||
falls back to the existing (possibly stale) stream so logging keeps
|
||||
working instead of dying on a stat failure.
|
||||
Triggered when ``baseFilename`` was renamed (logrotate), unlinked, or replaced by a
|
||||
different inode. Silent + best-effort: any error falls back to the existing (possibly stale)
|
||||
stream so logging keeps working instead of dying on a stat failure.
|
||||
"""
|
||||
try:
|
||||
st = os.stat(self.baseFilename)
|
||||
except FileNotFoundError:
|
||||
# File was rotated/unlinked underneath us. Close + reopen so a
|
||||
# fresh inode is created at the expected path.
|
||||
try:
|
||||
if self.stream is not None:
|
||||
self.stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.stream = None # type: ignore[assignment]
|
||||
try:
|
||||
self.stream = self._open()
|
||||
self._record_stream_stat()
|
||||
except Exception:
|
||||
# Couldn't reopen — leave stream=None; next emit will
|
||||
# bail rather than write to a stale inode.
|
||||
pass
|
||||
# File was rotated/unlinked underneath us: reopen so a fresh inode
|
||||
# is created at the expected path.
|
||||
self._reopen_stream()
|
||||
return
|
||||
except OSError:
|
||||
return # transient — try again on the next emit
|
||||
|
||||
if self._stat_dev is None or self._stat_ino is None:
|
||||
self._stat_dev, self._stat_ino = st.st_dev, st.st_ino
|
||||
return
|
||||
|
||||
if (st.st_dev, st.st_ino) != (self._stat_dev, self._stat_ino):
|
||||
# baseFilename now points at a DIFFERENT inode than the one we
|
||||
# hold open. Close the old stream and open the new file.
|
||||
try:
|
||||
if self.stream is not None:
|
||||
self.stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.stream = None # type: ignore[assignment]
|
||||
try:
|
||||
self.stream = self._open()
|
||||
self._stat_dev, self._stat_ino = st.st_dev, st.st_ino
|
||||
except Exception:
|
||||
pass
|
||||
self._record_stream_stat(st)
|
||||
elif (st.st_dev, st.st_ino) != (self._stat_dev, self._stat_ino):
|
||||
# baseFilename now points at a DIFFERENT inode than the one we hold open.
|
||||
self._reopen_stream(st)
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
# Cheap-ish stat-per-record check; the kernel caches inode metadata
|
||||
@@ -528,19 +374,13 @@ class _ManagedRotatingFileHandler(RotatingFileHandler):
|
||||
|
||||
def handleError(self, record: logging.LogRecord) -> None:
|
||||
"""Suppress the known Windows ``concurrent-log-handler`` lock timeout
|
||||
instead of printing a traceback.
|
||||
|
||||
CLH's own ``emit()`` wraps its body in ``try/except Exception:
|
||||
self.handleError(record)``, so the ``"Cannot acquire lock after N
|
||||
attempts"`` RuntimeError raised in ``_do_lock()`` is caught inside CLH
|
||||
and routed here — it never propagates out of ``super().emit()``. This
|
||||
override is the single point where that timeout can be silenced before
|
||||
the stdlib handler prints it to stderr (which, under the Desktop
|
||||
slash-worker, is captured and surfaced into chat output)."""
|
||||
exc = sys.exc_info()[1]
|
||||
if _is_windows_concurrent_log_lock_timeout(exc):
|
||||
return
|
||||
super().handleError(record)
|
||||
CLH's ``emit()`` catches the ``"Cannot acquire lock after N attempts"`` RuntimeError and
|
||||
routes it here, so this override is the single point to silence it before stdlib prints to
|
||||
stderr (which the Desktop slash-worker captures and surfaces into chat output).
|
||||
"""
|
||||
if not _is_windows_concurrent_log_lock_timeout(sys.exc_info()[1]):
|
||||
super().handleError(record)
|
||||
|
||||
def _open(self):
|
||||
stream = super()._open()
|
||||
@@ -555,48 +395,42 @@ class _ManagedRotatingFileHandler(RotatingFileHandler):
|
||||
self._record_stream_stat()
|
||||
|
||||
|
||||
def _new_file_handler(
|
||||
path: Path, *, level: int, max_bytes: int, backup_count: int, formatter
|
||||
) -> "_ManagedRotatingFileHandler":
|
||||
"""Create the ``logs/`` directory and a configured ``_ManagedRotatingFileHandler``."""
|
||||
mkdir_under_hermes_home(path.parent)
|
||||
handler = _ManagedRotatingFileHandler(
|
||||
str(path), maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
|
||||
)
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(formatter)
|
||||
return handler
|
||||
|
||||
|
||||
class _ProfileRoutingFileHandler(logging.Handler):
|
||||
"""Route queued records to the log file for their Hermes home.
|
||||
|
||||
Dashboard logging is initialized once for the process that launched it,
|
||||
while the desktop cron ticker can execute jobs for several profile homes.
|
||||
A normal ``RotatingFileHandler`` therefore pins every cron record to the
|
||||
dashboard profile. This handler keeps one rotating file handler per live
|
||||
profile and selects it from the home captured by the record factory.
|
||||
|
||||
The handler itself is used only behind the existing QueueListener, so its
|
||||
small routing lock never blocks an agent or dashboard event loop. The
|
||||
underlying handlers retain the existing rotation, redaction, and managed
|
||||
permission behavior.
|
||||
The handler itself is used only behind the existing QueueListener, so its small routing lock
|
||||
never blocks an agent or dashboard event loop. The underlying handlers retain the existing
|
||||
rotation, redaction, and managed permission behavior.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
default_path: Path,
|
||||
profile_homes: Sequence[Path],
|
||||
level: int,
|
||||
max_bytes: int,
|
||||
backup_count: int,
|
||||
formatter: logging.Formatter | None,
|
||||
log_filters: Sequence[logging.Filter],
|
||||
) -> None:
|
||||
super().__init__(level=level)
|
||||
self.baseFilename = str(default_path.resolve())
|
||||
self._hermes_routed_log_path = Path(self.baseFilename)
|
||||
self._default_home = Path(self.baseFilename).parent.parent.resolve()
|
||||
self._profile_homes = {
|
||||
Path(home).expanduser().resolve()
|
||||
for home in profile_homes
|
||||
}
|
||||
self._filename = Path(self.baseFilename).name
|
||||
self._max_bytes = max_bytes
|
||||
self._backup_count = backup_count
|
||||
def __init__(self, existing: RotatingFileHandler, profile_homes: Sequence[Path]) -> None:
|
||||
"""Take over *existing*'s path, level, rotation, formatter and filters."""
|
||||
super().__init__(level=existing.level)
|
||||
resolved = Path(existing.baseFilename).resolve()
|
||||
self.baseFilename = str(resolved)
|
||||
self._hermes_routed_log_path = resolved
|
||||
self._default_home = resolved.parent.parent.resolve()
|
||||
self._profile_homes = {Path(home).expanduser().resolve() for home in profile_homes}
|
||||
self._filename = resolved.name
|
||||
self._max_bytes = getattr(existing, "maxBytes", 0)
|
||||
self._backup_count = getattr(existing, "backupCount", 0)
|
||||
self._profile_handlers: dict[Path, _ManagedRotatingFileHandler] = {}
|
||||
self._profile_handlers_lock = threading.RLock()
|
||||
if formatter is not None:
|
||||
self.setFormatter(formatter)
|
||||
for log_filter in log_filters:
|
||||
self.setFormatter(existing.formatter)
|
||||
for log_filter in existing.filters:
|
||||
self.addFilter(log_filter)
|
||||
|
||||
def _home_for_record(self, record: logging.LogRecord) -> Path:
|
||||
@@ -609,24 +443,12 @@ class _ProfileRoutingFileHandler(logging.Handler):
|
||||
|
||||
def _handler_for_home(self, home: Path) -> _ManagedRotatingFileHandler:
|
||||
with self._profile_handlers_lock:
|
||||
handler = self._profile_handlers.get(home)
|
||||
if handler is not None:
|
||||
return handler
|
||||
|
||||
path = home / "logs" / self._filename
|
||||
from hermes_constants import mkdir_under_hermes_home
|
||||
|
||||
mkdir_under_hermes_home(path.parent)
|
||||
handler = _ManagedRotatingFileHandler(
|
||||
str(path),
|
||||
maxBytes=self._max_bytes,
|
||||
backupCount=self._backup_count,
|
||||
encoding="utf-8",
|
||||
)
|
||||
handler.setLevel(self.level)
|
||||
handler.setFormatter(self.formatter)
|
||||
self._profile_handlers[home] = handler
|
||||
return handler
|
||||
if home not in self._profile_handlers:
|
||||
self._profile_handlers[home] = _new_file_handler(
|
||||
home / "logs" / self._filename, level=self.level, max_bytes=self._max_bytes,
|
||||
backup_count=self._backup_count, formatter=self.formatter,
|
||||
)
|
||||
return self._profile_handlers[home]
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
@@ -639,14 +461,10 @@ class _ProfileRoutingFileHandler(logging.Handler):
|
||||
handlers = list(self._profile_handlers.values())
|
||||
self._profile_handlers.clear()
|
||||
for handler in handlers:
|
||||
try:
|
||||
handler.close()
|
||||
except Exception:
|
||||
pass
|
||||
_quietly(handler.close)
|
||||
super().close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Asynchronous file logging — keep the cross-process rotation lock off the loop
|
||||
#
|
||||
# The rotating file handlers serialize rollover with a cross-process lock (see
|
||||
@@ -656,7 +474,6 @@ class _ProfileRoutingFileHandler(logging.Handler):
|
||||
# WebSocket clients. To keep file I/O off the hot path, every file handler is
|
||||
# driven by a single ``QueueListener`` on a dedicated thread; loggers only touch
|
||||
# an in-memory queue (a non-blocking enqueue).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_log_queue: "Optional[queue.SimpleQueue]" = None
|
||||
_queue_listener: Optional[QueueListener] = None
|
||||
@@ -674,49 +491,47 @@ _queue_state_lock = threading.Lock()
|
||||
class _NonFormattingQueueHandler(QueueHandler):
|
||||
"""``QueueHandler`` for an in-process queue.
|
||||
|
||||
Stdlib ``prepare()`` formats the record and drops ``args``/``exc_info`` so it
|
||||
can be pickled to another process. Our queue is in-process, so we skip that
|
||||
and hand the target file handlers an unformatted record — they apply their
|
||||
own ``RedactingFormatter`` and component filters on the listener thread.
|
||||
|
||||
We return a **shallow copy** rather than the original record: the same
|
||||
record is still owned by the emitting thread (and any synchronous handler
|
||||
on it, e.g. a ``StreamHandler``), which may format/mutate ``record.message``
|
||||
while our listener thread reads it. Copying preserves ``msg``/``args``/
|
||||
``exc_info`` for the deferred format while removing the cross-thread
|
||||
mutation race on a shared object.
|
||||
Stdlib ``prepare()`` formats and strips ``args``/``exc_info`` for pickling across processes;
|
||||
our queue is in-process, so the target handlers get an unformatted record and apply their own
|
||||
``RedactingFormatter`` on the listener thread. A shallow copy is returned because the emitting
|
||||
thread's synchronous handlers may mutate ``record.message`` while the listener reads it.
|
||||
"""
|
||||
|
||||
def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
|
||||
return copy.copy(record)
|
||||
|
||||
|
||||
def _stop_queue_listener_locked() -> None:
|
||||
"""Stop the listener assuming ``_queue_state_lock`` is already held."""
|
||||
global _queue_listener
|
||||
listener, _queue_listener = _queue_listener, None
|
||||
if listener is not None:
|
||||
try:
|
||||
listener.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _stop_queue_listener() -> None:
|
||||
"""Flush and stop the background log listener (idempotent, thread-safe).
|
||||
|
||||
This is the atexit hook, so it must acquire the state lock itself.
|
||||
"""
|
||||
global _queue_listener
|
||||
with _queue_state_lock:
|
||||
_stop_queue_listener_locked()
|
||||
listener, _queue_listener = _queue_listener, None
|
||||
if listener is not None:
|
||||
_quietly(listener.stop)
|
||||
|
||||
|
||||
def _start_queue_listener_locked() -> None:
|
||||
"""(Re)build + start a listener over the current handler set (``_queue_state_lock`` held).
|
||||
|
||||
A running listener is stopped first; this only happens while handlers are being added
|
||||
(queue empty), so ``stop()`` returns immediately.
|
||||
"""
|
||||
global _queue_listener
|
||||
if _queue_listener is not None:
|
||||
_queue_listener.stop()
|
||||
_queue_listener = QueueListener(_log_queue, *_queued_file_handlers, respect_handler_level=True)
|
||||
_queue_listener.start()
|
||||
|
||||
|
||||
def _register_queued_handler(handler: logging.Handler) -> None:
|
||||
"""Route *handler* through the shared async queue instead of attaching it to
|
||||
*root* directly, so emitting threads never block on file I/O or the
|
||||
cross-process rotation lock. The ``QueueListener`` applies each handler's
|
||||
own level and filters on its worker thread."""
|
||||
global _log_queue, _queue_listener, _queue_atexit_registered
|
||||
"""Route *handler* through the shared async queue instead of attaching it to *root* directly, so
|
||||
emitting threads never block on file I/O or the cross-process rotation lock. The
|
||||
``QueueListener`` applies each handler's own level and filters on its worker thread.
|
||||
"""
|
||||
global _log_queue, _queue_atexit_registered
|
||||
with _queue_state_lock:
|
||||
if _log_queue is None:
|
||||
_log_queue = queue.SimpleQueue()
|
||||
@@ -727,15 +542,7 @@ def _register_queued_handler(handler: logging.Handler) -> None:
|
||||
# queue via propagation.
|
||||
logging.getLogger().addHandler(qh)
|
||||
_queued_file_handlers.append(handler)
|
||||
# Rebuild the listener with the full target set. This only happens
|
||||
# while init_logging() adds handlers (2-3 times, queue empty), so
|
||||
# stop() returns immediately.
|
||||
if _queue_listener is not None:
|
||||
_queue_listener.stop()
|
||||
_queue_listener = QueueListener(
|
||||
_log_queue, *_queued_file_handlers, respect_handler_level=True
|
||||
)
|
||||
_queue_listener.start()
|
||||
_start_queue_listener_locked()
|
||||
if not _queue_atexit_registered:
|
||||
# Runs before logging.shutdown (registered earlier at import time),
|
||||
# so the listener stops before its file handlers are closed.
|
||||
@@ -746,14 +553,12 @@ def _register_queued_handler(handler: logging.Handler) -> None:
|
||||
def flush_log_queue() -> None:
|
||||
"""Block until all queued records have been written, then resume.
|
||||
|
||||
Draining is done by stopping the listener (which processes every pending
|
||||
record before joining) and restarting it. Used by tests that read a log
|
||||
file right after emitting to it.
|
||||
Draining is done by stopping the listener (which processes every pending record before joining)
|
||||
and restarting it. Used by tests that read a log file right after emitting to it.
|
||||
|
||||
NOTE: ``stop()`` joins the worker thread, so this blocks until the queue
|
||||
is empty. Do NOT call this on a hard-exit path where the listener may be
|
||||
wedged on the rotation lock — use ``drain_log_queue()`` there instead,
|
||||
which bounds the wait.
|
||||
NOTE: ``stop()`` joins the worker thread, so this blocks until the queue is empty. Do NOT call
|
||||
this on a hard-exit path where the listener may be wedged on the rotation lock — use
|
||||
``drain_log_queue()`` there instead, which bounds the wait.
|
||||
"""
|
||||
with _queue_state_lock:
|
||||
listener = _queue_listener
|
||||
@@ -765,59 +570,34 @@ def flush_log_queue() -> None:
|
||||
def drain_log_queue(timeout: float = 1.0) -> None:
|
||||
"""Best-effort, time-bounded drain for hard-exit paths (no restart).
|
||||
|
||||
Unlike ``flush_log_queue()``, this stops the listener WITHOUT restarting it
|
||||
(the process is about to exit) and bounds the drain: if the listener's
|
||||
worker thread is wedged on the cross-process rotation lock — the very
|
||||
failure this async-logging change exists to survive — an unbounded
|
||||
``stop()``/join would re-freeze the shutdown path. We run ``stop()`` on a
|
||||
throwaway thread and only wait ``timeout`` seconds for it; if it hasn't
|
||||
drained by then we abandon the last few records and let ``os._exit``
|
||||
proceed. Availability beats the last log line when the disk is already
|
||||
wedged.
|
||||
Unlike ``flush_log_queue()``, this stops the listener WITHOUT restarting it (the process is
|
||||
about to exit) and bounds the drain: if the listener's worker thread is wedged on the cross-
|
||||
process rotation lock — the very failure this async-logging change exists to survive — an
|
||||
unbounded ``stop()``/join would re-freeze the shutdown path.
|
||||
"""
|
||||
listener = _queue_listener
|
||||
if listener is None:
|
||||
return
|
||||
|
||||
def _drain() -> None:
|
||||
try:
|
||||
listener.stop()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
t = threading.Thread(target=_drain, name="hermes-log-drain", daemon=True)
|
||||
t = threading.Thread(target=lambda: _quietly(listener.stop), name="hermes-log-drain", daemon=True)
|
||||
t.start()
|
||||
t.join(timeout)
|
||||
|
||||
|
||||
def rotating_file_handlers() -> list:
|
||||
"""Return the live rotating file handlers.
|
||||
|
||||
They are attached to the async ``QueueListener`` rather than the root
|
||||
logger, so callers/tests must use this instead of scanning
|
||||
``logging.getLogger().handlers``."""
|
||||
return list(_queued_file_handlers)
|
||||
|
||||
|
||||
def enable_profile_log_routing(profile_homes: Sequence[str | Path]) -> bool:
|
||||
"""Make the queued file logs follow a desktop profile context.
|
||||
|
||||
``setup_logging`` normally binds handlers to one process home. The
|
||||
desktop dashboard is the exception: its embedded cron ticker may run
|
||||
jobs for every profile. Replace the existing static file handlers with
|
||||
profile routers after that profile list is known.
|
||||
``setup_logging`` normally binds handlers to one process home. The desktop dashboard is the
|
||||
exception: its embedded cron ticker may run jobs for every profile. Replace the existing static
|
||||
file handlers with profile routers after that profile list is known.
|
||||
|
||||
Returns ``True`` when routing is enabled or was already enabled. A
|
||||
single-profile caller is left untouched because its existing handlers are
|
||||
already correctly scoped.
|
||||
Returns ``True`` when routing is enabled or was already enabled. A single-profile caller is left
|
||||
untouched because its existing handlers are already correctly scoped.
|
||||
"""
|
||||
global _queue_listener
|
||||
|
||||
homes = []
|
||||
homes: list[Path] = []
|
||||
for entry in profile_homes:
|
||||
home = entry[1] if isinstance(entry, tuple) else entry
|
||||
try:
|
||||
resolved = Path(home).expanduser().resolve()
|
||||
resolved = Path(entry[1] if isinstance(entry, tuple) else entry).expanduser().resolve()
|
||||
except (TypeError, ValueError, OSError):
|
||||
continue
|
||||
if resolved not in homes:
|
||||
@@ -834,58 +614,38 @@ def enable_profile_log_routing(profile_homes: Sequence[str | Path]) -> bool:
|
||||
listener = _queue_listener
|
||||
if listener is not None:
|
||||
listener.stop()
|
||||
_queue_listener = None
|
||||
|
||||
replacement = []
|
||||
for existing in _queued_file_handlers:
|
||||
if not isinstance(existing, RotatingFileHandler):
|
||||
if isinstance(existing, RotatingFileHandler):
|
||||
replacement.append(_ProfileRoutingFileHandler(existing, homes))
|
||||
_quietly(existing.close)
|
||||
else:
|
||||
replacement.append(existing)
|
||||
continue
|
||||
|
||||
default_path = Path(existing.baseFilename)
|
||||
router = _ProfileRoutingFileHandler(
|
||||
default_path=default_path,
|
||||
profile_homes=homes,
|
||||
level=existing.level,
|
||||
max_bytes=getattr(existing, "maxBytes", 0),
|
||||
backup_count=getattr(existing, "backupCount", 0),
|
||||
formatter=existing.formatter,
|
||||
log_filters=list(existing.filters),
|
||||
)
|
||||
replacement.append(router)
|
||||
try:
|
||||
existing.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_queued_file_handlers[:] = replacement
|
||||
if listener is not None:
|
||||
_queue_listener = QueueListener(
|
||||
_log_queue, *_queued_file_handlers, respect_handler_level=True
|
||||
)
|
||||
_queue_listener.start()
|
||||
_start_queue_listener_locked()
|
||||
return True
|
||||
|
||||
|
||||
def _reset_queued_handlers() -> None:
|
||||
"""Tear down the async logging queue + listener (test-isolation helper)."""
|
||||
global _log_queue
|
||||
_stop_queue_listener()
|
||||
with _queue_state_lock:
|
||||
_stop_queue_listener_locked()
|
||||
root = logging.getLogger()
|
||||
for h in list(root.handlers):
|
||||
if getattr(h, "_hermes_queue", False):
|
||||
root.removeHandler(h)
|
||||
for h in list(_queued_file_handlers):
|
||||
try:
|
||||
h.close()
|
||||
except Exception:
|
||||
pass
|
||||
_quietly(h.close)
|
||||
_queued_file_handlers.clear()
|
||||
_log_queue = None
|
||||
|
||||
|
||||
def _add_rotating_handler(
|
||||
logger: logging.Logger,
|
||||
path: Path,
|
||||
*,
|
||||
level: int,
|
||||
@@ -894,33 +654,21 @@ def _add_rotating_handler(
|
||||
formatter: logging.Formatter,
|
||||
log_filter: Optional[logging.Filter] = None,
|
||||
) -> None:
|
||||
"""Add a ``RotatingFileHandler`` to *logger*, skipping if one already
|
||||
exists for the same resolved file path (idempotent).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
log_filter
|
||||
Optional filter to attach to the handler (e.g. ``_ComponentFilter``
|
||||
for gateway.log).
|
||||
"""Register a queued ``RotatingFileHandler`` for *path*, skipping if one already exists for the
|
||||
same resolved file path (idempotent).
|
||||
"""
|
||||
resolved = path.resolve()
|
||||
for existing in _queued_file_handlers:
|
||||
if (
|
||||
# Already attached directly, or already covered by the profile router.
|
||||
if getattr(existing, "_hermes_routed_log_path", None) == resolved or (
|
||||
isinstance(existing, RotatingFileHandler)
|
||||
and Path(getattr(existing, "baseFilename", "")).resolve() == resolved
|
||||
):
|
||||
return # already attached
|
||||
if getattr(existing, "_hermes_routed_log_path", None) == resolved:
|
||||
return # already covered by the profile router
|
||||
return
|
||||
|
||||
from hermes_constants import mkdir_under_hermes_home
|
||||
mkdir_under_hermes_home(path.parent)
|
||||
handler = _ManagedRotatingFileHandler(
|
||||
str(path), maxBytes=max_bytes, backupCount=backup_count,
|
||||
encoding="utf-8",
|
||||
handler = _new_file_handler(
|
||||
path, level=level, max_bytes=max_bytes, backup_count=backup_count, formatter=formatter,
|
||||
)
|
||||
handler.setLevel(level)
|
||||
handler.setFormatter(formatter)
|
||||
if log_filter is not None:
|
||||
handler.addFilter(log_filter)
|
||||
# Route through the async queue instead of ``logger.addHandler(handler)`` so
|
||||
@@ -929,10 +677,7 @@ def _add_rotating_handler(
|
||||
|
||||
|
||||
def _read_logging_config():
|
||||
"""Best-effort read of ``logging.*`` from config.yaml.
|
||||
|
||||
Returns ``(level, max_size_mb, backup_count)`` — any may be ``None``.
|
||||
"""
|
||||
"""Best-effort read of ``logging.*`` from config.yaml."""
|
||||
try:
|
||||
# Prefer the shared (mtime, size)-keyed raw-config cache so this read
|
||||
# reuses the parse hermes_cli.main's early bridge already did (one
|
||||
@@ -945,25 +690,22 @@ def _read_logging_config():
|
||||
except Exception:
|
||||
from utils import fast_safe_load
|
||||
config_path = get_config_path()
|
||||
if not config_path.exists():
|
||||
return (None, None, None)
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
cfg = fast_safe_load(f) or {}
|
||||
if cfg:
|
||||
# Managed scope: an administrator can pin logging.* too. Overlay via
|
||||
# the shared helper (fail-open) since this reads config.yaml directly.
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
cfg = managed_scope.apply_managed_overlay(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
log_cfg = cfg.get("logging", {})
|
||||
if isinstance(log_cfg, dict):
|
||||
return (
|
||||
log_cfg.get("level"),
|
||||
log_cfg.get("max_size_mb"),
|
||||
log_cfg.get("backup_count"),
|
||||
)
|
||||
cfg = {}
|
||||
if config_path.exists():
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
cfg = fast_safe_load(f) or {}
|
||||
if not cfg:
|
||||
return (None, None, None)
|
||||
# Managed scope: an administrator can pin logging.* too. Overlay via
|
||||
# the shared helper (fail-open) since this reads config.yaml directly.
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
cfg = managed_scope.apply_managed_overlay(cfg)
|
||||
except Exception:
|
||||
pass
|
||||
log_cfg = cfg.get("logging", {})
|
||||
if isinstance(log_cfg, dict):
|
||||
return (log_cfg.get("level"), log_cfg.get("max_size_mb"), log_cfg.get("backup_count"))
|
||||
except Exception:
|
||||
pass
|
||||
return (None, None, None)
|
||||
|
||||
729
utils.py
729
utils.py
@@ -24,8 +24,6 @@ def is_truthy_value(value: Any, default: bool = False) -> bool:
|
||||
"""Coerce bool-ish values using the project's shared truthy string set."""
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
return value.strip().lower() in TRUTHY_STRINGS
|
||||
return bool(value)
|
||||
@@ -46,47 +44,40 @@ def _preserve_file_mode(path: Path) -> "int | None":
|
||||
|
||||
def _preserve_file_owner(path: Path) -> "tuple[int, int] | None":
|
||||
"""Capture the owning uid/gid of *path* if the platform supports it."""
|
||||
if os.name != "posix":
|
||||
return None
|
||||
try:
|
||||
st = path.stat()
|
||||
st = path.stat() if os.name == "posix" else None
|
||||
except OSError:
|
||||
return None
|
||||
return st.st_uid, st.st_gid
|
||||
return (st.st_uid, st.st_gid) if st else None
|
||||
|
||||
|
||||
def _restore_file_metadata(path: Path, owner: "tuple[int, int] | None", mode: "int | None") -> None:
|
||||
"""Best-effort re-apply of uid/gid and permission bits after an atomic replace.
|
||||
|
||||
Docker/NAS installs often run some commands as root while the volume is owned by the runtime
|
||||
user; ``os.replace`` swaps in the temp file's owner, leaving ``config.yaml`` root-owned, so
|
||||
privileged callers chown it back (harmless otherwise). ``tempfile.mkstemp`` creates files 0o600;
|
||||
without re-applying *mode* the target would inherit that and break volume mounts relying on
|
||||
broader permissions.
|
||||
"""
|
||||
if owner is not None and hasattr(os, "chown"):
|
||||
try:
|
||||
os.chown(path, owner[0], owner[1])
|
||||
except OSError:
|
||||
pass
|
||||
if mode is not None:
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _restore_file_owner(path: Path, owner: "tuple[int, int] | None") -> None:
|
||||
"""Re-apply uid/gid after an atomic replace when permitted.
|
||||
|
||||
Docker and NAS-backed installs often run some commands as root while the
|
||||
persistent volume is owned by the runtime user. ``os.replace`` swaps in the
|
||||
temp file's owner, so a root-run config write can leave ``config.yaml`` owned
|
||||
by root. Best-effort chown preserves the existing owner for privileged
|
||||
callers and is harmless for unprivileged callers that cannot chown.
|
||||
"""
|
||||
if owner is None or not hasattr(os, "chown"):
|
||||
return
|
||||
try:
|
||||
os.chown(path, owner[0], owner[1])
|
||||
except OSError:
|
||||
pass
|
||||
_restore_file_metadata(path, owner, None)
|
||||
|
||||
|
||||
def _restore_file_mode(path: Path, mode: "int | None") -> None:
|
||||
"""Re-apply *mode* to *path* after an atomic replace.
|
||||
|
||||
``tempfile.mkstemp`` creates files with 0o600 (owner-only). After
|
||||
``os.replace`` swaps the temp file into place the target inherits
|
||||
those restrictive permissions, breaking Docker / NAS volume mounts
|
||||
that rely on broader permissions set by the user. Calling this
|
||||
right after ``os.replace`` restores the original permissions.
|
||||
"""
|
||||
if mode is None:
|
||||
return
|
||||
try:
|
||||
os.chmod(path, mode)
|
||||
except OSError:
|
||||
pass
|
||||
_restore_file_metadata(path, None, mode)
|
||||
|
||||
|
||||
_IS_WINDOWS = os.name == "nt"
|
||||
@@ -127,42 +118,27 @@ _REPLACE_RETRY_MAX_DELAY_S = 0.1
|
||||
def _is_contended_windows_replace_error(exc: OSError) -> bool:
|
||||
"""Return True for Windows rename failures a retry might clear.
|
||||
|
||||
Only a *candidate* classification: ``ERROR_ACCESS_DENIED`` covers both a
|
||||
concurrent handle and a real ACL denial, and the two are not reliably
|
||||
distinguishable up front. Probing the target with ``os.access`` does not
|
||||
work — ``os.replace`` needs delete-child rights on the *parent directory*,
|
||||
so a directory-level denial reports the target as writable. Instead of
|
||||
guessing, both cases enter the same bounded retry and a genuine denial
|
||||
falls through to the caller with its original error.
|
||||
Only a *candidate* classification: ``ERROR_ACCESS_DENIED`` covers both a concurrent handle and a
|
||||
real ACL denial, and the two are not reliably distinguishable up front.
|
||||
"""
|
||||
return _IS_WINDOWS and getattr(exc, "winerror", None) in (
|
||||
_WINDOWS_CONTENDED_REPLACE_ERRORS
|
||||
)
|
||||
return _IS_WINDOWS and getattr(exc, "winerror", None) in _WINDOWS_CONTENDED_REPLACE_ERRORS
|
||||
|
||||
|
||||
def _rewrite_in_place(tmp_str: str, real_path: str) -> None:
|
||||
"""Overwrite *real_path* with the contents of *tmp_str*, in place.
|
||||
|
||||
Last-resort path for a target whose handle is still held after the retry
|
||||
budget: writing through the existing file works where renaming onto it
|
||||
does not. Unlike ``shutil.copyfile`` this never truncates the target to
|
||||
zero first — a concurrent reader would otherwise be able to observe an
|
||||
empty ``auth.json`` / ``gateway_state.json`` mid-write (measured: a
|
||||
4-thread poller sees a 0-byte read during a plain copyfile). A single
|
||||
``os.write`` of the full payload followed by ``ftruncate`` keeps the
|
||||
visible content going straight from old to new.
|
||||
Last-resort path for a target whose handle is still held after the retry budget: writing through
|
||||
the existing file works where renaming onto it does not.
|
||||
|
||||
This is still not atomic — it is a strictly smaller window than a copy,
|
||||
not the absence of one — so it runs only after the rename has genuinely
|
||||
failed. Writing through the target also preserves its ACL, which
|
||||
``os.replace`` does not (the temp file's inherited ACL wins there).
|
||||
This is still not atomic — it is a strictly smaller window than a copy, not the absence of one —
|
||||
so it runs only after the rename has genuinely failed. Writing through the target also preserves
|
||||
its ACL, which ``os.replace`` does not (the temp file's inherited ACL wins there).
|
||||
"""
|
||||
with open(tmp_str, "rb") as src:
|
||||
data = src.read()
|
||||
flags = os.O_WRONLY | getattr(os, "O_BINARY", 0)
|
||||
fd = os.open(real_path, flags)
|
||||
try:
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
written = 0
|
||||
while written < len(data):
|
||||
written += os.write(fd, data[written:])
|
||||
@@ -194,34 +170,13 @@ def _copy_fallback(tmp_str: str, real_path: str) -> None:
|
||||
def atomic_replace(tmp_path: Union[str, Path], target: Union[str, Path]) -> str:
|
||||
"""Atomically move *tmp_path* onto *target*, preserving symlinks.
|
||||
|
||||
``os.replace(tmp, target)`` atomically swaps ``tmp`` into place at
|
||||
``target``. When ``target`` is a symlink, the symlink itself is
|
||||
replaced with a regular file — silently detaching managed deployments
|
||||
that symlink ``config.yaml`` / ``SOUL.md`` / ``auth.json`` etc. from
|
||||
``~/.hermes/`` to a git-tracked profile package or dotfiles repo
|
||||
(GitHub #16743).
|
||||
This helper resolves the symlink first so ``os.replace`` writes to the real file in-place while
|
||||
the symlink survives. For non-symlink and non-existent paths the behavior is identical to a
|
||||
plain ``os.replace`` call unless the rename fails with:
|
||||
|
||||
This helper resolves the symlink first so ``os.replace`` writes to
|
||||
the real file in-place while the symlink survives. For non-symlink
|
||||
and non-existent paths the behavior is identical to a plain
|
||||
``os.replace`` call unless the rename fails with:
|
||||
|
||||
* ``EXDEV`` / ``EBUSY`` (any platform) — cross-device, bind-mount, and
|
||||
busy-file deployments fall back to copy/fsync/unlink immediately.
|
||||
These never clear on retry.
|
||||
* A Windows rename contended by another open handle (winerror 5/32/33).
|
||||
CPython opens files without ``FILE_SHARE_DELETE``, so *any* concurrent
|
||||
reader of the target blocks the rename. The rename is retried with
|
||||
jittered backoff first — a retry that wins keeps the write atomic —
|
||||
and only a target whose handle outlives the budget is rewritten in
|
||||
place, so the update lands instead of being silently dropped.
|
||||
|
||||
A genuine Windows permission failure produces the same winerror as a
|
||||
contended one, so it is not classified up front: it exhausts the retry
|
||||
budget, fails the in-place rewrite too, and is re-raised unchanged.
|
||||
|
||||
Returns the resolved real path used for the replace, so callers that
|
||||
need to re-apply permissions can target it instead of the symlink.
|
||||
* ``EXDEV`` / ``EBUSY`` (any platform) — cross-device, bind-mount, and busy-file deployments
|
||||
fall back to copy/fsync/unlink immediately. These never clear on retry. * A Windows rename
|
||||
contended by another open handle (winerror 5/32/33).
|
||||
"""
|
||||
target_str = str(target)
|
||||
real_path = os.path.realpath(target_str) if os.path.islink(target_str) else target_str
|
||||
@@ -239,13 +194,9 @@ def atomic_replace(tmp_path: Union[str, Path], target: Union[str, Path]) -> str:
|
||||
from agent.retry_utils import jittered_backoff
|
||||
|
||||
for attempt in range(1, _REPLACE_RETRY_ATTEMPTS + 1):
|
||||
time.sleep(
|
||||
jittered_backoff(
|
||||
attempt,
|
||||
base_delay=_REPLACE_RETRY_BASE_DELAY_S,
|
||||
max_delay=_REPLACE_RETRY_MAX_DELAY_S,
|
||||
)
|
||||
)
|
||||
time.sleep(jittered_backoff(
|
||||
attempt, base_delay=_REPLACE_RETRY_BASE_DELAY_S, max_delay=_REPLACE_RETRY_MAX_DELAY_S
|
||||
))
|
||||
try:
|
||||
os.replace(tmp_str, real_path)
|
||||
return real_path
|
||||
@@ -260,10 +211,8 @@ def atomic_replace(tmp_path: Union[str, Path], target: Union[str, Path]) -> str:
|
||||
exc = retry_exc
|
||||
logger.debug(
|
||||
"atomic_replace: %s -> %s failed with %s; falling back to %s",
|
||||
tmp_str,
|
||||
real_path,
|
||||
getattr(exc, "winerror", None)
|
||||
or errno.errorcode.get(exc.errno or 0, exc.errno),
|
||||
tmp_str, real_path,
|
||||
getattr(exc, "winerror", None) or errno.errorcode.get(exc.errno or 0, exc.errno),
|
||||
"in-place rewrite" if contended else "copy",
|
||||
)
|
||||
if contended:
|
||||
@@ -276,6 +225,53 @@ def atomic_replace(tmp_path: Union[str, Path], target: Union[str, Path]) -> str:
|
||||
return real_path
|
||||
|
||||
|
||||
def _atomic_write(
|
||||
path: Path,
|
||||
write,
|
||||
*,
|
||||
prefix: str,
|
||||
encoding: str = "utf-8",
|
||||
mode: "int | None" = None,
|
||||
preserve_owner: bool = True,
|
||||
) -> None:
|
||||
"""Temp file + fsync + :func:`atomic_replace`, then re-apply owner/mode.
|
||||
|
||||
*write(f)* emits the payload into the open text handle. *mode* (when not ``None``) is fchmod'd
|
||||
onto the temp fd BEFORE the replace so the target never transits through mkstemp's 0600 (fchmod
|
||||
is Unix-only; the post-replace chmod is the sole path on Windows and harmless elsewhere). The
|
||||
temp file is removed on any failure — ``BaseException`` on purpose, so KeyboardInterrupt /
|
||||
SystemExit still clean up before re-raising.
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
original_owner = _preserve_file_owner(path) if preserve_owner else None
|
||||
fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), prefix=prefix, suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding=encoding) as f:
|
||||
if mode is not None and hasattr(os, "fchmod"):
|
||||
os.fchmod(f.fileno(), mode)
|
||||
write(f)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
# Preserve symlinks — swap in-place on the real file (GitHub #16743).
|
||||
_restore_file_metadata(Path(atomic_replace(tmp_path, path)), original_owner, mode)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _mode_for_write(
|
||||
path: Path, create_mode: "int | None", preserve: bool = True
|
||||
) -> "int | None":
|
||||
"""Existing permission bits of *path* (when *preserve*), else *create_mode* for a new file."""
|
||||
mode = _preserve_file_mode(path) if preserve else None
|
||||
if mode is None and create_mode is not None and not path.exists():
|
||||
mode = create_mode
|
||||
return mode
|
||||
|
||||
|
||||
def atomic_write_text(
|
||||
path: Union[str, Path],
|
||||
content: str,
|
||||
@@ -287,60 +283,22 @@ def atomic_write_text(
|
||||
) -> None:
|
||||
"""Write *content* to *path* via temp file + fsync + atomic rename.
|
||||
|
||||
Ensures the target file is never left in a partially-written state if
|
||||
the process crashes or is interrupted. ``atomic_replace`` preserves
|
||||
symlinks and handles cross-device / busy-file fallbacks.
|
||||
Ensures the target file is never left in a partially-written state if the process crashes or is
|
||||
interrupted. ``atomic_replace`` preserves symlinks and handles cross-device / busy-file
|
||||
fallbacks.
|
||||
|
||||
Used by the memory store, skill manager, and agent importer so that
|
||||
every destructive file rewrite in the codebase shares one implementation.
|
||||
|
||||
Args:
|
||||
preserve_mode: When True, carry an existing target's permission bits
|
||||
and (POSIX, best-effort) owner across the replace, like
|
||||
``atomic_yaml_write`` does unconditionally. ``os.replace`` swaps
|
||||
in mkstemp's 0600 temp file owned by the writing user, so without
|
||||
this a root-run rewrite of a user-owned file flips its owner and
|
||||
tightens its mode. The mode is applied to the temp fd *before*
|
||||
the replace, so the file never transits through 0600. Off by
|
||||
default: the historical callers (memory store, skill manager,
|
||||
cron) own their 0600-is-fine files.
|
||||
create_mode: Permission bits to apply when the target does not yet
|
||||
exist (otherwise the new file keeps mkstemp's 0600). Never
|
||||
applied to an existing file.
|
||||
Used by the memory store, skill manager, and agent importer so that every destructive file
|
||||
rewrite in the codebase shares one implementation.
|
||||
"""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_mode = _preserve_file_mode(path) if preserve_mode else None
|
||||
original_owner = _preserve_file_owner(path) if preserve_mode else None
|
||||
effective_mode = original_mode
|
||||
if effective_mode is None and create_mode is not None and not path.exists():
|
||||
effective_mode = create_mode
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(path.parent), prefix=tmp_prefix, suffix=".tmp"
|
||||
_atomic_write(
|
||||
path,
|
||||
lambda f: f.write(content),
|
||||
prefix=tmp_prefix,
|
||||
encoding=encoding,
|
||||
mode=_mode_for_write(path, create_mode, preserve=preserve_mode),
|
||||
preserve_owner=preserve_mode,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding=encoding) as handle:
|
||||
if effective_mode is not None and hasattr(os, "fchmod"):
|
||||
# fchmod the temp fd BEFORE the replace so the target never
|
||||
# transits through mkstemp's 0600. fchmod is Unix-only; on
|
||||
# Windows the post-replace chmod below applies the mode.
|
||||
os.fchmod(handle.fileno(), effective_mode)
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
real_path = atomic_replace(tmp_path, path)
|
||||
if preserve_mode:
|
||||
_restore_file_owner(Path(real_path), original_owner)
|
||||
if effective_mode is not None and not hasattr(os, "fchmod"):
|
||||
_restore_file_mode(Path(real_path), effective_mode)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def atomic_json_write(
|
||||
@@ -353,66 +311,17 @@ def atomic_json_write(
|
||||
) -> None:
|
||||
"""Write JSON data to a file atomically.
|
||||
|
||||
Uses temp file + fsync + os.replace to ensure the target file is never
|
||||
left in a partially-written state. If the process crashes mid-write,
|
||||
the previous version of the file remains intact.
|
||||
|
||||
Args:
|
||||
path: Target file path (will be created or overwritten).
|
||||
data: JSON-serializable data to write.
|
||||
indent: JSON indentation (default 2).
|
||||
mode: Optional final permission mode. When set, the temp file is
|
||||
created and replaced with this mode, avoiding chmod-after-write
|
||||
TOCTOU exposure for secret-bearing files.
|
||||
**dump_kwargs: Additional keyword args forwarded to json.dump(), such
|
||||
as default=str for non-native types.
|
||||
Uses temp file + fsync + os.replace to ensure the target file is never left in a partially-
|
||||
written state. If the process crashes mid-write, the previous version of the file remains
|
||||
intact.
|
||||
"""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_mode = None if mode is not None else _preserve_file_mode(path)
|
||||
original_owner = _preserve_file_owner(path)
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(path.parent),
|
||||
_atomic_write(
|
||||
path,
|
||||
lambda f: json.dump(data, f, indent=indent, ensure_ascii=False, **dump_kwargs),
|
||||
prefix=f".{path.stem}_",
|
||||
suffix=".tmp",
|
||||
mode=mode if mode is not None else _preserve_file_mode(path),
|
||||
)
|
||||
try:
|
||||
if mode is not None and hasattr(os, "fchmod"):
|
||||
# fchmod is Unix-only; Windows' os module has no fchmod. Skipping it
|
||||
# here is safe — mkstemp already created the temp file as 0o600, and
|
||||
# the post-replace os.chmod below applies the final mode durably.
|
||||
os.fchmod(fd, mode)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
data,
|
||||
f,
|
||||
indent=indent,
|
||||
ensure_ascii=False,
|
||||
**dump_kwargs,
|
||||
)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
# Preserve symlinks — swap in-place on the real file (GitHub #16743).
|
||||
real_path = atomic_replace(tmp_path, path)
|
||||
real_path_obj = Path(real_path)
|
||||
_restore_file_owner(real_path_obj, original_owner)
|
||||
if mode is not None:
|
||||
try:
|
||||
os.chmod(real_path_obj, mode)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
_restore_file_mode(real_path_obj, original_mode)
|
||||
except BaseException:
|
||||
# Intentionally catch BaseException so temp-file cleanup still runs for
|
||||
# KeyboardInterrupt/SystemExit before re-raising the original signal.
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def warn_if_credential_file_broadly_readable(
|
||||
@@ -423,29 +332,24 @@ def warn_if_credential_file_broadly_readable(
|
||||
) -> bool:
|
||||
"""Warn (once per call) when a credential file is group/world-readable.
|
||||
|
||||
Secret-bearing files that users create by hand (or that older Hermes
|
||||
versions wrote without an explicit mode) commonly end up 0o644 under the
|
||||
default umask. This helper is the shared read-time check for that class:
|
||||
call it before loading any token/credential file so the owner gets a
|
||||
Secret-bearing files that users create by hand (or that older Hermes versions wrote without an
|
||||
explicit mode) commonly end up 0o644 under the default umask. This helper is the shared read-
|
||||
time check for that class: call it before loading any token/credential file so the owner gets a
|
||||
remediation hint in the logs.
|
||||
|
||||
Returns True when a warning was emitted. No-ops (returns False) on
|
||||
platforms without POSIX permission bits semantics (best effort), when the
|
||||
file is missing, or when permissions are already tight.
|
||||
Returns True when a warning was emitted. No-ops (returns False) on platforms without POSIX
|
||||
permission bits semantics (best effort), when the file is missing, or when permissions are
|
||||
already tight.
|
||||
"""
|
||||
p = Path(path)
|
||||
_log = log or logger
|
||||
try:
|
||||
file_mode = p.stat().st_mode
|
||||
except OSError:
|
||||
return False
|
||||
if os.name != "posix":
|
||||
# Windows ACLs don't map onto POSIX group/other bits; st_mode there
|
||||
# is synthesized and would false-positive.
|
||||
# Windows ACLs don't map onto POSIX group/other bits; st_mode there is synthesized.
|
||||
if os.name != "posix" or not (file_mode & (stat.S_IRGRP | stat.S_IROTH)):
|
||||
return False
|
||||
if not (file_mode & (stat.S_IRGRP | stat.S_IROTH)):
|
||||
return False
|
||||
_log.warning(
|
||||
(log or logger).warning(
|
||||
"%s%s is group/world-readable (mode 0%o) and contains secrets. "
|
||||
"Run: chmod 600 %s",
|
||||
f"{label} " if label else "",
|
||||
@@ -459,13 +363,10 @@ def warn_if_credential_file_broadly_readable(
|
||||
class IndentDumper(yaml.SafeDumper):
|
||||
"""PyYAML dumper that indents list items under mapping keys (2-space).
|
||||
|
||||
Default PyYAML emits "indentless" sequences — list items start at the
|
||||
same column as their parent mapping key. ``ruamel.yaml`` (used by
|
||||
:func:`atomic_roundtrip_yaml_update`) emits 2-space-indented sequences.
|
||||
Mixing both styles in the same ``config.yaml`` produces a file that
|
||||
stricter parsers like ``js-yaml`` reject with ``bad indentation of a
|
||||
mapping entry``. Forcing ``indentless=False`` aligns the two
|
||||
serializers so all write paths emit byte-identical layouts (#31999).
|
||||
Default PyYAML emits "indentless" sequences while ``ruamel.yaml`` (used by
|
||||
:func:`atomic_roundtrip_yaml_update`) indents them; mixing both in one ``config.yaml`` makes
|
||||
stricter parsers like ``js-yaml`` reject it. Forcing ``indentless=False`` keeps every write
|
||||
path byte-identical.
|
||||
"""
|
||||
|
||||
def increase_indent(self, flow=False, indentless=False): # noqa: ARG002
|
||||
@@ -483,74 +384,57 @@ def atomic_yaml_write(
|
||||
) -> None:
|
||||
"""Write YAML data to a file atomically.
|
||||
|
||||
Uses temp file + fsync + os.replace to ensure the target file is never
|
||||
left in a partially-written state. If the process crashes mid-write,
|
||||
the previous version of the file remains intact.
|
||||
|
||||
Args:
|
||||
path: Target file path (will be created or overwritten).
|
||||
data: YAML-serializable data to write.
|
||||
default_flow_style: YAML flow style (default False).
|
||||
sort_keys: Whether to sort dict keys (default False).
|
||||
extra_content: Optional string to append after the YAML dump
|
||||
(e.g. commented-out sections for user reference).
|
||||
create_mode: Permission bits to apply when the target does not yet
|
||||
exist (a created file otherwise keeps mkstemp's 0600). Never
|
||||
applied to an existing file, whose mode is always preserved.
|
||||
Uses temp file + fsync + os.replace to ensure the target file is never left in a partially-
|
||||
written state. If the process crashes mid-write, the previous version of the file remains
|
||||
intact.
|
||||
"""
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_mode = _preserve_file_mode(path)
|
||||
original_owner = _preserve_file_owner(path)
|
||||
if original_mode is None and create_mode is not None and not path.exists():
|
||||
original_mode = create_mode
|
||||
def _write(f) -> None:
|
||||
# allow_unicode=True writes emoji/kaomoji (e.g. personalities, skin
|
||||
# cursors) as real UTF-8 instead of fragile escape sequences. Without
|
||||
# it, PyYAML emits astral-plane chars as `\UXXXXXXXX` (8-digit) escapes
|
||||
# inside multi-line double-quoted strings wrapped with `\`
|
||||
# continuations — a structure that stricter/non-PyYAML parsers and
|
||||
# hand-edits routinely break into unclosed quotes, corrupting the whole
|
||||
# config (GitHub #51356).
|
||||
yaml.dump(
|
||||
data,
|
||||
f,
|
||||
Dumper=IndentDumper,
|
||||
default_flow_style=default_flow_style,
|
||||
sort_keys=sort_keys,
|
||||
allow_unicode=True,
|
||||
)
|
||||
if extra_content:
|
||||
f.write(extra_content)
|
||||
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(path.parent),
|
||||
prefix=f".{path.stem}_",
|
||||
suffix=".tmp",
|
||||
_atomic_write(
|
||||
path, _write, prefix=f".{path.stem}_", mode=_mode_for_write(path, create_mode)
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
if original_mode is not None and hasattr(os, "fchmod"):
|
||||
# Apply the mode to the temp fd BEFORE the replace so the
|
||||
# target never transits through mkstemp's 0600 (the
|
||||
# post-replace _restore_file_mode below then re-applies it
|
||||
# harmlessly, and remains the sole path on Windows).
|
||||
os.fchmod(f.fileno(), original_mode)
|
||||
# allow_unicode=True writes emoji/kaomoji (e.g. personalities, skin
|
||||
# cursors) as real UTF-8 instead of fragile escape sequences. Without
|
||||
# it, PyYAML emits astral-plane chars as `\UXXXXXXXX` (8-digit) escapes
|
||||
# inside multi-line double-quoted strings wrapped with `\`
|
||||
# continuations — a structure that stricter/non-PyYAML parsers and
|
||||
# hand-edits routinely break into unclosed quotes, corrupting the whole
|
||||
# config (GitHub #51356).
|
||||
yaml.dump(
|
||||
data,
|
||||
f,
|
||||
Dumper=IndentDumper,
|
||||
default_flow_style=default_flow_style,
|
||||
sort_keys=sort_keys,
|
||||
allow_unicode=True,
|
||||
)
|
||||
if extra_content:
|
||||
f.write(extra_content)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
# Preserve symlinks — swap in-place on the real file (GitHub #16743).
|
||||
real_path = atomic_replace(tmp_path, path)
|
||||
real_path_obj = Path(real_path)
|
||||
_restore_file_owner(real_path_obj, original_owner)
|
||||
_restore_file_mode(real_path_obj, original_mode)
|
||||
except BaseException:
|
||||
# Match atomic_json_write: cleanup must also happen for process-level
|
||||
# interruptions before we re-raise them.
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def _roundtrip_yaml():
|
||||
"""ruamel round-trip ``YAML`` configured to keep quotes/Unicode with 2-space indents."""
|
||||
from ruamel.yaml import YAML
|
||||
|
||||
yaml_rt = YAML(typ="rt")
|
||||
yaml_rt.preserve_quotes = True
|
||||
yaml_rt.allow_unicode = True
|
||||
yaml_rt.default_flow_style = False
|
||||
yaml_rt.indent(mapping=2, sequence=4, offset=2)
|
||||
return yaml_rt
|
||||
|
||||
|
||||
def _load_commented_map(yaml_rt, path: Path):
|
||||
"""Load *path* with *yaml_rt* as a ``CommentedMap`` (empty when missing/blank)."""
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
|
||||
data = None
|
||||
if path.exists():
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = yaml_rt.load(f)
|
||||
return data if isinstance(data, CommentedMap) else CommentedMap(data or {})
|
||||
|
||||
|
||||
def atomic_roundtrip_yaml_update(
|
||||
@@ -560,31 +444,16 @@ def atomic_roundtrip_yaml_update(
|
||||
) -> None:
|
||||
"""Update one dotted YAML key while preserving comments and readable text.
|
||||
|
||||
This is intentionally narrower than :func:`atomic_yaml_write`: it is for
|
||||
user-edited config files where comments, ordering, quoting, and Unicode
|
||||
should survive a single setting mutation. Writes still use the same temp
|
||||
file + fsync + atomic replace pattern.
|
||||
Narrower than :func:`atomic_yaml_write` on purpose: for user-edited config files where
|
||||
comments, ordering, quoting and Unicode must survive a single setting mutation. Still writes
|
||||
via temp file + fsync + atomic replace.
|
||||
"""
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
yaml_rt = YAML(typ="rt")
|
||||
yaml_rt.preserve_quotes = True
|
||||
yaml_rt.allow_unicode = True
|
||||
yaml_rt.default_flow_style = False
|
||||
yaml_rt.indent(mapping=2, sequence=4, offset=2)
|
||||
|
||||
if path.exists():
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
config = yaml_rt.load(f) or CommentedMap()
|
||||
else:
|
||||
config = CommentedMap()
|
||||
|
||||
if not isinstance(config, CommentedMap):
|
||||
config = CommentedMap(config)
|
||||
yaml_rt = _roundtrip_yaml()
|
||||
config = _load_commented_map(yaml_rt, path)
|
||||
|
||||
current = config
|
||||
# Honor escaped dots and prefer existing literal dotted keys (e.g. model
|
||||
@@ -612,28 +481,12 @@ def atomic_roundtrip_yaml_update(
|
||||
current = next_value
|
||||
i += consumed
|
||||
|
||||
original_mode = _preserve_file_mode(path)
|
||||
original_owner = _preserve_file_owner(path)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(path.parent),
|
||||
_atomic_write(
|
||||
path,
|
||||
lambda f: yaml_rt.dump(config, f),
|
||||
prefix=f".{path.stem}_",
|
||||
suffix=".tmp",
|
||||
mode=_preserve_file_mode(path),
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
yaml_rt.dump(config, f)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
real_path = atomic_replace(tmp_path, path)
|
||||
real_path_obj = Path(real_path)
|
||||
_restore_file_owner(real_path_obj, original_owner)
|
||||
_restore_file_mode(real_path_obj, original_mode)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
def atomic_roundtrip_yaml_save(
|
||||
@@ -642,34 +495,13 @@ def atomic_roundtrip_yaml_save(
|
||||
) -> None:
|
||||
"""Persist a full config-state dict while preserving comments and ordering.
|
||||
|
||||
Behaves like ``atomic_yaml_write`` (writes the whole file in one shot from
|
||||
``new_state``), but routes through ruamel.yaml round-trip mode so existing
|
||||
comments, key order, quotes, and readable Unicode survive.
|
||||
Behaves like ``atomic_yaml_write`` (writes the whole file in one shot from ``new_state``), but
|
||||
routes through ruamel.yaml round-trip mode so existing comments, key order, quotes, and readable
|
||||
Unicode survive.
|
||||
|
||||
Reconciliation rules against the on-disk YAML:
|
||||
|
||||
* Keys present in both are updated in-place via assignment, which keeps
|
||||
ruamel's CommentedMap anchors (and their attached comments) attached to
|
||||
their original positions.
|
||||
* Keys missing from ``new_state`` are deleted.
|
||||
* Keys added in ``new_state`` are appended at the end of their parent map.
|
||||
* Nested ``dict`` values recurse with the same rules.
|
||||
* Non-dict values (lists, scalars) are overwritten wholesale — list
|
||||
element comments are not individually preserved, matching ruamel's
|
||||
semantics.
|
||||
|
||||
This is the comment-safe replacement for ``yaml.safe_dump(cfg, f)`` in
|
||||
callers that mutate a deep-loaded config dict and want to persist the
|
||||
whole thing.
|
||||
|
||||
Shares the fail-closed contract ``hermes_cli.config.atomic_config_write``
|
||||
enforces for plain (non-comment-preserving) full-document writes: an
|
||||
existing-but-unreadable ``config.yaml`` (permission error, broken mount,
|
||||
transient I/O) raises rather than being silently replaced with only
|
||||
``new_state``. Imported lazily to avoid a module-level circular import —
|
||||
``hermes_cli.config`` itself imports from this module.
|
||||
This is the comment-safe replacement for ``yaml.safe_dump(cfg, f)`` in callers that mutate a
|
||||
deep-loaded config dict and want to persist the whole thing.
|
||||
"""
|
||||
from ruamel.yaml import YAML
|
||||
from ruamel.yaml.comments import CommentedMap
|
||||
from ruamel.yaml.scalarstring import DoubleQuotedScalarString
|
||||
|
||||
@@ -678,20 +510,8 @@ def atomic_roundtrip_yaml_save(
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
require_readable_config_before_write(path)
|
||||
|
||||
yaml_rt = YAML(typ="rt")
|
||||
yaml_rt.preserve_quotes = True
|
||||
yaml_rt.allow_unicode = True
|
||||
yaml_rt.default_flow_style = False
|
||||
yaml_rt.indent(mapping=2, sequence=4, offset=2)
|
||||
|
||||
if path.exists():
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
existing = yaml_rt.load(f)
|
||||
if not isinstance(existing, CommentedMap):
|
||||
existing = CommentedMap(existing or {})
|
||||
else:
|
||||
existing = CommentedMap()
|
||||
yaml_rt = _roundtrip_yaml()
|
||||
existing = _load_commented_map(yaml_rt, path)
|
||||
|
||||
# ruamel's round-trip dumper resolves plain scalars against the YAML 1.2
|
||||
# core schema, where only true/false/null are reserved words — so a plain
|
||||
@@ -702,9 +522,7 @@ def atomic_roundtrip_yaml_save(
|
||||
# `approvals.mode: off` silently round-trips back as `False` under
|
||||
# yaml.safe_load. Force-quote any new string value that YAML 1.1 would
|
||||
# otherwise misparse as bool/null.
|
||||
_YAML11_AMBIGUOUS_WORDS = {
|
||||
"y", "n", "yes", "no", "true", "false", "on", "off", "null", "~",
|
||||
}
|
||||
_YAML11_AMBIGUOUS_WORDS = {"y", "n", "yes", "no", "true", "false", "on", "off", "null", "~"}
|
||||
|
||||
def _quote_if_yaml11_ambiguous(value):
|
||||
if isinstance(value, str) and value.lower() in _YAML11_AMBIGUOUS_WORDS:
|
||||
@@ -725,45 +543,24 @@ def atomic_roundtrip_yaml_save(
|
||||
# Delete keys missing from src — preserves "explicit absence" semantics
|
||||
# of the old _save_cfg(cfg) pattern (e.g. cfg.pop("custom_prompt", None)
|
||||
# then _save_cfg must actually remove the key from disk).
|
||||
for key in [k for k in dst.keys() if k not in src]:
|
||||
for key in [k for k in dst if k not in src]:
|
||||
del dst[key]
|
||||
|
||||
_merge(existing, new_state)
|
||||
|
||||
original_mode = _preserve_file_mode(path)
|
||||
original_owner = _preserve_file_owner(path)
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=str(path.parent),
|
||||
_atomic_write(
|
||||
path,
|
||||
lambda f: yaml_rt.dump(existing, f),
|
||||
prefix=f".{path.stem}_",
|
||||
suffix=".tmp",
|
||||
mode=_preserve_file_mode(path),
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
yaml_rt.dump(existing, f)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
real_path = atomic_replace(tmp_path, path)
|
||||
real_path_obj = Path(real_path)
|
||||
_restore_file_owner(real_path_obj, original_owner)
|
||||
_restore_file_mode(real_path_obj, original_mode)
|
||||
except BaseException:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
# ─── JSON Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def safe_json_loads(text: str, default: Any = None) -> Any:
|
||||
"""Parse JSON, returning *default* on any parse error.
|
||||
|
||||
Replaces the ``try: json.loads(x) except (JSONDecodeError, TypeError)``
|
||||
pattern duplicated across display.py, anthropic_adapter.py,
|
||||
auxiliary_client.py, and others.
|
||||
"""
|
||||
"""Parse JSON, returning *default* on any parse error."""
|
||||
try:
|
||||
return json.loads(text)
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
@@ -777,50 +574,41 @@ def safe_json_loads(text: str, default: Any = None) -> Any:
|
||||
# manifest with the slow path, costing ~0.9s of cold-start time. The C loader
|
||||
# is a true drop-in for ``safe_load`` (same restricted tag set), so prefer it
|
||||
# and fall back to the pure-Python loader only when libyaml isn't compiled in.
|
||||
_fast_yaml_loader = None
|
||||
|
||||
|
||||
def _get_fast_yaml_loader():
|
||||
global _fast_yaml_loader
|
||||
if _fast_yaml_loader is None:
|
||||
_fast_yaml_loader = getattr(yaml, "CSafeLoader", None) or yaml.SafeLoader
|
||||
return _fast_yaml_loader
|
||||
_fast_yaml_loader = getattr(yaml, "CSafeLoader", None) or yaml.SafeLoader
|
||||
|
||||
|
||||
def fast_safe_load(stream: Any) -> Any:
|
||||
"""``yaml.safe_load`` using the libyaml C loader when available.
|
||||
|
||||
Accepts the same inputs as ``yaml.safe_load`` (a ``str``/``bytes`` document
|
||||
or a readable file object) and returns the same parsed structure. Falls
|
||||
back to PyYAML's pure-Python ``SafeLoader`` when ``CSafeLoader`` isn't
|
||||
available, so behavior is identical everywhere — only the speed differs.
|
||||
Accepts the same inputs as ``yaml.safe_load`` (a ``str``/``bytes`` document or a readable file
|
||||
object) and returns the same parsed structure. Falls back to PyYAML's pure-Python ``SafeLoader``
|
||||
when ``CSafeLoader`` isn't available, so behavior is identical everywhere — only the speed
|
||||
differs.
|
||||
"""
|
||||
return yaml.load(stream, Loader=_get_fast_yaml_loader())
|
||||
return yaml.load(stream, Loader=_fast_yaml_loader)
|
||||
|
||||
|
||||
# ─── Environment Variable Helpers ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def env_int(key: str, default: int = 0) -> int:
|
||||
"""Read an environment variable as an integer, with fallback."""
|
||||
def _env_number(key: str, default, cast):
|
||||
raw = os.getenv(key, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
return cast(raw)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def env_int(key: str, default: int = 0) -> int:
|
||||
"""Read an environment variable as an integer, with fallback."""
|
||||
return _env_number(key, default, int)
|
||||
|
||||
|
||||
def env_float(key: str, default: float = 0.0) -> float:
|
||||
"""Read an environment variable as a float, with fallback."""
|
||||
raw = os.getenv(key, "").strip()
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
return float(raw)
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
return _env_number(key, default, float)
|
||||
|
||||
|
||||
def env_bool(key: str, default: bool = False) -> bool:
|
||||
@@ -840,16 +628,13 @@ _PROXY_ENV_KEYS = (
|
||||
def normalize_proxy_url(proxy_url: str | None) -> str | None:
|
||||
"""Normalize proxy URLs for httpx/aiohttp compatibility.
|
||||
|
||||
WSL/Clash-style environments often export SOCKS proxies as
|
||||
``socks://127.0.0.1:PORT``. httpx rejects that alias and expects the
|
||||
explicit ``socks5://`` scheme instead.
|
||||
WSL/Clash-style environments export SOCKS proxies as ``socks://host:port``; httpx rejects
|
||||
that alias and needs the explicit ``socks5://`` scheme.
|
||||
"""
|
||||
candidate = str(proxy_url or "").strip()
|
||||
if not candidate:
|
||||
return None
|
||||
if candidate.lower().startswith("socks://"):
|
||||
return f"socks5://{candidate[len('socks://'):]}"
|
||||
return candidate
|
||||
return candidate or None
|
||||
|
||||
|
||||
def normalize_proxy_env_vars() -> None:
|
||||
@@ -864,21 +649,23 @@ def normalize_proxy_env_vars() -> None:
|
||||
# ─── URL Parsing Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_base_url(base_url: str):
|
||||
"""``urlparse`` that tolerates a bare ``host[:port][/path]`` (no scheme)."""
|
||||
raw = (base_url or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
return urlparse(raw if "://" in raw else f"//{raw}")
|
||||
|
||||
|
||||
def base_url_hostname(base_url: str) -> str:
|
||||
"""Return the lowercased hostname for a base URL, or ``""`` if absent.
|
||||
|
||||
Use exact-hostname comparisons against known provider hosts
|
||||
(``api.openai.com``, ``api.x.ai``, ``api.anthropic.com``) instead of
|
||||
substring matches on the raw URL. Substring checks treat attacker- or
|
||||
proxy-controlled paths/hosts like ``https://api.openai.com.example/v1``
|
||||
or ``https://proxy.test/api.openai.com/v1`` as native endpoints, which
|
||||
leads to wrong api_mode / auth routing.
|
||||
Compare exact hostnames against known provider hosts instead of substring-matching the raw
|
||||
URL: substring checks treat ``https://api.openai.com.example/v1`` or
|
||||
``https://proxy.test/api.openai.com/v1`` as native endpoints, mis-routing api_mode and auth.
|
||||
"""
|
||||
raw = (base_url or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
parsed = urlparse(raw if "://" in raw else f"//{raw}")
|
||||
return (parsed.hostname or "").lower().rstrip(".")
|
||||
parsed = _parse_base_url(base_url)
|
||||
return (parsed.hostname or "").lower().rstrip(".") if parsed else ""
|
||||
|
||||
|
||||
# ─── Model Capability Detection ──────────────────────────────────────────────
|
||||
@@ -887,58 +674,27 @@ def base_url_hostname(base_url: str) -> str:
|
||||
def model_forces_max_completion_tokens(model: str) -> bool:
|
||||
"""Return True for model families that require ``max_completion_tokens``.
|
||||
|
||||
OpenAI's newer families reject ``max_tokens`` on /v1/chat/completions with
|
||||
HTTP 400 ``unsupported_parameter`` — the caller must send
|
||||
``max_completion_tokens`` instead. This covers:
|
||||
|
||||
- ``gpt-4o`` / ``gpt-4o-mini`` / ``gpt-4o-*``
|
||||
- ``gpt-4.1`` / ``gpt-4.1-*``
|
||||
- ``gpt-5`` / ``gpt-5.x`` / ``gpt-5-*``
|
||||
- ``o1`` / ``o1-*``
|
||||
- ``o3`` / ``o3-*``
|
||||
- ``o4`` / ``o4-*``
|
||||
|
||||
Handles vendor prefixes like ``openai/gpt-5.4`` by stripping to the tail.
|
||||
The URL-based check (``base_url_hostname == "api.openai.com"``) misses
|
||||
third-party OpenAI-compatible endpoints (custom OpenAI gateways,
|
||||
OpenRouter) that front these models and enforce the same parameter
|
||||
constraint, so name-based detection is required as a fallback.
|
||||
OpenAI's newer families reject ``max_tokens`` on /v1/chat/completions with HTTP 400
|
||||
``unsupported_parameter`` — the caller must send ``max_completion_tokens`` instead. This covers:
|
||||
"""
|
||||
m = (model or "").strip().lower()
|
||||
if not m:
|
||||
return False
|
||||
if "/" in m:
|
||||
m = m.rsplit("/", 1)[-1]
|
||||
return (
|
||||
m.startswith("gpt-4o")
|
||||
or m.startswith("gpt-4.1")
|
||||
or m.startswith("gpt-5")
|
||||
or m.startswith("o1")
|
||||
or m.startswith("o3")
|
||||
or m.startswith("o4")
|
||||
)
|
||||
m = (model or "").strip().lower().rsplit("/", 1)[-1]
|
||||
return m.startswith(("gpt-4o", "gpt-4.1", "gpt-5", "o1", "o3", "o4"))
|
||||
|
||||
|
||||
def base_url_origin(base_url: str) -> tuple[str, str, int]:
|
||||
"""Return ``(scheme, hostname, effective_port)`` for a base URL.
|
||||
|
||||
Origin, not just host. ``https://h/v1`` and ``http://h/v1`` are different
|
||||
trust boundaries, and so are two ports on the same host, so any decision
|
||||
about handing a bearer secret to a new URL has to compare all three —
|
||||
hostname equality alone would authorise an HTTPS→HTTP downgrade.
|
||||
|
||||
The port is normalised to the scheme default (443/80) when absent, so
|
||||
``https://h`` and ``https://h:443`` compare equal. Returns
|
||||
``("", "", 0)`` when the URL yields no usable hostname or a bad port.
|
||||
Origin, not just host: ``https://h`` vs ``http://h`` and two ports on one host are different
|
||||
trust boundaries, so any decision to hand a bearer secret to a new URL must compare all
|
||||
three — hostname alone would authorise an HTTPS→HTTP downgrade. The port defaults to 443/80
|
||||
when absent so ``https://h`` equals ``https://h:443``. Returns ``("", "", 0)`` on no
|
||||
hostname or a bad port.
|
||||
"""
|
||||
raw = (base_url or "").strip()
|
||||
if not raw:
|
||||
return ("", "", 0)
|
||||
parsed = urlparse(raw if "://" in raw else f"//{raw}")
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
hostname = (parsed.hostname or "").lower().rstrip(".")
|
||||
parsed = _parse_base_url(base_url)
|
||||
hostname = (parsed.hostname or "").lower().rstrip(".") if parsed else ""
|
||||
if not hostname:
|
||||
return ("", "", 0)
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
@@ -952,19 +708,10 @@ def base_url_origin(base_url: str) -> tuple[str, str, int]:
|
||||
def base_url_host_matches(base_url: str, domain: str) -> bool:
|
||||
"""Return True when the base URL's hostname is ``domain`` or a subdomain.
|
||||
|
||||
Safer counterpart to ``domain in base_url``, which is the substring
|
||||
false-positive class documented on ``base_url_hostname``. Accepts bare
|
||||
hosts, full URLs, and URLs with paths.
|
||||
|
||||
base_url_host_matches("https://api.moonshot.ai/v1", "moonshot.ai") == True
|
||||
base_url_host_matches("https://moonshot.ai", "moonshot.ai") == True
|
||||
base_url_host_matches("https://evil.com/moonshot.ai/v1", "moonshot.ai") == False
|
||||
base_url_host_matches("https://moonshot.ai.evil/v1", "moonshot.ai") == False
|
||||
Safer counterpart to ``domain in base_url``, which has the substring false-positive class
|
||||
noted on ``base_url_hostname`` (``evil.com/moonshot.ai`` or ``moonshot.ai.evil`` must not
|
||||
match). Accepts bare hosts, full URLs, and URLs with paths.
|
||||
"""
|
||||
hostname = base_url_hostname(base_url)
|
||||
if not hostname:
|
||||
return False
|
||||
domain = (domain or "").strip().lower().rstrip(".")
|
||||
if not domain:
|
||||
return False
|
||||
return hostname == domain or hostname.endswith("." + domain)
|
||||
return bool(hostname and domain) and (hostname == domain or hostname.endswith("." + domain))
|
||||
|
||||
Reference in New Issue
Block a user