Merge branch 'main' of github.com:NousResearch/hermes-agent into bb/gui

# Conflicts:
#	tui_gateway/server.py
This commit is contained in:
Brooklyn Nicholson
2026-05-30 13:19:27 -05:00
157 changed files with 10059 additions and 831 deletions
+26 -8
View File
@@ -496,12 +496,12 @@ class CDPSupervisor:
if not session_id:
return {"ok": False, "error": "supervisor has no attached page session"}
async def _do_eval() -> Dict[str, Any]:
async def _do_eval(by_value: bool) -> Dict[str, Any]:
return await self._cdp(
"Runtime.evaluate",
{
"expression": expression,
"returnByValue": return_by_value,
"returnByValue": by_value,
"awaitPromise": await_promise,
# userGesture matters for things like clipboard / fullscreen
# APIs that require a user-activation context.
@@ -511,14 +511,32 @@ class CDPSupervisor:
timeout=timeout,
)
try:
from agent.async_utils import safe_schedule_threadsafe
fut = safe_schedule_threadsafe(_do_eval(), loop)
from agent.async_utils import safe_schedule_threadsafe
def _run_eval(by_value: bool) -> Dict[str, Any]:
fut = safe_schedule_threadsafe(_do_eval(by_value), loop)
if fut is None:
return {"ok": False, "error": "Browser supervisor loop unavailable"}
response = fut.result(timeout=timeout + 1)
raise RuntimeError("Browser supervisor loop unavailable")
return fut.result(timeout=timeout + 1)
try:
response = _run_eval(return_by_value)
except Exception as exc:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
# ``returnByValue=True`` asks Chrome to deep-serialize the result.
# For live DOM nodes / NodeLists / Window that serialization can
# blow past CDP's recursion guard and fail the whole call with
# ``Object reference chain is too long`` (a protocol-level error,
# not a JS exception). Retry once with ``returnByValue=False`` so
# Chrome returns the object's description string instead — the same
# graceful degradation path used for ``document.querySelector(...)``
# results — rather than crashing the eval.
if return_by_value and "reference chain is too long" in str(exc).lower():
try:
response = _run_eval(False)
except Exception as exc2:
return {"ok": False, "error": f"{type(exc2).__name__}: {exc2}"}
else:
return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
# Runtime.evaluate response shape:
# {"id": N, "result": {"result": {"type": "...", "value": ..., ...},
+16
View File
@@ -2874,6 +2874,22 @@ def _browser_eval(expression: str, task_id: Optional[str] = None) -> str:
"error": f"JavaScript evaluation is not supported by this browser backend. {err}",
}
return json.dumps(_copy_fallback_warning(response, result))
# A live DOM node / NodeList / Window can't be JSON-serialized by CDP
# and fails the eval with "Object reference chain is too long". The
# supervisor fast path retries with returnByValue=false, but the CLI
# subprocess can't, so turn the cryptic protocol error into actionable
# guidance instead of surfacing it raw.
if "reference chain is too long" in err.lower():
response = {
"success": False,
"error": (
"Expression returned a live DOM node / NodeList / Window, "
"which can't be serialized. Extract a primitive value "
"(e.g. .innerText, .href, .src, .value) or use "
"JSON.stringify() / a snapshot tool instead."
),
}
return json.dumps(_copy_fallback_warning(response, result))
response = {
"success": False,
"error": err,
+173 -10
View File
@@ -113,6 +113,36 @@ def _normalize_line_endings(text: str, target: str) -> str:
return text
# UTF-8 byte order mark. Some Windows editors (Notepad, older Visual Studio,
# some PowerShell redirects) prepend this invisible 3-byte marker
# (EF BB BF == U+FEFF) to UTF-8 text files. It renders as nothing but is a
# real character at the start of the decoded string, so without handling it:
# - read_file would surface a stray U+FEFF as the first character (the
# model sees a phantom char before `import ...`), and
# - patch matches against the true first line would miss, and write_file
# would silently drop or double the marker on rewrite.
# We strip it on read so the model sees clean content, and restore it on
# write when the original file had one — exactly mirroring the line-ending
# preservation above (detect on disk, preserve across the edit).
_UTF8_BOM = "\ufeff"
def _strip_bom(text: str) -> tuple[str, bool]:
"""Return (text-without-leading-BOM, had_bom).
Only a single leading BOM is stripped; a BOM appearing mid-content is
left alone (it's legitimate data there, not a file marker).
"""
if text and text.startswith(_UTF8_BOM):
return text[len(_UTF8_BOM):], True
return text, False
def _has_bom(text: Optional[str]) -> bool:
"""True if ``text`` begins with a UTF-8 BOM."""
return bool(text) and text.startswith(_UTF8_BOM)
def _is_write_denied(path: str) -> bool:
"""Return True if path is on the write deny list."""
return _shared_is_write_denied(path)
@@ -672,8 +702,25 @@ class ShellFileOperations(FileOperations):
return ext in IMAGE_EXTENSIONS
def _add_line_numbers(self, content: str, start_line: int = 1) -> str:
"""Add line numbers to content in LINE_NUM|CONTENT format."""
"""Add line numbers to content in ``LINE_NUM|CONTENT`` format.
The gutter uses a compact ``<n>|`` prefix (e.g. ``34|foo``) rather
than a fixed-width zero/space-padded one (`` 34|foo``). The
padding was pure token overhead: on dense source the padded gutter
cost ~48% more tokens than the bare content and ~16% more than the
compact form, because the leading spaces + zero-padding tokenize
into extra tokens on every single line. An A/B (Sonnet 4.6, 2
passes) showed the compact gutter matches the padded gutter on
line-reference / patch / value-lookup / structure tasks (4/4 both),
while dropping line numbers entirely regressed line-referencing
(the model hand-counted and was off-by-one, 3/4) — so we keep the
numbers, just not the padding. ``HERMES_READ_GUTTER=padded``
restores the legacy fixed-width format for anyone who relied on
column alignment.
"""
import os as _os
from tools.tool_output_limits import get_max_line_length
padded = (_os.environ.get("HERMES_READ_GUTTER") or "").lower() == "padded"
max_line_length = get_max_line_length()
lines = content.split('\n')
numbered = []
@@ -681,7 +728,7 @@ class ShellFileOperations(FileOperations):
# Truncate long lines
if len(line) > max_line_length:
line = line[:max_line_length] + "... [truncated]"
numbered.append(f"{i:6d}|{line}")
numbered.append(f"{i:6d}|{line}" if padded else f"{i}|{line}")
return '\n'.join(numbered)
def _expand_path(self, path: str) -> str:
@@ -726,6 +773,60 @@ class ShellFileOperations(FileOperations):
# Use single quotes and escape any single quotes in the string
return "'" + arg.replace("'", "'\"'\"'") + "'"
def _atomic_write(self, path: str, content: str) -> "ExecuteResult":
"""Write ``content`` to ``path`` atomically via temp-file + rename.
Streams ``content`` over stdin into a temp file in the SAME
directory as ``path`` (so the final ``mv`` is a real rename on the
same filesystem, not a non-atomic cross-device copy), preserves the
existing file's mode if it exists, then renames over the target.
On any failure the temp file is removed so we never leak a partial
``.hermes-tmp`` file next to the user's data, and the original file
is left untouched. Content rides stdin so there is no ARG_MAX limit.
Returns an :class:`ExecuteResult`; ``exit_code == 0`` means the file
was swapped into place atomically. A non-zero exit means nothing was
renamed and the original (if any) is intact.
"""
q_path = self._escape_shell_arg(path)
parent = os.path.dirname(path) or "."
q_parent = self._escape_shell_arg(parent)
# template basename: hidden so it doesn't show up in casual `ls`,
# carries a marker so an orphaned temp (only possible on a hard
# crash *between* cat and mv) is identifiable.
tmpl = self._escape_shell_arg(".hermes-tmp.XXXXXX")
# One shell script, fully quoted. Notes:
# - `mktemp` lands the temp in the target's own dir (-p) so `mv` is
# same-FS atomic; we fall back to a PID-stamped name if the
# backend lacks mktemp (rare; busybox/macOS/Linux all ship it).
# - `chmod --reference` is GNU-only, so we read the octal mode with
# `stat` (GNU `-c%a` or BSD `-f%Lp`) and `chmod` it explicitly;
# silent best-effort — a perms-copy failure must not abort the
# write, the file still lands with default umask perms.
# - `trap ... EXIT` guarantees the temp is removed on every error
# path (cat failure, mv failure, signal) but NOT after a
# successful mv (the temp no longer exists by then).
# - we `cat >` the temp, then `mv -f` it over the target.
script = (
"set -e; "
f"d={q_parent}; t={q_path}; "
'tmp="$(mktemp -p "$d" ' + tmpl + ' 2>/dev/null '
'|| mktemp "$d/.hermes-tmp.$$.XXXXXX" 2>/dev/null '
'|| { tmp="$d/.hermes-tmp.$$"; : > "$tmp" && echo "$tmp"; })"; '
'[ -n "$tmp" ] || { echo "atomic write: could not create temp file" >&2; exit 1; }; '
"trap 'rm -f \"$tmp\"' EXIT; "
# preserve mode of an existing target (best-effort, never fatal)
'if [ -e "$t" ]; then '
'm="$(stat -c%a "$t" 2>/dev/null || stat -f%Lp "$t" 2>/dev/null || true)"; '
'[ -n "$m" ] && chmod "$m" "$tmp" 2>/dev/null || true; '
"fi; "
'cat > "$tmp"; '
'mv -f "$tmp" "$t"; '
"trap - EXIT"
)
return self._exec(script, stdin_data=content)
def _detect_file_line_ending(self, path: str, pre_content: Optional[str] = None) -> Optional[str]:
"""Detect the dominant line ending of a file on disk.
@@ -747,6 +848,22 @@ class ShellFileOperations(FileOperations):
return None
return _detect_line_ending(head_result.stdout)
def _file_has_bom(self, path: str, pre_content: Optional[str] = None) -> bool:
"""Whether the file on disk starts with a UTF-8 BOM.
Uses ``pre_content`` if we already read the file (zero extra exec
calls); otherwise issues a tiny ``head -c 3`` to sample just the
marker. A missing/empty file returns False (new writes get no BOM
unless the caller explicitly includes one).
"""
if pre_content is not None:
return _has_bom(pre_content)
head_cmd = f"head -c 3 {self._escape_shell_arg(path)} 2>/dev/null"
head_result = self._exec(head_cmd)
if head_result.exit_code != 0 or not head_result.stdout:
return False
return _has_bom(head_result.stdout)
def _unified_diff(self, old_content: str, new_content: str, filename: str) -> str:
"""Generate unified diff between old and new content."""
@@ -831,6 +948,11 @@ class ShellFileOperations(FileOperations):
if read_result.exit_code != 0:
return ReadResult(error=f"Failed to read file: {read_result.stdout}")
read_output = _strip_terminal_fence_leaks(read_result.stdout)
# Strip a leading UTF-8 BOM so the model never sees a phantom U+FEFF
# before the first real character. Only meaningful on the first
# chunk (the marker lives at byte 0); later pages can't carry it.
if offset == 1:
read_output, _ = _strip_bom(read_output)
# Get total line count
wc_cmd = f"wc -l < {self._escape_shell_arg(path)}"
@@ -935,8 +1057,14 @@ class ShellFileOperations(FileOperations):
cat_result = self._exec(f"cat {self._escape_shell_arg(path)}")
if cat_result.exit_code != 0:
return ReadResult(error=f"Failed to read file: {cat_result.stdout}")
# Strip a leading UTF-8 BOM so patch's fuzzy matcher operates on
# clean content (a phantom U+FEFF before line 1 would defeat an
# exact first-line match). write_file restores the BOM on the way
# back out — it re-probes the on-disk file, which still has the
# marker — so the round-trip preserves it.
raw_content, _ = _strip_bom(_strip_terminal_fence_leaks(cat_result.stdout))
return ReadResult(
content=_strip_terminal_fence_leaks(cat_result.stdout),
content=raw_content,
file_size=file_size,
)
@@ -1036,6 +1164,18 @@ class ShellFileOperations(FileOperations):
if original_ending == "\r\n":
content = _normalize_line_endings(content, "\r\n")
# ── BOM preservation ──────────────────────────────────────────
# If the file on disk started with a UTF-8 BOM, keep it. read_file
# strips the BOM so the agent never sees it, which means the
# content it hands back to write_file / patch has no BOM either —
# without restoring it here a round-trip would silently strip the
# marker and change the file's byte signature (some Windows
# toolchains key on it). Only prepend when the original had a BOM
# and the new content doesn't already carry one (guards against
# double-BOM if a caller passed raw bytes).
if self._file_has_bom(path, pre_content) and not _has_bom(content):
content = _UTF8_BOM + content
# Snapshot LSP diagnostics for this file (best-effort) so the
# post-write LSP layer can return only diagnostics introduced
# by this specific edit. Mirrors claude-code's
@@ -1053,10 +1193,22 @@ class ShellFileOperations(FileOperations):
if mkdir_result.exit_code == 0:
dirs_created = True
# Write via stdin pipe — content bypasses shell arg parsing entirely,
# so there's no ARG_MAX limit regardless of file size.
write_cmd = f"cat > {self._escape_shell_arg(path)}"
write_result = self._exec(write_cmd, stdin_data=content)
# Write atomically: stream into a temp file in the SAME directory,
# then ``mv`` it over the target. The rename is atomic on POSIX
# (and on every backend FS we run on), so a crash / power loss /
# truncated pipe mid-write leaves the original file intact instead
# of a half-written corrupt file. Same-directory is load-bearing —
# ``mv`` across filesystems degrades to copy+unlink, which is NOT
# atomic; keeping the temp beside the target guarantees a real
# rename. Content still rides stdin so there's no ARG_MAX limit.
#
# The temp file is created with ``mktemp`` (collision-safe) when the
# backend has it, falling back to a PID-stamped name otherwise. We
# then chmod the temp to match the existing file's mode (if any) so
# the atomic swap doesn't silently widen or narrow permissions, and
# clean the temp up on any failure so we never leak a ``.hermes-tmp``
# turd next to the user's file.
write_result = self._atomic_write(path, content)
if write_result.exit_code != 0:
return WriteResult(error=f"Failed to write file: {write_result.stdout}")
@@ -1127,7 +1279,13 @@ class ShellFileOperations(FileOperations):
return PatchResult(error=f"Failed to read file: {path}")
content = read_result.stdout
# Strip a leading UTF-8 BOM before matching so the fuzzy matcher and
# the diff operate on clean content (a phantom U+FEFF before line 1
# defeats an exact first-line match). write_file restores the BOM on
# the way back out by re-probing the on-disk file, so the round-trip
# preserves the marker.
content, _ = _strip_bom(content)
# Import and use fuzzy matching
from tools.fuzzy_match import fuzzy_find_and_replace
@@ -1176,8 +1334,13 @@ class ShellFileOperations(FileOperations):
# ``new_content`` string has bare LFs. Without this normalization
# every patch on Windows returns a bogus "wrote 39, read 42"
# false-negative even though the edit landed correctly. POSIX
# backends don't translate, so this is a no-op there.
_verify_stdout_normalized = verify_result.stdout.replace("\r\n", "\n").replace("\r", "\n")
# backends don't translate, so this is a no-op there. We also
# strip a leading BOM from the re-read: write_file restored the
# marker on disk but ``new_content`` is the BOM-less string we
# matched against, so the comparison must drop it to stay
# apples-to-apples.
_verify_bomless, _ = _strip_bom(verify_result.stdout)
_verify_stdout_normalized = _verify_bomless.replace("\r\n", "\n").replace("\r", "\n")
_new_content_normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
if _verify_stdout_normalized != _new_content_normalized:
return PatchResult(error=(
+103 -10
View File
@@ -116,15 +116,80 @@ def _get_live_tracking_cwd(task_id: str = "default") -> str | None:
return None
def _resolve_base_dir(task_id: str = "default") -> Path:
"""Return the ABSOLUTE base directory for resolving relative paths.
Resolution order:
1. The task's live terminal cwd (the directory the agent is actually
working in — e.g. a git worktree). Authoritative when known.
2. ``$TERMINAL_CWD`` from config/env.
3. The process cwd.
The returned base is ALWAYS absolute. This is the core invariant that
prevents the worktree-cwd divergence bug: a relative ``TERMINAL_CWD``
(commonly the literal ``"."`` from a stale config) is meaningless as a
resolution anchor — left to ``Path.resolve()`` it silently resolves
against whatever the agent PROCESS cwd happens to be (e.g. the main repo
while the terminal is in a worktree), routing edits to the wrong checkout.
Anchoring a relative base against the process cwd here makes the resolution
deterministic and inspectable rather than dependent on resolve()-time cwd.
"""
live = _get_live_tracking_cwd(task_id)
if live:
base = Path(live).expanduser()
else:
raw = os.environ.get("TERMINAL_CWD")
base = Path(raw).expanduser() if raw else Path(os.getcwd())
if not base.is_absolute():
# A relative base (".", "./sub", "..") is anchored to the process cwd
# once, here, so the result no longer depends on cwd at resolve() time.
base = Path(os.getcwd()) / base
return base.resolve()
def _resolve_path_for_task(filepath: str, task_id: str = "default") -> Path:
"""Resolve *filepath* against the task's live terminal cwd when possible."""
"""Resolve *filepath* against the task's absolute base directory.
See :func:`_resolve_base_dir` for how the base is chosen. Absolute input
paths are returned resolved-but-unanchored.
"""
p = Path(filepath).expanduser()
if not p.is_absolute():
base = _get_live_tracking_cwd(task_id) or os.environ.get(
"TERMINAL_CWD", os.getcwd()
)
p = Path(base) / p
return p.resolve()
if p.is_absolute():
return p.resolve()
return (_resolve_base_dir(task_id) / p).resolve()
def _path_resolution_warning(filepath: str, resolved: Path, task_id: str = "default") -> str | None:
"""Warn when a relative path resolved OUTSIDE the task's workspace root.
Surfaces the worktree-cwd divergence the moment it would matter: if the
agent passes a relative path but it resolves under a directory that is not
the live terminal cwd (i.e. the edit is about to land in a different
checkout than the one the agent is working in), return a message naming the
absolute target. ``None`` when the path is absolute, the base is unknown,
or the resolved path is correctly under the workspace root.
"""
try:
if Path(filepath).expanduser().is_absolute():
return None
live = _get_live_tracking_cwd(task_id)
if not live:
return None # No authoritative workspace root to compare against.
root = Path(live).expanduser().resolve()
# Is `resolved` inside `root`?
try:
resolved.relative_to(root)
return None # Inside the workspace — expected.
except ValueError:
return (
f"Relative path {filepath!r} resolved to {str(resolved)!r}, which is "
f"OUTSIDE the active workspace ({str(root)!r}). The edit will land in "
f"a different directory than the terminal's cwd. If this is not "
f"intended (e.g. a git-worktree session writing into the main "
f"checkout), pass an absolute path under the workspace instead."
)
except Exception:
return None
def _is_blocked_device_path(path: str) -> bool:
@@ -930,12 +995,21 @@ def write_file_tool(path: str, content: str, task_id: str = "default",
# fire — its message names the sibling subagent.
cross_warning = file_state.check_stale(task_id, _resolved)
stale_warning = _check_file_staleness(path, task_id)
# Workspace-divergence warning: relative path resolving outside the
# terminal's cwd (the worktree-cwd bug). Lowest priority of the three.
cwd_warning = _path_resolution_warning(path, Path(_resolved), task_id)
file_ops = _get_file_ops(task_id)
result = file_ops.write_file(path, content)
result = file_ops.write_file(_resolved, content)
result_dict = result.to_dict()
effective_warning = cross_warning or stale_warning
effective_warning = cross_warning or stale_warning or cwd_warning
if effective_warning:
result_dict["_warning"] = effective_warning
# Always report the ABSOLUTE path actually written, so a wrong-cwd
# mismatch is visible in the response instead of silently routing
# the edit to the wrong checkout.
result_dict["resolved_path"] = _resolved
if not result_dict.get("error"):
result_dict["files_modified"] = [_resolved]
# Refresh stamps after the successful write so consecutive
# writes by this task don't trigger false staleness warnings.
_update_read_timestamp(path, task_id)
@@ -1027,6 +1101,10 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
_path_to_resolved[_p] = _r
_cross = file_state.check_stale(task_id, _r) if _r else None
_sw = _cross or _check_file_staleness(_p, task_id)
if not _sw and _r:
# Workspace-divergence warning (worktree-cwd bug): relative
# path resolving outside the terminal's cwd.
_sw = _path_resolution_warning(_p, Path(_r), task_id)
if _sw:
stale_warnings.append(_sw)
@@ -1037,7 +1115,13 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
return tool_error("path required")
if old_string is None or new_string is None:
return tool_error("old_string and new_string required")
result = file_ops.patch_replace(path, old_string, new_string, replace_all)
# Pass the resolved ABSOLUTE path to the shell layer so it
# operates on the exact file the tool layer resolved — the
# shell's own cwd may differ (worktree-cwd bug), and a relative
# path would let the two layers disagree about which file is
# being edited.
_replace_target = _path_to_resolved.get(path) or path
result = file_ops.patch_replace(_replace_target, old_string, new_string, replace_all)
elif mode == "patch":
if not patch:
return tool_error("patch content required")
@@ -1048,9 +1132,18 @@ def patch_tool(mode: str = "replace", path: str = None, old_string: str = None,
result_dict = result.to_dict()
if stale_warnings:
result_dict["_warning"] = stale_warnings[0] if len(stale_warnings) == 1 else " | ".join(stale_warnings)
# Report the ABSOLUTE path(s) actually patched so a wrong-cwd
# mismatch (e.g. a worktree session editing the main checkout) is
# visible in the response instead of silently landing elsewhere.
_resolved_modified = [
_path_to_resolved.get(_p) or _p for _p in _paths_to_check
]
# Refresh stored timestamps for all successfully-patched paths so
# consecutive edits by this task don't trigger false warnings.
if not result_dict.get("error"):
result_dict["files_modified"] = _resolved_modified
if len(_resolved_modified) == 1:
result_dict["resolved_path"] = _resolved_modified[0]
for _p in _paths_to_check:
_update_read_timestamp(_p, task_id)
_r = _path_to_resolved.get(_p)
+1
View File
@@ -173,6 +173,7 @@ LAZY_DEPS: dict[str, tuple[str, ...]] = {
"tool.dashboard": (
"fastapi==0.133.1",
"uvicorn[standard]==0.41.0",
"starlette==1.0.1", # CVE-2026-48710 (BadHost) — keep lazy-install in sync with pyproject [web]
),
}
+30 -5
View File
@@ -72,15 +72,34 @@ def _access_token_is_expiring(expires_at: object, skew_seconds: int) -> bool:
return remaining <= max(0, int(skew_seconds))
def read_nous_access_token() -> Optional[str]:
"""Read a Nous Subscriber OAuth access token from auth store or env override."""
def peek_nous_access_token() -> Optional[str]:
"""Cheap probe for a Nous gateway token without triggering refresh.
Availability scans (`hermes tools`, banner/status paint, provider
`is_available()` checks) must stay off the synchronous OAuth refresh path.
This helper therefore only inspects the explicit env override and the
cached auth-store token, without checking expiry and without making any
network calls. Truthful refresh handling stays in request/session paths
that call :func:`read_nous_access_token`.
"""
explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN")
if isinstance(explicit, str) and explicit.strip():
return explicit.strip()
nous_provider = _read_nous_provider_state() or {}
access_token = nous_provider.get("access_token")
cached_token = access_token.strip() if isinstance(access_token, str) and access_token.strip() else None
if isinstance(access_token, str) and access_token.strip():
return access_token.strip()
return None
def read_nous_access_token() -> Optional[str]:
"""Read a Nous Subscriber OAuth access token from auth store or env override."""
explicit = os.getenv("TOOL_GATEWAY_USER_TOKEN")
if isinstance(explicit, str) and explicit.strip():
return explicit.strip()
nous_provider = _read_nous_provider_state() or {}
cached_token = peek_nous_access_token()
if cached_token and not _access_token_is_expiring(
nous_provider.get("expires_at"),
@@ -159,9 +178,15 @@ def is_managed_tool_gateway_ready(
gateway_builder: Optional[Callable[[str], str]] = None,
token_reader: Optional[Callable[[], Optional[str]]] = None,
) -> bool:
"""Return True when gateway URL and Nous access token are available."""
"""Return True when gateway URL and a likely-usable Nous token are present.
Defaults to :func:`peek_nous_access_token` so read-only availability scans
avoid synchronous OAuth refresh. Callers that are about to make a real
gateway request should use :func:`resolve_managed_tool_gateway` (which
still defaults to the refresh-aware :func:`read_nous_access_token`).
"""
return resolve_managed_tool_gateway(
vendor,
gateway_builder=gateway_builder,
token_reader=token_reader,
token_reader=token_reader or peek_nous_access_token,
) is not None
+84 -16
View File
@@ -1395,9 +1395,22 @@ class MCPServerTask:
# Capture the newly spawned subprocess PID for force-kill cleanup.
new_pids = _snapshot_child_pids() - pids_before
if new_pids:
# Capture pgid while the child is alive — once it exits we
# can no longer call ``os.getpgid`` on it, and the cleanup
# sweep needs the pgid to reach any reparented descendants
# (e.g. ``claude mcp serve`` spawned by a stdio wrapper).
new_pgids: Dict[int, int] = {}
for _pid in new_pids:
try:
new_pgids[_pid] = os.getpgid(_pid)
except (AttributeError, ProcessLookupError, OSError):
# AttributeError: Windows (os.getpgid is POSIX-only)
# ProcessLookupError: child raced and already exited
pass
with _lock:
for _pid in new_pids:
_stdio_pids[_pid] = self.name
_stdio_pgids.update(new_pgids)
async with ClientSession(
read_stream, write_stream, **sampling_kwargs
) as session:
@@ -1416,16 +1429,33 @@ class MCPServerTask:
# on Linux, where setsid() children escape the parent cgroup).
# Mark them as orphans so the next cleanup sweep can reap them.
if new_pids:
from gateway.status import _pid_exists
_killpg = getattr(os, "killpg", None)
with _lock:
for _pid in new_pids:
_stdio_pids.pop(_pid, None)
for pid in new_pids:
# ``os.kill(pid, 0)`` is NOT a no-op on Windows
# (bpo-14484). Use the cross-platform check.
from gateway.status import _pid_exists
if not _pid_exists(pid):
continue # process already exited — nothing to do
_orphan_stdio_pids.add(pid)
pid_alive = _pid_exists(pid)
pgroup_alive = False
pgid = _stdio_pgids.get(pid)
if not pid_alive and pgid is not None and _killpg is not None:
# Direct child exited but descendants may still be
# in its pgroup (e.g. ``claude mcp serve`` spawned
# by an MCP wrapper that exited first). Probe with
# signal 0 — succeeds iff any pgroup member is alive.
try:
_killpg(pgid, 0)
pgroup_alive = True
except (ProcessLookupError, PermissionError, OSError):
pgroup_alive = False
if pid_alive or pgroup_alive:
_orphan_stdio_pids.add(pid)
else:
# Nothing left to reap — drop the pgid entry so
# PID-reuse can't surface stale pgroup state later.
_stdio_pgids.pop(pid, None)
async def _run_http(self, config: dict):
"""Run the server using HTTP/StreamableHTTP transport."""
@@ -2224,6 +2254,19 @@ _stdio_pids: Dict[int, str] = {} # pid -> server_name
# sessions (e.g. concurrent cron jobs or live user chats).
_orphan_stdio_pids: set = set()
# Process-group IDs of stdio MCP subprocesses, captured at spawn time.
# The MCP SDK spawns stdio children with ``start_new_session=True`` so each
# direct child becomes its own session/pgroup leader (PGID == its own PID).
# Grandchildren spawned by that child (e.g. a wrapper MCP server that itself
# launches helper subprocesses like ``claude mcp serve``) inherit that PGID
# unless they call ``setsid`` themselves. When the direct child exits, those
# grandchildren reparent to init/systemd-user but keep the original PGID, so
# ``killpg(pgid, sig)`` still reaches them. Tracked separately from
# ``_stdio_pids`` so we retain the PGID even after the direct child has
# exited and been removed from the active map. Empty on Windows
# (``os.getpgid`` is POSIX-only).
_stdio_pgids: Dict[int, int] = {} # pid -> pgid
def _snapshot_child_pids() -> set:
"""Return a set of current child process PIDs.
@@ -3640,6 +3683,12 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None:
survivors, avoiding shared-resource collisions when multiple hermes
processes run on the same host (each has its own ``_stdio_pids`` dict).
On POSIX, signals are sent via ``os.killpg`` to the spawn-time pgid when
one is tracked, so reparented grandchildren in the same process group
(e.g. ``claude mcp serve`` spawned by a stdio MCP wrapper that exited
first) are reaped alongside the direct child. Falls back to ``os.kill``
on Windows and when no pgid is recorded.
With ``include_active=True`` also kills every PID in ``_stdio_pids`` —
used only at final shutdown, after the MCP event loop has stopped and no
sessions can still be in flight.
@@ -3654,20 +3703,42 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None:
if include_active:
pids.update(dict(_stdio_pids))
_stdio_pids.clear()
# Snapshot pgids for the pids we're about to kill, then drop the
# entries so a future spawn can't collide with stale state.
pgids: Dict[int, int] = {pid: _stdio_pgids[pid] for pid in pids if pid in _stdio_pgids}
for pid in pgids:
_stdio_pgids.pop(pid, None)
# Fast path: no tracked stdio PIDs to reap. Skip the SIGTERM/sleep/SIGKILL
# dance entirely — otherwise every MCP-free shutdown pays a 2s sleep tax.
if not pids:
return
# Phase 1: SIGTERM (graceful)
for pid, server_name in pids.items():
def _send_signal(pid: int, sig: int, server_name: str) -> None:
"""SIGTERM/SIGKILL via pgroup on POSIX, fall back to pid signal."""
pgid = pgids.get(pid)
killpg = getattr(os, "killpg", None)
if pgid is not None and killpg is not None:
try:
killpg(pgid, sig)
return
except (ProcessLookupError, PermissionError, OSError) as exc:
# Pgroup gone (all members exited) or refused — fall back to
# the per-pid path so we still try the direct child if alive.
logger.debug(
"killpg(%d, %d) failed for MCP server '%s': %s; falling back to kill(pid)",
pgid, sig, server_name, exc,
)
try:
os.kill(pid, _signal.SIGTERM)
logger.debug("Sent SIGTERM to orphaned MCP process %d (%s)", pid, server_name)
os.kill(pid, sig)
except (ProcessLookupError, PermissionError, OSError):
pass
# Phase 1: SIGTERM (graceful)
for pid, server_name in pids.items():
_send_signal(pid, _signal.SIGTERM, server_name)
logger.debug("Sent SIGTERM to orphaned MCP process %d (%s)", pid, server_name)
# Phase 2: Wait for graceful exit
time.sleep(2)
@@ -3679,14 +3750,11 @@ def _kill_orphaned_mcp_children(include_active: bool = False) -> None:
for pid, server_name in pids.items():
if not _pid_exists(pid):
continue # Good — exited after SIGTERM
try:
os.kill(pid, _sigkill)
logger.warning(
"Force-killed MCP process %d (%s) after SIGTERM timeout",
pid, server_name,
)
except (ProcessLookupError, PermissionError, OSError):
pass
_send_signal(pid, _sigkill, server_name)
logger.warning(
"Force-killed MCP process %d (%s) after SIGTERM timeout",
pid, server_name,
)
def _stop_mcp_loop():
+17 -1
View File
@@ -40,6 +40,15 @@ _NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE
# downstream adapters (signal, etc.) expect.
_PHONE_PLATFORMS = frozenset({"signal", "sms", "whatsapp"})
_E164_TARGET_RE = re.compile(r"^\s*\+(\d{7,15})\s*$")
# Email addresses — a valid email like "user@domain.com" should be treated as
# an explicit target for the email platform, not fall through to channel-name
# resolution which has no way to resolve a raw address.
_EMAIL_TARGET_RE = re.compile(r"^\s*[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\s*$")
# Most platforms read their home channel from "<PLATFORM>_HOME_CHANNEL", but a
# few diverge. Email reads EMAIL_HOME_ADDRESS (see gateway/config.py), so the
# generic "<PLATFORM>_HOME_CHANNEL" hint would point users at a variable that is
# never read. Map the exceptions so the error guidance is actually actionable.
_HOME_CHANNEL_ENV_OVERRIDES = {"email": "EMAIL_HOME_ADDRESS"}
_IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
_VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"}
_AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a", ".flac"}
@@ -265,10 +274,13 @@ def _handle_send(args):
chat_id = home.chat_id
used_home_channel = True
else:
home_env = _HOME_CHANNEL_ENV_OVERRIDES.get(
platform_name, f"{platform_name.upper()}_HOME_CHANNEL"
)
return json.dumps({
"error": f"No home channel set for {platform_name} to determine where to send the message. "
f"Either specify a channel directly with '{platform_name}:CHANNEL_NAME', "
f"or set a home channel via: hermes config set {platform_name.upper()}_HOME_CHANNEL <channel_id>"
f"or set a home channel via: hermes config set {home_env} <channel_id>"
})
duplicate_skip = _maybe_skip_cron_duplicate_send(platform_name, chat_id, thread_id)
@@ -383,6 +395,10 @@ def _parse_target_ref(platform_name: str, target_ref: str):
if target_ref.strip().isdigit():
return f"group:{target_ref.strip()}", None, True
return None, None, False
if platform_name == "email":
match = _EMAIL_TARGET_RE.fullmatch(target_ref)
if match:
return target_ref.strip(), None, True
if platform_name in _PHONE_PLATFORMS:
match = _E164_TARGET_RE.fullmatch(target_ref)
if match:
+41 -13
View File
@@ -517,7 +517,10 @@ def sync_skills(quiet: bool = False) -> dict:
if not quiet:
print(f"{skill_name} (updated)")
# Remove backup after successful copy
shutil.rmtree(backup, ignore_errors=True)
try:
_rmtree_writable(backup)
except (OSError, IOError):
logger.debug("Could not remove backup %s", backup, exc_info=True)
except (OSError, IOError):
# Restore from backup
if backup.exists() and not dest.exists():
@@ -563,6 +566,30 @@ def sync_skills(quiet: bool = False) -> dict:
}
def _rmtree_writable(path: Path) -> None:
"""Remove a directory tree, making read-only entries writable first.
Handles immutable package sources (Nix store, deb/rpm installs) that
preserve read-only permissions on copied files *and* directories
(``r-xr-xr-x``). Removing a child requires write permission on its
parent directory, so the retry handler makes the failing path **and its
parent** writable before re-attempting. See #34860, #34972.
"""
import stat
def _on_error(func, fpath, exc_info):
# Unlinking a child requires the parent dir to be writable, so chmod
# the parent as well as the failing path, then retry.
for target in (os.path.dirname(fpath), fpath):
try:
os.chmod(target, stat.S_IRWXU)
except OSError:
pass
func(fpath)
shutil.rmtree(path, onerror=_on_error)
def reset_bundled_skill(name: str, restore: bool = False) -> dict:
"""
Reset a bundled skill's manifest tracking so future syncs work normally.
@@ -606,12 +633,9 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:
"synced": None,
}
# Step 1: drop the manifest entry so next sync treats it as new
if in_manifest:
del manifest[name]
_write_manifest(manifest)
# Step 2 (optional): delete the user's copy so next sync re-copies bundled
# Step 1 (optional): delete the user's copy so next sync re-copies bundled.
# Must happen BEFORE manifest deletion so that a failed rmtree does not
# leave the skill in a manifest-less limbo state (see #34972).
deleted_user_copy = False
if restore:
if not is_bundled:
@@ -619,28 +643,32 @@ def reset_bundled_skill(name: str, restore: bool = False) -> dict:
"ok": False,
"action": "bundled_missing",
"message": (
f"'{name}' has no bundled source — manifest entry cleared "
f"'{name}' has no bundled source — manifest entry preserved "
f"but cannot restore from bundled (skill was removed upstream)."
),
"synced": None,
}
# The destination mirrors the bundled path relative to bundled_dir.
dest = _compute_relative_dest(bundled_by_name[name], bundled_dir)
if dest.exists():
try:
shutil.rmtree(dest)
_rmtree_writable(dest)
deleted_user_copy = True
except (OSError, IOError) as e:
return {
"ok": False,
"action": "manifest_cleared",
"action": "not_reset",
"message": (
f"Cleared manifest entry for '{name}' but could not "
f"delete user copy at {dest}: {e}"
f"Could not delete user copy at {dest}: {e}. "
f"Manifest entry preserved — nothing was changed."
),
"synced": None,
}
# Step 2: drop the manifest entry so next sync treats it as new
if in_manifest:
del manifest[name]
_write_manifest(manifest)
# Step 3: run sync to re-baseline (or re-copy if we deleted)
synced = sync_skills(quiet=True)
+46 -14
View File
@@ -127,6 +127,30 @@ def _detect_image_mime_type(image_path: Path) -> Optional[str]:
return None
def _is_retryable_download_error(error: Exception) -> bool:
"""Return True only for transient image-download failures worth retrying.
Non-retryable (fail-fast):
- httpx.HTTPStatusError with a 4xx status other than 429 (404/403/410/...):
the resource is missing or forbidden; retrying can't change that.
- PermissionError: blocked by website policy / SSRF guard.
- ValueError: image too large or blocked redirect deterministic.
Retryable (transient):
- httpx 429 (rate limited) and 5xx (server-side) errors.
- Connection/timeout/transport errors (httpx.TransportError) and any
other unclassified exception, which may be a flaky network blip.
"""
if isinstance(error, (PermissionError, ValueError)):
return False
if isinstance(error, httpx.HTTPStatusError):
status = error.response.status_code
if 400 <= status < 500 and status != 429:
return False
return True
return True
async def _download_image(image_url: str, destination: Path, max_retries: int = 3) -> Path:
"""
Download an image from a URL to a local destination (async) with retry logic.
@@ -210,24 +234,32 @@ async def _download_image(image_url: str, destination: Path, max_retries: int =
return destination
except Exception as e:
last_error = e
if attempt < max_retries - 1:
wait_time = 2 ** (attempt + 1) # 2s, 4s, 8s
logger.warning("Image download failed (attempt %s/%s): %s", attempt + 1, max_retries, str(e)[:50])
logger.warning("Retrying in %ss...", wait_time)
await asyncio.sleep(wait_time)
else:
# Error-class-aware retry: only retry transient failures. A 4xx
# client error (404/403/410, etc.) will never succeed on retry —
# the resource isn't there or we're not allowed — so burning 3
# attempts with 2s/4s/8s backoff just inflates latency. 429 (rate
# limit) and 5xx remain retryable. PermissionError (policy block)
# and ValueError (too-large / SSRF redirect) are also terminal.
if not _is_retryable_download_error(e) or attempt >= max_retries - 1:
logger.error(
"Image download failed after %s attempts: %s",
max_retries,
"Image download failed after %s attempt(s): %s",
attempt + 1,
str(e)[:100],
exc_info=True,
)
if last_error is None:
raise RuntimeError(
f"_download_image exited retry loop without attempting (max_retries={max_retries})"
)
raise last_error
raise
wait_time = 2 ** (attempt + 1) # 2s, 4s, 8s
logger.warning("Image download failed (attempt %s/%s): %s", attempt + 1, max_retries, str(e)[:50])
logger.warning("Retrying in %ss...", wait_time)
await asyncio.sleep(wait_time)
# The loop always returns on success or re-raises on the final/non-retryable
# attempt, so reaching here means max_retries was non-positive.
if last_error is not None:
raise last_error
raise RuntimeError(
f"_download_image exited retry loop without attempting (max_retries={max_retries})"
)
def _determine_mime_type(image_path: Path) -> str:
+1
View File
@@ -93,6 +93,7 @@ from tools.debug_helpers import DebugSession
# tools.web_tools (the firecrawl plugin reads them via its own import chain).
from tools.managed_tool_gateway import ( # noqa: F401 — backward-compat names for tests
build_vendor_gateway_url,
peek_nous_access_token as _peek_nous_access_token,
read_nous_access_token as _read_nous_access_token,
resolve_managed_tool_gateway,
)