fix(pm): integrate audited runtime and lifecycle repairs

Prepare dependency generations before selecting them. Keep shipped tool
bytes separate from writable additions, and store facts beside their entries.
Validate proposed plugin sets before config publication. Restore the previous
config if the facts write fails.

Consolidate duplicate updater, backup, setup, and voice helpers. Repair
launcher selection, dependency consumers, download ownership, update feeds,
and native Windows process and file handling.

Verification: 206 changed/prior-failing Python files reported 4630 passed,
one failed, and 330 skipped. Fix the remaining Hindsight fixture boundary.
The final targeted rerun reported 234 passed and two skipped. The store
review regression batch reported 83 passed and one skipped. Desktop
TypeScript checks, 56 selected Electron tests, 24 release tests, and the
removed-import/compatibility guards passed.

This is an integration checkpoint, not full audit acceptance. The complete
Python suite has not run on this fixed tree. Crash-atomic plugin publication,
generation cleanup, receipt correlation, and packaged lifecycle acceptance
remain open in docs/pm-audit-status.md.
This commit is contained in:
ethernet committed 2026-09-05 22:36:48 -04:00
1 parent 49945b1402
commit 92686159d1
362 files changed
+12928 -22396

No files matched your search

+6
View File
@@ -197,6 +197,11 @@ jobs:
needs: detect
uses: ./.github/workflows/case-collision-check.yml
lazy-deps-guard:
name: No imports of deleted tools.lazy_deps
needs: detect
uses: ./.github/workflows/lazy-deps-guard.yml
lockfile-diff:
name: package-lock.json diff
needs: detect
@@ -261,6 +266,7 @@ jobs:
- contributor-check
- uv-lockfile
- case-collision-check
- lazy-deps-guard
- lockfile-diff
- docker-lint
- profile-artifact-check
+36
View File
@@ -0,0 +1,36 @@
name: Lazy-deps import guard
# Rejects any tracked production code that imports tools/lazy_deps.py,
# which was deleted by the pm migration (pm.extras — available /
# ensure_import / ensure_and_bind — is the only lazy-install surface). A
# remaining import is an ImportError at call time.
#
# Runs unconditionally (no change-classifier gate): the deleted module
# must stay unimported on every change, in any kind of PR — the same
# "passive rule that cannot enforce a policy" reasoning as the
# case-collision check. Not folded into lint.yml because the lint lane is
# language-gated by the orchestrator's detect outputs.
#
# The checker builds the tracked inventory (git ls-files), does AST import
# analysis (comments/prose ignored by construction), and FAILS on
# inventory errors — it can never pass by scanning nothing. Exit 1 lists
# the exact offender sites; their migration is owned by the lazy-deps
# owner, not this gate. Contract tests (fixture repos prove red/green):
# tests/pm/test_no_lazy_deps.py.
on:
workflow_call:
permissions:
contents: read
jobs:
lazy-deps-guard:
name: No imports of deleted tools.lazy_deps
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check tools.lazy_deps imports in tracked production code
run: python3 scripts/ci/check_lazy_deps_imports.py
+8 -1
View File
@@ -76,6 +76,9 @@ def _path_from_file_uri(uri: str) -> Path | None:
if not raw:
return None
# urlparse treats a bare Windows drive as a URI scheme.
if len(raw) >= 3 and raw[0].isalpha() and raw[1] == ":" and raw[2] in "/\\":
raw = "file:///" + raw.replace("\\", "/")
parsed = urlparse(raw)
if parsed.scheme and parsed.scheme != "file":
return None
@@ -91,7 +94,11 @@ def _path_from_file_uri(uri: str) -> Path | None:
drive, rest = path_text[0], path_text[2:]
else:
return Path(path_text)
return Path("/mnt") / drive.lower() / rest.lstrip("/\\").replace("\\", "/")
import os
rest = rest.lstrip("/\\").replace("\\", "/")
if os.name == "nt":
return Path(f"{drive}:/{rest}")
return Path("/mnt") / drive.lower() / rest
def _decode_text_bytes(data: bytes, mime_type: str | None) -> str | None:
+1 -1
View File
@@ -59,7 +59,7 @@ def reset_edit_approval_requester(token: Token) -> None:
def _read_text_if_exists(path: str) -> str | None:
p = Path(path).expanduser()
if p.is_file():
return p.read_text(encoding=utf-8-sig, errors="replace")
return p.read_text(encoding="utf-8-sig", errors="replace")
if p.exists():
raise OSError(f"Cannot edit non-file path: {path}")
return None
+13 -73
View File
@@ -20,89 +20,29 @@ fi
_HERMES_REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# --- target detection (same rules as pm.sh: msys on Windows must report the
# machine's truth from the registry, not the emulated shell's) ---
_hermes_os="$(uname -s)"
case "$_hermes_os" in
Linux) _hermes_os=linux ;;
Darwin) _hermes_os=darwin ;;
MINGW*|MSYS*|CYGWIN*) _hermes_os=win32 ;;
*) echo "activate: unsupported OS $(uname -s)" >&2; return 1 2>/dev/null || exit 1 ;;
esac
if [ "$_hermes_os" = win32 ]; then
_hermes_winarch="$(reg.exe query 'HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment' /v PROCESSOR_ARCHITECTURE 2>/dev/null | tr -d '
' | awk '/PROCESSOR_ARCHITECTURE/ {print $NF}')"
# PROCESSOR_ARCHITECTURE reports the EMULATED arch inside an x64-on-
# arm64 shell, and uname -m does too. The registry is the machine's
# truth; when reg.exe is unreachable, uname -s's release token (e.g.
# MINGW64_NT-10.0-28000-ARM64) still names the real machine arch.
if [ -z "$_hermes_winarch" ]; then
case "$(uname -s)" in
*ARM64) _hermes_winarch=ARM64 ;;
*AMD64) _hermes_winarch=AMD64 ;;
esac
fi
case "${_hermes_winarch:-}" in
ARM64) _hermes_arch=arm64 ;;
AMD64) _hermes_arch=x64 ;;
*) : ;; # probe unavailable — the store probe below decides
esac
fi
if [ -z "${_hermes_arch:-}" ] && [ "$_hermes_os" != win32 ]; then
case "$(uname -m)" in
arm64|aarch64) _hermes_arch=arm64 ;;
x86_64|amd64) _hermes_arch=x64 ;;
*) : ;; # unknown — the store probe below decides
esac
fi
# Resolve the pm store root here so the arch probe below can consult it.
_hermes_store="${HERMES_RUNTIME_DIR:-$HOME/.hermes/tools}"
if [ -n "${_hermes_arch:-}" ]; then
_hermes_target="$_hermes_os-$_hermes_arch"
else
# Registry + env both unavailable (blanked PATH): ask the store which
# target it actually holds instead of guessing an arch. First
# python-<ver>-<os>-<arch> entry wins.
_hermes_target=""
for _hermes_entry in "$_hermes_store"/python-*; do
[ -d "$_hermes_entry" ] || continue
case "${_hermes_entry##*/}" in
python-*-linux-*|python-*-darwin-*|python-*-win32-*)
_hermes_target="${_hermes_entry##*python-}"
_hermes_target="${_hermes_target#*-}"
break
;;
esac
done
if [ -z "$_hermes_target" ]; then
echo "activate: cannot determine target arch and the store holds no python entry — run ./setup-hermes.sh first" >&2
unset _hermes_repo _hermes_os _hermes_arch _hermes_target _hermes_store _hermes_py
return 1 2>/dev/null || exit 1
fi
fi
# Bootstrap Python only emits the environment; it does not install anything.
_hermes_repo="$_HERMES_REPO"
_hermes_py=""
for _hermes_entry in "$_hermes_store"/python-*-"$_hermes_target"; do
if [ -x "$_hermes_entry/bin/python" ]; then
_hermes_py="$_hermes_entry/bin/python"
elif [ -x "$_hermes_entry/bin/python.exe" ]; then
_hermes_py="$_hermes_entry/bin/python.exe"
fi
[ -n "$_hermes_py" ] && break
for _hermes_candidate in "$_hermes_repo/.venv/bin/python" "$_hermes_repo/.venv/Scripts/python.exe" \
"$_hermes_repo/venv/bin/python" "$_hermes_repo/venv/Scripts/python.exe"; do
[ -x "$_hermes_candidate" ] && { _hermes_py="$_hermes_candidate"; break; }
done
if [ -z "$_hermes_py" ]; then
for _hermes_venvpy in "$_hermes_repo/venv/bin/python" "$_hermes_repo/venv/bin/python.exe" \
"$_hermes_repo/venv/Scripts/python.exe"; do
[ -x "$_hermes_venvpy" ] && { _hermes_py="$_hermes_venvpy"; break; }
for _hermes_store in "${HERMES_RUNTIME_DIR:-}" "$_hermes_repo/../tools" "${HERMES_HOME:-$HOME/.hermes}/tools"; do
[ -n "$_hermes_store" ] || continue
for _hermes_candidate in "$_hermes_store"/python-*/bin/python3 "$_hermes_store"/python-*/python.exe "$_hermes_store"/python-*/bin/python "$_hermes_store"/python-*/bin/python.exe; do
[ -x "$_hermes_candidate" ] && { _hermes_py="$_hermes_candidate"; break; }
done
[ -n "$_hermes_py" ] && break
done
fi
if [ -z "$_hermes_py" ]; then
echo "activate: no pm python found — run ./setup-hermes.sh first" >&2
unset _hermes_repo _hermes_os _hermes_arch _hermes_target _hermes_store _hermes_py
echo "activate: no bootstrap Python found; run setup-hermes.sh" >&2
return 1 2>/dev/null || exit 1
fi
# --- emit export lines from `pm env` ---
_hermes_json="$(PYTHONPATH="$_hermes_repo${PYTHONPATH:+:$PYTHONPATH}" "$_hermes_py" -m pm.cli env 2>/dev/null)"
_hermes_json="$(PYTHONHOME= PYTHONPATH="$_hermes_repo" "$_hermes_py" -m hermes_cli.runtime_paths)"
if [ -z "$_hermes_json" ] || [ "${_hermes_json#*{}" = "$_hermes_json" ]; then
echo "activate: could not read pm env (run ./setup-hermes.sh first)" >&2
unset _hermes_repo _hermes_os _hermes_arch _hermes_target _hermes_store _hermes_py _hermes_json
+35 -55
View File
@@ -1,73 +1,53 @@
# ============================================================================
# venv-style activation for the Hermes dev environment (pm-managed tools).
#
# .\activate.ps1 (repo root; if script execution is disabled:
# powershell -ExecutionPolicy Bypass -File .\activate.ps1, or set
# Set-ExecutionPolicy RemoteSigned -Scope CurrentUser once)
#
# Emits the composed pm env (PATH + tool vars) into the CURRENT session, with
# save/restore: `deactivate` undoes exactly what activation changed.
#
# Requires a completed .\setup-hermes.ps1 — it never invokes uv. It runs the
# pm store's pinned python (fallback: the repo venv) to emit the env JSON.
# ============================================================================
# Source this file to apply the installed PM environment; deactivate restores it.
$ErrorActionPreference = 'Stop'
# Guard against double-sourcing: re-activating deactivates first.
if (Test-Path function:deactivate) { deactivate }
$repo = $PSScriptRoot
$machineArch = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Environment').PROCESSOR_ARCHITECTURE
$arch = if ($machineArch -eq 'ARM64') { 'arm64' } else { 'x64' }
$target = "win32-$arch"
$store = if ($env:HERMES_RUNTIME_DIR) { $env:HERMES_RUNTIME_DIR } else { Join-Path $HOME '.hermes/tools' }
$py = $null
$entry = Get-ChildItem -Path $store -Directory -Filter "python-*-$target" -ErrorAction SilentlyContinue |
Sort-Object Name | Select-Object -Last 1
if ($entry -and (Test-Path (Join-Path $entry.FullName 'bin/python.exe'))) {
$py = Join-Path $entry.FullName 'bin/python.exe'
foreach ($candidate in @("$repo\.venv\Scripts\python.exe", "$repo\venv\Scripts\python.exe")) {
if (Test-Path -LiteralPath $candidate) { $py = $candidate; break }
}
if (-not $py) {
foreach ($candidate in @(
(Join-Path $repo 'venv/Scripts/python.exe'),
(Join-Path $repo 'venv/bin/python.exe'))) {
if (Test-Path $candidate) { $py = $candidate; break }
$roots = @($env:HERMES_RUNTIME_DIR, "$repo\..\tools")
$homeRoot = if ($env:HERMES_HOME) { $env:HERMES_HOME } else { "$env:LOCALAPPDATA\hermes" }
$roots += (Join-Path $homeRoot 'tools')
foreach ($root in $roots) {
if (-not $root) { continue }
foreach ($entry in @(Get-ChildItem -LiteralPath $root -Directory -Filter 'python-*' -ErrorAction SilentlyContinue)) {
$candidate = Join-Path $entry.FullName 'python.exe'
if (Test-Path -LiteralPath $candidate) { $py = $candidate; break }
}
if ($py) { break }
}
}
if (-not $py) {
Write-Error 'activate: no pm python found - run .\setup-hermes.ps1 first'
if (-not $py) { throw 'activate: no bootstrap Python found; run setup-hermes.ps1' }
$priorPath = $env:PYTHONPATH
$priorHome = $env:PYTHONHOME
try {
$env:PYTHONPATH = $repo
Remove-Item env:PYTHONHOME -ErrorAction SilentlyContinue
$json = (& $py -m hermes_cli.runtime_paths) -join "`n"
if ($LASTEXITCODE -ne 0) { throw 'activate: could not read the installed environment' }
$composed = $json | ConvertFrom-Json
} finally {
if ($null -eq $priorPath) { Remove-Item env:PYTHONPATH -ErrorAction SilentlyContinue } else { $env:PYTHONPATH = $priorPath }
if ($null -eq $priorHome) { Remove-Item env:PYTHONHOME -ErrorAction SilentlyContinue } else { $env:PYTHONHOME = $priorHome }
}
$envJSON = (& $py -m pm.cli env 2>$null) -join "`n"
if (-not $envJSON) {
Write-Error 'activate: could not read pm env - run .\setup-hermes.ps1 first'
}
$composed = $envJSON | ConvertFrom-Json
# --- snapshot what we are about to change (deactivate restores this) ---
$global:_hermesKeys = @($composed.PSObject.Properties.Name)
$global:_hermesSaved = @{}
foreach ($k in $global:_hermesKeys) {
$global:_hermesSaved[$k] = [pscustomobject]@{
WasSet = (Test-Path "env:$k")
Value = [Environment]::GetEnvironmentVariable($k)
foreach ($key in $global:_hermesKeys) {
$global:_hermesSaved[$key] = [pscustomobject]@{
WasSet = (Test-Path "env:$key")
Value = [Environment]::GetEnvironmentVariable($key)
}
}
foreach ($prop in $composed.PSObject.Properties) {
Set-Item -Path "env:$($prop.Name)" -Value ([string]$prop.Value)
foreach ($property in $composed.PSObject.Properties) {
Set-Item -Path "env:$($property.Name)" -Value ([string]$property.Value)
}
function global:deactivate {
foreach ($k in $global:_hermesKeys) {
$saved = $global:_hermesSaved[$k]
if ($saved.WasSet) {
Set-Item -Path "env:$k" -Value $saved.Value
} else {
Remove-Item -Path "env:$k" -ErrorAction SilentlyContinue
}
foreach ($key in $global:_hermesKeys) {
$saved = $global:_hermesSaved[$key]
if ($saved.WasSet) { Set-Item -Path "env:$key" -Value $saved.Value }
else { Remove-Item -Path "env:$key" -ErrorAction SilentlyContinue }
}
$global:_hermesKeys = $null
$global:_hermesSaved = $null
-2
View File
@@ -919,8 +919,6 @@ def _init_openai_client(agent, api_key, base_url, fallback_model, _provider_time
agent.api_key = client_kwargs.get("api_key", "")
agent.base_url = client_kwargs.get("base_url", agent.base_url)
try:
from agent.ssl_guard import verify_ca_bundle
verify_ca_bundle()
agent.client = agent._create_openai_client(client_kwargs, reason="agent_init", shared=True)
if not agent.quiet_mode:
print(f"🤖 AI Agent initialized with model: {agent.model}")
+1 -1
View File
@@ -127,7 +127,7 @@ _LOGGED_UNSUPPORTED_OAUTH_KEYS: set = set()
def _resolve_aux_verify(base_url: Optional[str]) -> Any:
"""httpx ``verify`` for an aux base_url, mirroring the main client (per-provider ``ssl_ca_cert`` /
``ssl_verify``, ``HERMES_CA_BUNDLE`` / ``SSL_CERT_FILE``); any failure → httpx default (``True``)."""
``ssl_verify``; otherwise the OS trust store); any failure → httpx default (``True``)."""
try:
from agent.ssl_verify import resolve_httpx_verify
from hermes_cli.config import get_custom_provider_tls_settings, load_config_readonly
+5 -2
View File
@@ -55,10 +55,13 @@ def _matches_outside_code(pattern: re.Pattern, text: str) -> Iterable[str]:
def _existing_file(candidate: str) -> Optional[str]:
"""Expanded path when it is a regular file; None otherwise (incl. OSError on pathological input)."""
"""Normalized path when it is a regular file; None otherwise (incl. OSError on pathological input).
The return value is the OS-canonical spelling of the real file (same norm class as
``str(Path(...))``), so callers can compare it against actual paths — ``~`` expansion
alone would leave the textual ``/`` separators of the source text in place on Windows."""
expanded = os.path.expanduser(candidate)
try:
return expanded if os.path.isfile(expanded) else None
return os.path.normpath(expanded) if os.path.isfile(expanded) else None
except OSError:
return None
-107
View File
@@ -56,113 +56,6 @@ def _locate_memory(node_id: str) -> tuple[Path, list[str], int]:
return path, chunks, local
# MERGE-CHECK: upstream removed this section in #102117 refactor; kept our node detail/delete/edit API
# ── Inspect (edit prefill) ──────────────────────────────────────────────────
def node_detail(node_id: str) -> dict[str, Any]:
"""Current content for an edit prefill. ``content`` is the full SKILL.md
(skills) or the raw memory chunk (memories)."""
try:
return _node_detail(node_id)
except (ValueError, IndexError) as exc:
return {"ok": False, "message": str(exc)}
def _node_detail(node_id: str) -> dict[str, Any]:
if parse_node_kind(node_id) == "memory":
source, gidx = _parse_memory_id(node_id)
_, chunks, local = _locate_memory(source, gidx)
body = chunks[local].strip()
return {"ok": True, "kind": "memory", "id": node_id, "label": body.splitlines()[0][:80], "content": body}
from tools.skill_manager_tool import _find_skill
found = _find_skill(node_id)
if not found:
return {"ok": False, "message": f"skill '{node_id}' not found"}
skill_md = Path(found["path"]) / "SKILL.md"
if not skill_md.exists():
return {"ok": False, "message": f"SKILL.md missing for '{node_id}'"}
return {
"ok": True,
"kind": "skill",
"id": node_id,
"label": node_id,
"content": skill_md.read_text(encoding="utf-8-sig"),
}
# ── Delete ──────────────────────────────────────────────────────────────────
def delete_node(node_id: str) -> dict[str, Any]:
try:
return _delete_memory(node_id) if parse_node_kind(node_id) == "memory" else _delete_skill(node_id)
except (ValueError, IndexError) as exc:
return {"ok": False, "message": str(exc)}
def _delete_skill(name: str) -> dict[str, Any]:
from tools import skill_usage
if skill_usage.get_record(name).get("pinned"):
return {"ok": False, "message": f"'{name}' is pinned — unpin it first (hermes curator unpin {name})"}
ok, message = skill_usage.archive_skill(name)
if ok:
_clear_skill_cache()
return {"ok": ok, "message": f"archived '{name}' — restore with: hermes curator restore {name}" if ok else message}
def _delete_memory(node_id: str) -> dict[str, Any]:
source, gidx = _parse_memory_id(node_id)
path, chunks, local = _locate_memory(source, gidx)
del chunks[local]
_write_memory(path, chunks)
return {"ok": True, "message": f"deleted memory from {path.name}"}
# ── Edit ────────────────────────────────────────────────────────────────────
def edit_node(node_id: str, content: str) -> dict[str, Any]:
try:
return _edit_memory(node_id, content) if parse_node_kind(node_id) == "memory" else _edit_skill(node_id, content)
except (ValueError, IndexError) as exc:
return {"ok": False, "message": str(exc)}
def _edit_skill(name: str, content: str) -> dict[str, Any]:
from tools.skill_manager_tool import _edit_skill as _do_edit
result = _do_edit(name, content)
if result.get("success"):
_clear_skill_cache()
return {"ok": True, "message": f"updated '{name}'"}
return {"ok": False, "message": result.get("error", "edit failed")}
def _edit_memory(node_id: str, content: str) -> dict[str, Any]:
source, gidx = _parse_memory_id(node_id)
body = content.strip()
if not body:
return {"ok": False, "message": "empty memory — use delete to remove it"}
path, chunks, local = _locate_memory(source, gidx)
chunks[local] = body
_write_memory(path, chunks)
return {"ok": True, "message": f"updated memory in {path.name}"}
# ── Helpers ─────────────────────────────────────────────────────────────────
+1 -3
View File
@@ -287,9 +287,7 @@ def start_gateway_health_export(config: Dict[str, Any]) -> GatewayHealthExportRu
sdk: Optional[Dict[str, Any]] = None
if metrics_on or diagnostics_on:
try:
sdk = otlp_exporter._require_sdk(_METRICS_SDK, auto_install=True, prompt=False)
# MERGE-CHECK: depends on otlp_exporter._require_sdk(names, auto_install, prompt) upstream signature;
# pm lazy-install wiring ('otlp' extra) lives inside otlp_exporter._require_sdk itself.
sdk = otlp_exporter._require_sdk(_METRICS_SDK, auto_install=True)
except Exception:
logger.warning("monitoring.gateway_health_export.enabled but OTLP SDK is unavailable; install 'hermes-agent[otlp]'", exc_info=True)
return GatewayHealthExportRuntime(enabled=False, reason="otlp_unavailable")
+7 -4
View File
@@ -15,7 +15,7 @@ import logging
import threading
from typing import Any, Callable, Dict, FrozenSet, Generic, List, Optional, TypeVar
from hermes_constants import hermes_home_key
from hermes_constants import hermes_home_key, normalize_scope
P = TypeVar("P")
@@ -54,6 +54,7 @@ class ProviderRegistry(Generic[P]):
self._log_label = label if label.isupper() else label[0].lower() + label[1:]
def _target(self, scope: Optional[str], *, create: bool) -> Dict[str, P]:
scope = normalize_scope(scope)
if scope is None:
return self._providers
if create:
@@ -61,6 +62,7 @@ class ProviderRegistry(Generic[P]):
return self._scoped_providers.get(scope, {})
def _bump(self, scope: Optional[str]) -> None:
scope = normalize_scope(scope)
if scope is None:
self._generation += 1
else:
@@ -100,7 +102,7 @@ class ProviderRegistry(Generic[P]):
"""Global map overlaid with the active profile's scoped map (a copy)."""
with self._lock:
merged = dict(self._providers)
merged.update(self._scoped_providers.get(scope or hermes_home_key(), {}))
merged.update(self._scoped_providers.get(hermes_home_key(scope), {}))
return merged
def list_providers(self, *, scope: Optional[str] = None) -> List[P]:
@@ -114,13 +116,13 @@ class ProviderRegistry(Generic[P]):
key = self.normalize(name)
with self._lock:
return (
self._scoped_providers.get(scope or hermes_home_key(), {}).get(key)
self._scoped_providers.get(hermes_home_key(scope), {}).get(key)
or self._providers.get(key)
)
def registry_generation(self, *, scope: Optional[str] = None) -> tuple:
"""Cache fingerprint ``(global_generation, scoped_generation)``."""
active_scope = scope or hermes_home_key()
active_scope = hermes_home_key(scope)
with self._lock:
return self._generation, self._scoped_generations.get(active_scope, 0)
@@ -134,6 +136,7 @@ class ProviderRegistry(Generic[P]):
) -> bool:
"""Restore *previous* only when *current* is still installed under *name*."""
key = self.normalize(name)
scope = normalize_scope(scope)
with self._lock:
target = self._target(scope, create=True)
if target.get(key) is not current:
+10 -19
View File
@@ -387,38 +387,29 @@ def _read_text_or_none(p: Path) -> Optional[str]:
return None
def _management_token_path() -> Path:
"""Management-token location; resolving it never creates or changes state."""
return _proxy_state_dir_ro() / "management.token"
def ensure_management_token(*, force: bool = False) -> str:
"""Return the management-API bearer key, minting it on first call.
Stored at ``<hermes_home>/proxy/management.token`` with 0600 perms.
Stored at the path from :func:`_management_token_path` with 0600 perms.
The daemon receives it via the ``HERMES_IRON_PROXY_MGMT_KEY`` env var
(named in the generated config's ``management.api_key_env``);
``hermes egress reload`` reads the same file to authenticate.
"""
_proxy_state_dir()
p = _management_token_path()
if not force and p.exists():
try:
existing = p.read_text(encoding="utf-8-sig").strip()
if existing:
return existing
except OSError:
pass
if not force and (existing := _read_text_or_none(p)):
return existing
token = mint_proxy_token(prefix="hermes-mgmt")
_write_private_file(p, token.encode("utf-8"))
return token
# MERGE-CHECK: kept our utf-8-sig token reader; no in-file caller after upstream's #102117 refactor
def _read_management_token() -> Optional[str]:
p = _proxy_state_dir_ro() / "management.token"
try:
token = p.read_text(encoding="utf-8-sig").strip()
except OSError:
return None
return token or None
def _yaml():
"""PyYAML module or None (it is a Hermes dep, but never a hard requirement here)."""
@@ -477,7 +468,7 @@ def reload_proxy() -> bool:
raise RuntimeError(
"The generated proxy.yaml has no management listener (written before reload support). Re-run `hermes egress setup` and use `hermes egress restart` this one time."
)
if not (token := _read_text_or_none(_proxy_state_dir_ro() / "management.token")):
if not (token := _read_text_or_none(_management_token_path())):
raise RuntimeError("management.token is missing — re-run `hermes egress setup`, then `hermes egress restart`.")
host, port = mgmt
req = urllib.request.Request(f"http://{host}:{port}/v1/reload", method="POST", headers={"Authorization": f"Bearer {token}"}, data=b"")
+1 -1
View File
@@ -133,7 +133,7 @@ def register_source(source: SecretSource, *, replace: bool = False, builtin: boo
def _merged(scope: Optional[str]) -> Dict[str, SecretSource]:
"""Global sources overlaid with the scope's (default: current home) registrations."""
merged = dict(_SOURCES)
merged.update(_SCOPED_SOURCES.get(scope or hermes_home_key(), {}))
merged.update(_SCOPED_SOURCES.get(hermes_home_key(scope), {}))
return merged
+1 -1
View File
@@ -217,7 +217,7 @@ def _setup_note(loaded_skill: dict[str, Any]) -> Optional[str]:
def _supporting_files(loaded_skill: dict[str, Any], skill_dir: Path | None) -> list[str]:
"""Skill-relative support file paths: from ``linked_files`` or a disk walk."""
linked = (loaded_skill.get("linked_files") or {}).values()
supporting = [entry for entries in linked if isinstance(entries, list) for entry in entries]
supporting = [Path(entry).as_posix() for entries in linked if isinstance(entries, list) for entry in entries]
if not supporting and skill_dir:
for subdir in ("references", "templates", "scripts", "assets"):
files = sorted((skill_dir / subdir).rglob("*"))
+4 -4
View File
@@ -693,10 +693,10 @@ def nonretryable_client_error_result(
agent,
" 💡 The TLS certificate chain could not be verified. This fails the same",
" way on every retry — fix the environment, then try again:",
" • Corporate TLS-inspecting proxy? Point Python at its CA bundle:",
" export SSL_CERT_FILE=/path/to/corp-ca.pem (also REQUESTS_CA_BUNDLE)",
" • Missing/stale system CA store? Install/refresh it:",
" pip install --upgrade certifi (macOS: run 'Install Certificates.command')",
" • Corporate TLS-inspecting proxy? Ask your administrator to install",
" its root certificate in the operating system trust store.",
" • Missing/stale system CA store? Refresh the OS certificate store.",
" A provider-specific CA can also be configured with ssl_ca_cert.",
" • Self-signed local endpoint (llama.cpp, LM Studio, vLLM)? Use http://",
" for localhost, or add the server's cert to your trust store.",
)
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from 'vitest'
import { APPINSTALLER_CHECK_TIMEOUT_MS, type ExecFileImpl, runAppInstallerChecker } from './appinstaller-checker'
type Callback = Parameters<ExecFileImpl>[3]
interface CapturedCall {
file: string
args: readonly string[]
options: { encoding: string; timeout: number; windowsHide: boolean; env?: NodeJS.ProcessEnv }
callback: Callback
}
function stubExecFile(behavior: (call: CapturedCall) => void) {
const calls: CapturedCall[] = []
const impl = (
file: string,
args: readonly string[],
options: CapturedCall['options'],
callback: Callback
) => {
const call: CapturedCall = { file, args, options, callback }
calls.push(call)
behavior(call)
return { kill: vi.fn() }
}
return { impl: impl as unknown as ExecFileImpl, calls }
}
const UNKNOWN_JSON = JSON.stringify({ available: null, error: 'checker timed out after 50ms' })
describe('runAppInstallerChecker', () => {
it('runs python on the script, hidden and with the default deadline', async () => {
const { impl, calls } = stubExecFile(call => call.callback(null, '{"available": false}', ''))
const result = await runAppInstallerChecker('python.exe', 'check.py', { execFileImpl: impl })
expect(result).toEqual({ code: 0, stdout: '{"available": false}' })
expect(calls[0].file).toBe('python.exe')
expect(calls[0].args).toEqual(['check.py'])
expect(calls[0].options.windowsHide).toBe(true)
expect(calls[0].options.timeout).toBe(APPINSTALLER_CHECK_TIMEOUT_MS)
})
it('a nonzero exit keeps the checker stdout (the JSON "unknown" contract)', async () => {
const stdout = '{"available": null, "error": "winrt import failed"}'
const { impl } = stubExecFile(call => call.callback(Object.assign(new Error('exited'), { code: 1 }), stdout, ''))
const result = await runAppInstallerChecker('python.exe', 'check.py', { execFileImpl: impl })
expect(result).toEqual({ code: 1, stdout })
})
it('a kill at the deadline resolves the caller-unknown shape', async () => {
const { impl } = stubExecFile(call =>
call.callback(Object.assign(new Error('killed'), { code: null, killed: true }), '', '')
)
const result = await runAppInstallerChecker('python.exe', 'check.py', {
execFileImpl: impl,
timeoutMs: 50
})
expect(result.code).toBe(1)
expect(JSON.parse(result.stdout)).toEqual({ available: null, error: 'checker timed out after 50ms' })
})
it('resolves at the deadline even when the child never emits close', async () => {
// The old main.ts helper killed the child and then waited on 'close'
// forever. The deadline here is an independent bound: with a stub child
// that never calls back and never exits, the promise still settles.
const { impl, calls } = stubExecFile(() => undefined) // no callback, ever
const started = Date.now()
const result = await runAppInstallerChecker('python.exe', 'check.py', {
execFileImpl: impl,
timeoutMs: 50
})
expect(Date.now() - started).toBeLessThan(2000)
expect(result).toEqual({ code: 1, stdout: UNKNOWN_JSON })
expect(calls[0].options.timeout).toBe(50)
})
it('an interpreter that cannot spawn resolves unknown, never "no update"', async () => {
const { impl } = stubExecFile(call => call.callback(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), '', ''))
const result = await runAppInstallerChecker('missing-python.exe', 'check.py', { execFileImpl: impl })
expect(result.code).toBe(1)
expect(JSON.parse(result.stdout)).toEqual({ available: null, error: 'ENOENT' })
})
it('forwards checker stderr to the diagnostic sink without breaking the result', async () => {
const onStderr = vi.fn(() => {
throw new Error('sink exploded')
})
const { impl } = stubExecFile(call => call.callback(null, '{}', 'some warning'))
const result = await runAppInstallerChecker('python.exe', 'check.py', { execFileImpl: impl, onStderr })
expect(result).toEqual({ code: 0, stdout: '{}' })
expect(onStderr).toHaveBeenCalledWith('some warning')
})
it('a synchronously throwing execFile resolves unknown and leaves no pending timer', async () => {
const impl = (() => {
throw new Error('bad arguments')
}) as unknown as ExecFileImpl
const result = await runAppInstallerChecker('python.exe', 'check.py', { execFileImpl: impl, timeoutMs: 50 })
expect(result.code).toBe(1)
expect(JSON.parse(result.stdout)).toEqual({ available: null, error: 'bad arguments' })
})
it('passes the caller env through (PYTHONPATH for the payload site-packages)', async () => {
const { impl, calls } = stubExecFile(call => call.callback(null, '', ''))
await runAppInstallerChecker('python.exe', 'check.py', { execFileImpl: impl, env: { PYTHONPATH: 'C:\\sp' } })
expect(calls[0].options.env).toEqual({ PYTHONPATH: 'C:\\sp' })
})
})
@@ -0,0 +1,116 @@
/**
* appinstaller-checker.ts
*
* Bounded child run for the App Installer update checker
* (scripts/check-appinstaller-update.py). The checker runs on the
* update-check path, so a wedged child must never hang the check: the
* deadline resolves the promise AT the deadline with an honest unknown,
* while execFile's own timeout performs the bounded kill. Two independent
* bounds on purpose — a kill that the child never answers (no 'close' ever)
* still cannot hold the check hostage.
*/
import { execFile } from 'node:child_process'
export const APPINSTALLER_CHECK_TIMEOUT_MS = 20_000
export interface AppInstallerCheckerDeps {
env?: NodeJS.ProcessEnv
timeoutMs?: number
/** Diagnostic sink for checker stderr; never breaks the result. */
onStderr?: (text: string) => void
execFileImpl?: ExecFileImpl
}
export interface AppInstallerCheckResult {
code: number
stdout: string
}
type CheckerError = Error & { code?: string | number; killed?: boolean }
/** Shape of the injected exec primitive (node's execFile in production). */
export type ExecFileImpl = (
file: string,
args: readonly string[],
options: { encoding: 'utf8'; timeout: number; windowsHide: boolean; env?: NodeJS.ProcessEnv },
callback: (error: CheckerError | null, stdout: string, stderr: string) => void
) => unknown
/**
* Run `python <script>` with a hard deadline. Resolves (never rejects):
* exit code and stdout map through unchanged so the checker's own JSON
* contract survives; a deadline hit or an unspawnable interpreter resolves
* the caller's "unknown" shape instead of a fake "no update".
*/
export function runAppInstallerChecker(
python: string,
script: string,
deps: AppInstallerCheckerDeps = {}
): Promise<AppInstallerCheckResult> {
const timeoutMs = deps.timeoutMs ?? APPINSTALLER_CHECK_TIMEOUT_MS
const exec = deps.execFileImpl ?? execFile
const deadlineResult = (): AppInstallerCheckResult => ({
code: 1,
stdout: JSON.stringify({ available: null, error: `checker timed out after ${timeoutMs}ms` })
})
return new Promise((resolve) => {
let settled = false
const finish = (result: AppInstallerCheckResult): void => {
if (settled) {
return
}
settled = true
clearTimeout(deadline)
resolve(result)
}
// The resolve deadline is independent of the callback below: if the
// killed child never emits 'close', this still fires at the deadline.
const deadline = setTimeout(() => finish(deadlineResult()), timeoutMs)
try {
exec(
python,
[script],
{ encoding: 'utf8', timeout: timeoutMs, windowsHide: true, env: deps.env },
(error, stdout, stderr) => {
if (stderr) {
// A throwing diagnostic sink must never break the checker result.
try {
deps.onStderr?.(String(stderr))
} catch {
/* ignore */
}
}
if (error?.killed) {
finish(deadlineResult())
return
}
if (error) {
if (typeof error.code === 'number') {
finish({ code: error.code, stdout: String(stdout || '') })
} else {
finish({ code: 1, stdout: JSON.stringify({ available: null, error: error.message }) })
}
return
}
finish({ code: 0, stdout: String(stdout || '') })
}
)
} catch (err) {
// execFile can throw synchronously (bad arguments); the deadline timer
// must not be left pending — resolve the unknown shape right here.
finish({ code: 1, stdout: JSON.stringify({ available: null, error: (err as Error).message }) })
}
})
}
@@ -38,7 +38,7 @@ test('loadOrCreateInstallationId persists and reuses one installation ID', () =>
loadOrCreateInstallationId(filePath, () => ID_B),
ID_A
)
assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)
if (process.platform !== 'win32') assert.equal(fs.statSync(filePath).mode & 0o777, 0o600)
}))
test('loadOrCreateInstallationId tightens an existing identity file', () =>
+1 -1
View File
@@ -74,7 +74,7 @@ describe('scanGitRepos', () => {
})
})
describe('macOS TCC-protected media exclusions (issue #57611 salvage)', () => {
describe.runIf(process.platform !== 'win32')('macOS TCC-protected media exclusions (issue #57611 salvage)', () => {
it('finds a normal repo but skips root-level media folders on darwin', async () => {
const root = tempDir()
const dev = makeRepoAt(root, 'dev', 'proj')
+6 -1
View File
@@ -381,10 +381,15 @@ async function listBranches(repoPath, gitBin) {
}
try {
const [localOut, remoteOut] = await Promise.all([
// Both children own cwd handles: a failed probe must still wait for its
// sibling before the caller may remove or switch the repository directory.
const probes = await Promise.allSettled([
runGit(gitBin, ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/heads'], resolved),
runGit(gitBin, ['for-each-ref', '--format=%(refname:short)', '--sort=-committerdate', 'refs/remotes'], resolved)
])
const [local, remote] = probes
if (local.status === 'rejected' || remote.status === 'rejected') return []
const [localOut, remoteOut] = [local.value, remote.value]
const trees = await listWorktrees(resolved, gitBin)
const pathByBranch = new Map(trees.filter(tree => tree.branch).map(tree => [tree.branch, tree.path]))
+9 -9
View File
@@ -154,7 +154,7 @@ test('encryptDesktopSecret stores safeStorage base64 payload', () => {
// ─── Owner-only credential files (connection.json) ─────────────────────────
test('writeSecretFileAtomic creates the file owner-only, not at the 0644 umask default', () => {
test.runIf(process.platform !== 'win32')('writeSecretFileAtomic creates the file owner-only, not at the 0644 umask default', () => {
withTempDir(dir => {
const target = path.join(dir, 'connection.json')
const payload = JSON.stringify({ remote: { token: { encoding: SAFE_STORAGE_ENCODING, value: 'BLOB' } } })
@@ -381,7 +381,7 @@ test('resolvePersistedRemoteToken keeps the existing token when no new token is
assert.equal(called, false, 'an empty incoming token must not re-encrypt anything')
})
test('writeSecretFileAtomic does not inherit loose bits from a stale temp file', () => {
test.runIf(process.platform !== 'win32')('writeSecretFileAtomic does not inherit loose bits from a stale temp file', () => {
// renameSync keeps the TEMP file's permissions, and writeFileSync's `mode`
// is ignored when the path already exists — so a temp left by a crashed
// earlier write would otherwise hand 0644 straight to the target.
@@ -409,8 +409,8 @@ function fsWith(overrides: Record<string, unknown>) {
return { ...fs, ...overrides } as any
}
test('the written file is owner-only even where chmod does nothing', () => {
// Windows, and any mount that refuses chmod. The create-time `mode` is what
test.runIf(process.platform !== 'win32')('the written file is owner-only even where chmod does nothing', () => {
// A POSIX filesystem where chmod is unavailable. The create-time `mode` is what
// covers this — there is no second chance to tighten.
withTempDir(dir => {
const target = path.join(dir, 'connection.json')
@@ -434,7 +434,7 @@ test('the written file is owner-only even where chmod does nothing', () => {
})
})
test('the written file is owner-only even when a stale temp cannot be removed', () => {
test.runIf(process.platform !== 'win32')('the written file is owner-only even when a stale temp cannot be removed', () => {
// The unlink is best-effort; if the stale temp survives, writeFileSync's
// `mode` is ignored on an existing path and only the chmod before the rename
// can still fix the bits.
@@ -478,7 +478,7 @@ test('writeSecretFileAtomic cannot be redirected through a symlink planted at th
})
})
test('tightenSecretFileMode tightens a pre-existing world-readable config in place', () => {
test.runIf(process.platform !== 'win32')('tightenSecretFileMode tightens a pre-existing world-readable config in place', () => {
// The upgrade path: a connection.json written by an older build sits at 0644
// with a real (encrypted) token in it. Tightening must change the mode and
// nothing else — the token has to stay readable or the user loses their
@@ -505,7 +505,7 @@ test('tightenSecretFileMode tightens a pre-existing world-readable config in pla
})
})
test('tightenSecretFileMode leaves a non-safeStorage token payload readable', () => {
test.runIf(process.platform !== 'win32')('tightenSecretFileMode leaves a non-safeStorage token payload readable', () => {
// A hand-edited config (or one from a pre-release build) can hold a
// non-safeStorage token payload, which decryptDesktopSecret still reads
// verbatim on purpose. Tightening the mode must not disturb that fallback —
@@ -527,7 +527,7 @@ test('tightenSecretFileMode leaves a non-safeStorage token payload readable', ()
})
})
test('tightenSecretFileMode is idempotent and never throws on an unusable path', () => {
test.runIf(process.platform !== 'win32')('tightenSecretFileMode is idempotent and never throws on an unusable path', () => {
withTempDir(dir => {
const target = path.join(dir, 'connection.json')
writeSecretFileAtomic(target, '{}')
@@ -566,7 +566,7 @@ test('tightenSecretFileMode refuses to chmod a symlink instead of following it t
})
})
test('tightenSecretFileMode only touches a regular file the current user owns', () => {
test.runIf(process.platform !== 'win32')('tightenSecretFileMode only touches a regular file the current user owns', () => {
// Directories, sockets, fifos and files owned by another account are all
// "not ours to chmod". Injected lstat so the foreign-owner branch is
// reachable without a second OS account.
+19 -47
View File
@@ -34,6 +34,7 @@ import { classifyActiveRuntime } from './active-runtime-state'
import { destroyKeepaliveAgents, downloadAgentFor, jsonAgentFor, withRetry } from './api-transport'
import { appIconCandidates, resolveAppIcon } from './app-icon'
import { PLACEHOLDER_FEED_BASE_URL } from './app-updater'
import { runAppInstallerChecker } from './appinstaller-checker'
import { stopBackendChild as stopBackendChildImpl, stopBackendTreesForUpdate } from './backend-child'
import {
type BackendOutputTail,
@@ -3395,11 +3396,12 @@ function isLightVariant(): boolean {
}
/**
* Run a bundled payload python script. The payload python needs the payload
* venv's site-packages on PYTHONPATH to import the winrt module — the same
* way the bundled CLI launcher sets it (the launcher-wrapper.py shebang
* overlay puts the venv's site-packages on sys.path). Without it the
* checker would always fail with "winrt import failed".
* Run a bundled payload python script (the App Installer update checker).
* The payload python needs the payload venv's site-packages on PYTHONPATH to
* import the winrt module — the same way the bundled CLI launcher sets it.
* Bounded via runAppInstallerChecker: a wedged child is killed at the
* deadline and reported as an unknown, and the deadline resolves even if the
* child never emits close (see appinstaller-checker.ts).
*/
async function runPayloadPython(python: string, script: string): Promise<{ code: number; stdout: string }> {
const payload = resolvePayload(process.resourcesPath, { fileExists, directoryExists, isWindows: IS_WINDOWS })
@@ -3409,22 +3411,9 @@ async function runPayloadPython(python: string, script: string): Promise<{ code:
env.PYTHONPATH = payload.sitePackages
}
return new Promise((resolve) => {
const child = spawn(python, [script], {
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe'],
env
})
let stdout = ''
let stderr = ''
child.stdout.on('data', chunk => { stdout += chunk.toString() })
child.stderr.on('data', chunk => { stderr += chunk.toString() })
child.on('error', (error) => resolve({ code: 1, stdout: JSON.stringify({ available: null, error: error.message }) }))
child.on('close', (code) => {
if (stderr) {console.error(`[app-installer] checker stderr: ${stderr.slice(0, 400)}`)}
resolve({ code: code ?? 1, stdout })
})
return runAppInstallerChecker(python, script, {
env,
onStderr: stderr => console.error(`[app-installer] checker stderr: ${stderr.slice(0, 400)}`)
})
}
@@ -12974,29 +12963,14 @@ async function stopAllPoolBackends() {
}
/**
* Last teardown act on Windows: kill anything still running out of THIS
* install's roots, so no straggler holds an image open.
*
* Two roots, because two install shapes have the same daemon problem:
*
* - The artifact resources dir (bundled/MSIX). A live process rooted in the
* package family pins the family's container silo, and every later
* activation then fails the job → silo conversion with 0x80070020 — the app
* never launches again. Live cause on windows-11-arm was the payload's
* bundled-git gpg-agent, which daemonizes out of our process tree and so is
* unreachable by taskkill /T.
* - The managed tool store (mutable install.ps1 / install.sh checkout), where
* pm stages node, git and uv. A daemonized tool there keeps its own image
* open, and Windows cannot overwrite a running image — so the next
* `hermes update` fails to replace exactly those files.
*
* HERMES_RUNTIME_DIR is the store when pm is aimed at one (a bundled payload
* aims it at its own, where the roots overlap harmlessly); otherwise the store
* is <hermes root>/tools, mirroring pm.paths.store_root().
*
* Runs AFTER the graceful backend teardown and skips the pids it owned, so
* this is only the net for what detached. Best effort throughout: quit must
* never hang or fail on it. See package-process-reap.ts for the evidence.
* Last teardown act on Windows: kill orphaned tools still running out of
* THIS install's artifact root, so no straggler pins the package silo
* (0x80070020 on the next activation). The managed tool store is NOT a quit
* root — it is machine-scoped and shared with the surviving gateway and
* other installs; image locks there are the updater's pause/resume job.
* Runs after the graceful backend teardown, skips the pids it owned and
* every live Hermes runtime process, so it is only the net for what
* detached. Best effort throughout; see package-process-reap.ts.
*/
function reapInstallRootedStragglers(excludePids: number[]): void {
if (!IS_WINDOWS) {
@@ -13004,12 +12978,10 @@ function reapInstallRootedStragglers(excludePids: number[]): void {
}
const payloadRoot = isBundledInstall(process.resourcesPath, { fileExists }) ? process.resourcesPath : null
// HERMES_HOME is already root-normalized by resolveHermesHome().
const managedStore = process.env.HERMES_RUNTIME_DIR || path.join(HERMES_HOME, 'tools')
try {
reapPackageRootedProcesses({
installRoots: [payloadRoot, managedStore],
installRoots: [payloadRoot],
listProcesses: () => listWindowsProcesses((file, args, options) => execFileSync(file, args, {
...hiddenWindowsChildOptions({ encoding: 'utf8', timeout: options.timeout }),
windowsHide: options.windowsHide
@@ -280,7 +280,7 @@ test('POSIX managed launcher is detached, correlation-scoped, and never publishe
assert.match(command, /while \[ ! -e/)
})
test('POSIX managed launcher executes the updater command and atomically publishes its status', async () => {
test.runIf(process.platform !== 'win32')('POSIX managed launcher executes the updater command and atomically publishes its status', async () => {
const home = await mkdtemp(path.join(os.tmpdir(), 'hermes-managed-launch-'))
try {
@@ -359,7 +359,7 @@ test('remote observation rejects a receipt for another correlation', () => {
)
})
test('POSIX observer reads the exact correlation receipt and terminal marker from disk', async () => {
test.runIf(process.platform !== 'win32')('POSIX observer reads the exact correlation receipt and terminal marker from disk', async () => {
const home = await mkdtemp(path.join(os.tmpdir(), 'hermes-managed-update-'))
try {
@@ -401,7 +401,7 @@ test('POSIX observer reads the exact correlation receipt and terminal marker fro
}
})
test('managed observer unwraps a named profile home for the install-wide marker', async () => {
test.runIf(process.platform !== 'win32')('managed observer unwraps a named profile home for the install-wide marker', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'hermes-managed-profile-marker-'))
const profileHome = path.join(root, 'profiles', 'research')
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'
import {
isUnderInstallRoot,
listWindowsProcesses,
protectedRuntimePids,
reapPackageRootedProcesses,
type RunningProcess
} from './package-process-reap'
@@ -17,9 +18,23 @@ const GPG_AGENT = `${ROOT}\\app\\resources\\agent-payload\\tools\\git-2.53.0+3-w
const PAYLOAD_PYTHON = `${ROOT}\\app\\resources\\agent-payload\\tools\\python-3.11.16+20260814-win32-arm64\\python.exe`
const MAIN_EXE = `${ROOT}\\app\\Hermes.exe`
/** Live-observed daemon command line (payload git gpg-agent, C09). */
const DAEMON_CMDLINE = `"${GPG_AGENT}" --use-standard-socket --daemon`
/** Same class, mutable install: pm-staged node holding its own image open. */
const STORE_NODE = `${TOOLS_ROOT}\\node-26.7.0-win32-arm64\\node.exe`
/**
* The gateway that survives desktop quit: payload python running the Hermes
* gateway under the user-logon Scheduled Task (bundled installs run it out
* of the payload, so its image IS under the artifact root).
*/
const GATEWAY_CMDLINE = `"${PAYLOAD_PYTHON}" -m hermes_cli.main gateway run`
/** Legacy/alternate module launcher of the same runtime. */
const GATEWAY_RUN_MODULE_CMDLINE = `"${PAYLOAD_PYTHON}" -m gateway.run --profile work`
/** Script-path launcher of the same runtime. */
const GATEWAY_RUN_SCRIPT_CMDLINE = `"${PAYLOAD_PYTHON}" C:\\Hermes\\hermes-agent\\gateway\\run.py --profile work`
function reap(processes: RunningProcess[], overrides: Record<string, unknown> = {}) {
const killProcess = vi.fn()
@@ -35,6 +50,15 @@ function reap(processes: RunningProcess[], overrides: Record<string, unknown> =
return { outcome, killProcess }
}
function proc(
pid: number,
path: string | null,
commandLine: string | null = null,
parentPid: number | null = null
): RunningProcess {
return { pid, parentPid, path, commandLine }
}
describe('isUnderInstallRoot', () => {
it('matches an image nested under the root regardless of case or separator', () => {
expect(isUnderInstallRoot(GPG_AGENT, ROOT)).toBe(true)
@@ -53,7 +77,8 @@ describe('isUnderInstallRoot', () => {
})
it('does not match another vendor, an unreadable path, or an empty root', () => {
const other = 'C:\\Program Files\\WindowsApps\\8bitSolutionsLLC.bitwardendesktop_2026.7.0.0_arm64__x\\app\\Bitwarden.exe'
const other =
'C:\\Program Files\\WindowsApps\\8bitSolutionsLLC.bitwardendesktop_2026.7.0.0_arm64__x\\app\\Bitwarden.exe'
expect(isUnderInstallRoot(other, ROOT)).toBe(false)
expect(isUnderInstallRoot(null, ROOT)).toBe(false)
@@ -84,29 +109,29 @@ describe('reapPackageRootedProcesses', () => {
it('kills a detached package-rooted daemon that a tree-kill cannot reach', () => {
// The regression: gpg-agent reparented away from us, so it is in no tree we
// own. Path scoping is what catches it.
const { outcome, killProcess } = reap([{ pid: 48236, path: GPG_AGENT }])
const { outcome, killProcess } = reap([proc(48236, GPG_AGENT, DAEMON_CMDLINE)])
expect(outcome.matched).toBe(1)
expect(outcome.killed).toEqual([48236])
expect(killProcess).toHaveBeenCalledWith(48236)
})
it('reaps every package-rooted process, whatever the image name', () => {
it('leaves shared interpreters and entry points to their lifecycle owners', () => {
const { outcome } = reap([
{ pid: 10, path: MAIN_EXE },
{ pid: 11, path: PAYLOAD_PYTHON },
{ pid: 12, path: GPG_AGENT }
proc(10, MAIN_EXE, `${MAIN_EXE} --app`),
proc(11, PAYLOAD_PYTHON, 'idle-python'),
proc(12, GPG_AGENT, DAEMON_CMDLINE)
])
expect(outcome.matched).toBe(3)
expect(outcome.killed).toEqual([10, 11, 12])
expect(outcome.matched).toBe(1)
expect(outcome.killed).toEqual([12])
})
it('never kills processes outside the install root', () => {
const { outcome, killProcess } = reap([
{ pid: 20, path: 'C:\\Program Files\\Git\\usr\\bin\\gpg-agent.exe' },
{ pid: 21, path: 'C:\\Users\\arilo\\AppData\\Local\\Programs\\HermesBundled\\app\\Hermes.exe' },
{ pid: 22, path: GPG_AGENT }
proc(20, 'C:\\Program Files\\Git\\usr\\bin\\gpg-agent.exe', 'gpg-agent --daemon'),
proc(21, 'C:\\Users\\arilo\\AppData\\Local\\Programs\\HermesBundled\\app\\Hermes.exe'),
proc(22, GPG_AGENT, DAEMON_CMDLINE)
])
// The user's OWN gpg-agent is exactly what a GNUPGHOME-scoped
@@ -122,50 +147,136 @@ describe('reapPackageRootedProcesses', () => {
// the next `hermes update` fails to replace exactly those files.
const { outcome, killProcess } = reap(
[
{ pid: 70, path: STORE_NODE },
{ pid: 71, path: `${TOOLS_ROOT}\\git-2.53.0+3-win32-arm64\\usr\\bin\\gpg-agent.exe` },
{ pid: 72, path: 'C:\\Windows\\System32\\node.exe' }
proc(70, STORE_NODE, `${STORE_NODE} daemon.js`),
proc(71, `${TOOLS_ROOT}\\git-2.53.0+3-win32-arm64\\usr\\bin\\gpg-agent.exe`, DAEMON_CMDLINE),
proc(72, 'C:\\Windows\\System32\\node.exe', 'node server.js')
],
{ installRoots: [null, TOOLS_ROOT] }
)
expect(outcome.killed).toEqual([70, 71])
expect(outcome.killed).toEqual([71])
expect(killProcess).not.toHaveBeenCalledWith(72)
})
it('reaps both roots in one pass when an install has both', () => {
const { outcome } = reap(
[
{ pid: 80, path: GPG_AGENT },
{ pid: 81, path: STORE_NODE }
],
[proc(80, GPG_AGENT, DAEMON_CMDLINE), proc(81, STORE_NODE, `${STORE_NODE} daemon.js`)],
{ installRoots: [ROOT, TOOLS_ROOT] }
)
expect(outcome.matched).toBe(2)
expect(outcome.killed).toEqual([80, 81])
expect(outcome.matched).toBe(1)
expect(outcome.killed).toEqual([80])
})
it('never reaps a live Hermes runtime process rooted in the payload', () => {
// Bundled installs run the surviving gateway out of the payload, so its
// image IS under the artifact root — but the gateway survives desktop
// quit by contract. The `-m hermes_cli.main` invocation is what proves a
// process is a legitimate Hermes surface rather than an orphan.
const { outcome, killProcess } = reap([
proc(90, PAYLOAD_PYTHON, GATEWAY_CMDLINE),
proc(91, PAYLOAD_PYTHON, `"${PAYLOAD_PYTHON}" -m hermes_cli.main serve --port 8080`)
])
expect(outcome.matched).toBe(0)
expect(killProcess).not.toHaveBeenCalled()
})
it('the ordinary-quit selector kills the daemon but not the gateway or shared-store consumers', () => {
// Recorder for the wired quit call (roots = the artifact root only): a
// store-python gateway session and a store-node process are unrelated
// users of the machine-scoped store; the payload gateway survives the
// quit. Only the daemonized tool is selected.
const storePython = 'C:\\Users\\arilo\\AppData\\Local\\hermes\\tools\\python-3.11.16\\python.exe'
const { outcome, killProcess } = reap(
[
proc(100, storePython, `"${storePython}" -m hermes_cli.main gateway run`),
proc(101, STORE_NODE, `${STORE_NODE} script.js`),
proc(102, PAYLOAD_PYTHON, GATEWAY_CMDLINE),
proc(103, GPG_AGENT, `"${GPG_AGENT}" --use-standard-socket --daemon`)
],
{ installRoots: [ROOT] }
)
expect(outcome.killed).toEqual([103])
expect(killProcess).toHaveBeenCalledTimes(1)
expect(killProcess).toHaveBeenCalledWith(103)
})
it('protects the alternate runtime launcher shapes: -m gateway.run and gateway/run.py', () => {
// The Scheduled-Task launcher, elevated-handoff respawn and older
// releases do not all use `-m hermes_cli.main`; every launcher of the
// live runtime is protected (C06-09 review).
const { outcome, killProcess } = reap([
proc(91, PAYLOAD_PYTHON, GATEWAY_RUN_MODULE_CMDLINE),
proc(92, PAYLOAD_PYTHON, GATEWAY_RUN_SCRIPT_CMDLINE)
])
expect(outcome.matched).toBe(0)
expect(killProcess).not.toHaveBeenCalled()
})
it('protects the live runtime DESCENDANTS (venv launcher / worker / tool chain)', () => {
// gateway -> venv launcher shim -> worker python -> payload git: every
// one is a legitimate user of the payload while its ancestor lives.
const launcher = `${ROOT}\\app\\resources\\agent-payload\\venv\\Scripts\\python.exe`
const worker = `${launcher} -c "import worker"`
const git = `${ROOT}\\app\\resources\\agent-payload\\tools\\git-2.53.0+3-win32-arm64\\bin\\git.exe status`
const { outcome, killProcess } = reap([
proc(120, PAYLOAD_PYTHON, GATEWAY_CMDLINE),
proc(121, launcher, worker, 120),
proc(122, launcher, null, 121), // unreadable cmdline, but runtime-descended
proc(123, `${ROOT}\\app\\resources\\agent-payload\\tools\\git\\bin\\git.exe`, git, 121)
])
expect(outcome.matched).toBe(0)
expect(killProcess).not.toHaveBeenCalled()
})
it('protects flagged interpreters and children of unidentified processes', () => {
const { outcome } = reap([
proc(130, PAYLOAD_PYTHON, `"${PAYLOAD_PYTHON}" -u -X utf8 -m gateway.run`),
proc(131, GPG_AGENT, DAEMON_CMDLINE, 130),
proc(140, null, null),
proc(141, GPG_AGENT, DAEMON_CMDLINE, 140),
proc(150, GPG_AGENT, DAEMON_CMDLINE)
])
expect(outcome.killed).toEqual([150])
})
it('conservatively skips a process whose command line cannot be read', () => {
// An unreadable cmdline cannot prove the process is not a live runtime.
// Missing one pinner reproduces a bug we already have; killing an
// unidentified process is unbounded damage.
const { outcome, killProcess } = reap([proc(40, PAYLOAD_PYTHON, null), proc(41, PAYLOAD_PYTHON, '')])
expect(outcome.matched).toBe(0)
expect(killProcess).not.toHaveBeenCalled()
})
it('never kills itself', () => {
const { outcome, killProcess } = reap([{ pid: 99, path: MAIN_EXE }], { selfPid: 99 })
const { outcome, killProcess } = reap([proc(99, MAIN_EXE, `${MAIN_EXE} --app`)], { selfPid: 99 })
expect(outcome.matched).toBe(0)
expect(killProcess).not.toHaveBeenCalled()
})
it('skips pids the graceful backend teardown already owns', () => {
const { outcome } = reap([
{ pid: 30, path: MAIN_EXE },
{ pid: 31, path: PAYLOAD_PYTHON }
], { excludePids: [30] })
const { outcome } = reap(
[proc(30, MAIN_EXE, `${MAIN_EXE} --app`), proc(31, PAYLOAD_PYTHON, 'idle-python')],
{ excludePids: [30] }
)
expect(outcome.killed).toEqual([31])
expect(outcome.killed).toEqual([])
})
it('leaves a process whose path cannot be read alone', () => {
// Missing one pinner reproduces a bug we already have; killing an
// unidentified process is unbounded damage.
const { outcome, killProcess } = reap([{ pid: 40, path: null }])
const { outcome, killProcess } = reap([proc(40, null)])
expect(outcome.matched).toBe(0)
expect(killProcess).not.toHaveBeenCalled()
@@ -180,10 +291,7 @@ describe('reapPackageRootedProcesses', () => {
const outcome = reapPackageRootedProcesses({
installRoots: [ROOT],
listProcesses: () => [
{ pid: 50, path: MAIN_EXE },
{ pid: 51, path: GPG_AGENT }
],
listProcesses: () => [proc(50, GPG_AGENT, DAEMON_CMDLINE), proc(51, GPG_AGENT, DAEMON_CMDLINE)],
killProcess,
selfPid: 1,
isWindows: true
@@ -213,7 +321,7 @@ describe('reapPackageRootedProcesses', () => {
const posix = reapPackageRootedProcesses({
installRoots: [ROOT],
listProcesses: () => [{ pid: 60, path: MAIN_EXE }],
listProcesses: () => [proc(60, MAIN_EXE, `${MAIN_EXE} --app`)],
killProcess,
selfPid: 1,
isWindows: false
@@ -221,7 +329,7 @@ describe('reapPackageRootedProcesses', () => {
const rootless = reapPackageRootedProcesses({
installRoots: [null, ''],
listProcesses: () => [{ pid: 61, path: MAIN_EXE }],
listProcesses: () => [proc(61, MAIN_EXE, `${MAIN_EXE} --app`)],
killProcess,
selfPid: 1,
isWindows: true
@@ -233,32 +341,49 @@ describe('reapPackageRootedProcesses', () => {
})
})
describe('protectedRuntimePids', () => {
it('extends protection transitively through parentage and stays empty with no runtime', () => {
const running = [
proc(10, PAYLOAD_PYTHON, GATEWAY_CMDLINE, 2),
proc(11, PAYLOAD_PYTHON, 'worker', 10),
proc(12, PAYLOAD_PYTHON, 'grandchild', 11),
proc(13, GPG_AGENT, 'gpg-agent --daemon', null)
]
expect(protectedRuntimePids(running)).toEqual(new Set([10, 11, 12]))
expect(protectedRuntimePids([proc(14, GPG_AGENT, 'gpg-agent --daemon')])).toEqual(new Set())
})
})
describe('listWindowsProcesses', () => {
it('parses Get-Process output, mapping an unreadable path to null', () => {
// The real shape: `.Path` throws for processes we cannot open, so the
// script emits an empty tail for those.
it('parses Win32_Process output (pid|parent|path|command line), mapping unreadable fields to null', () => {
// The real shape: ExecutablePath / CommandLine are null for processes we
// cannot open, so the script emits empty middle/tail fields.
const stdout = [
`48236|${GPG_AGENT}`,
`22660|${MAIN_EXE}`,
'4|',
`48236|61728|${GPG_AGENT}|gpg-agent --daemon`,
`22660|4|${MAIN_EXE}|`,
'4|0||',
''
].join('\r\n')
const parsed = listWindowsProcesses(() => stdout)
expect(parsed).toEqual([
{ pid: 48236, path: GPG_AGENT },
{ pid: 22660, path: MAIN_EXE },
{ pid: 4, path: null }
{ pid: 48236, parentPid: 61728, path: GPG_AGENT, commandLine: 'gpg-agent --daemon' },
{ pid: 22660, parentPid: 4, path: MAIN_EXE, commandLine: '' },
{ pid: 4, parentPid: 0, path: null, commandLine: '' }
])
})
it('keeps paths that contain spaces intact', () => {
// "C:\Program Files\..." — splitting on the FIRST separator is what
// preserves the rest of the path.
const parsed = listWindowsProcesses(() => `10|${MAIN_EXE}`)
it('keeps paths and command lines that contain pipes and spaces intact', () => {
// Splitting on the FIRST three separators is what preserves the rest —
// a command line may contain "|", a path never does.
const cmd = 'python -c "x = a | b"'
const parsed = listWindowsProcesses(() => `10|99|${MAIN_EXE}|${cmd}`)
expect(parsed[0].path).toBe(MAIN_EXE)
expect(parsed[0].commandLine).toBe(cmd)
expect(parsed[0].parentPid).toBe(99)
})
it('ignores malformed lines instead of inventing pids', () => {
@@ -270,11 +395,7 @@ describe('listWindowsProcesses', () => {
it('runs hidden and bounded so it cannot stall or flash a console on quit', () => {
const calls: Array<{ file: string; options: { timeout: number; windowsHide: boolean } }> = []
const execFile = (
file: string,
_args: string[],
options: { timeout: number; windowsHide: boolean }
): string => {
const execFile = (file: string, _args: string[], options: { timeout: number; windowsHide: boolean }): string => {
calls.push({ file, options })
return ''
+120 -104
View File
@@ -1,101 +1,38 @@
/**
* package-process-reap.ts
*
* Reap every process still running out of our MSIX package root at shutdown.
*
* Why this exists. A Desktop-Bridge (Centennial) MSIX package activates inside
* a Desktop AppX container: Windows creates a Job Object for the package family
* and converts it to a silo. One live process rooted in the family pins that
* silo. While it is pinned, EVERY later activation of the family fails at the
* conversion step:
*
* AppModel-Runtime/Admin
* [215] 0x80070020: Cannot create the Desktop AppX container for package
* <full name> because an error was encountered converting the job.
* [208] 0x80070020: Cannot create the process for package <full name>
* because an error was encountered while configuring runtime.
* [LaunchProcess]
*
* 0x80070020 is ERROR_SHARING_VIOLATION, which Windows surfaces to the user as
* "The process cannot access the file because it is being used by another
* process" — a file-lock message for what is really a container-layer failure.
*
* Observed live on windows-11-arm: the payload's own bundled-git gpg-agent.exe
* (under tools/git-<version>/usr/bin/) had daemonized, outlived its package by
* a week, and pinned the family. Killing that ONE pid turned [215]/[208] into
* [210] Created / [211] Added on the very next activation, with nothing else
* changed.
*
* Why the platform does not save us. Deployment already asks for the shutdown
* (ForceApplicationShutdownOption + ForceTargetApplicationShutdownOption) and
* it still missed the daemon; the old package folder then failed to move with
* `0x80070005` and Windows logged `[503] The file system entries ... could not
* be cleaned up after reboot. The package is removed from the purge list.`
* Once that happens not even a reboot clears it. Our daemon defeated the
* platform's own mechanism, so the reap is ours to do.
*
* Why not a process-TREE kill. `taskkill /T` walks parentage, and a daemon that
* detaches is by construction no longer in our tree — that is what daemonizing
* means. Tree scoping is the actual bug. Install-root scoping is what matches
* the silo: the pin is "a process whose image lives under this package", so
* that is exactly the predicate to kill on.
*
* Why not a gpg-specific kill. `gpgconf --kill all` is scoped by GNUPGHOME, not
* by install root, so it can reach the user's OWN gpg-agent outside our package
* — real collateral harm for a case the generic path reap already covers. One
* mechanism, no per-daemon special cases: any future payload tool that
* daemonizes is caught with no new code.
*
* Scoping. Two roots hold binaries this install owns, and either can hold the
* straggler:
*
* 1. The artifact itself — resources/ on a bundled/MSIX install, which
* contains agent-payload (repo + venv + its own tool store).
* 2. The per-install managed tool store — where pm stages node, git, uv,
* ripgrep and friends for a MUTABLE install (an install.ps1 / install.sh
* checkout). That is HERMES_RUNTIME_DIR when set, else <hermes root>/tools.
* A bundled payload sets HERMES_RUNTIME_DIR at its own store, so the two
* roots coincide there and the overlap is harmless.
*
* A mutable install has exactly the same daemon problem with none of the MSIX
* framing: node tooling, a language server, or bundled git's gpg-agent under
* <hermes root>/tools keeps running and holds an open handle on the very files
* the next `hermes update` wants to replace. On Windows a running image cannot
* be overwritten, which is the same 0x80070020 the user reads as "used by
* another process". Reaping both roots covers both install shapes with one
* predicate.
*
* The roots are deliberately the CURRENT install's, not the package family or
* every Hermes on the box: once this ships, each shutdown reaps its own
* processes, so no install leaves a daemon behind. Cleaning up a straggler
* that a PREVIOUS version already leaked needs a repair path outside the
* install (the app cannot launch to clean up after itself) and is tracked
* separately.
*
* Live-verified on windows-11-arm at Medium Mandatory Level (admin=False, the
* integrity level the app actually runs at): all 6 package-rooted processes
* enumerated, all killed, 0 remaining, 0 with an unreadable path — then the
* next activation logged [210]/[211] and the app came up clean.
*
* Pure module: no electron import, no process globals reached directly, every
* dependency injected — so the predicate and the ordering are asserted against
* the real function rather than by grepping main.ts.
/** Clean up detached package tools without touching shared runtimes.
* Package-owned gpg-agent can outlive its parent and pin an MSIX silo,
* preventing subsequent activation. A root claim never includes the shared
* tool store. Interpreters, launchers, unidentified processes and their
* descendants belong to other lifecycle owners and are protected.
*/
/** One running process, reduced to what the reap decision needs. */
export interface RunningProcess {
pid: number
/**
* Parent pid (Win32_Process.ParentProcessId), or null when unavailable.
* Descendants of a live Hermes runtime process are protected too — see
* reapPackageRootedProcesses.
*/
parentPid: number | null
/**
* Absolute path of the process image, or null when it cannot be read.
* A path we cannot read is never a match — see reapPackageRootedProcesses.
*/
path: string | null
/**
* Full command line, or null when it cannot be read. A live Hermes runtime
* process is identified by its command line and never reaped — see
* protectedRuntimePids. An UNREADABLE command line is conservatively
* skipped as well: the cost of missing one pinner is the bug we already
* have; killing a process we could not identify is unbounded damage.
*/
commandLine: string | null
}
export interface ReapPackageRootedProcessesDeps {
/**
* Absolute roots to scope on: the artifact resources dir and/or the managed
* tool store. Empty/nullish entries are ignored, and an empty list disables
* Absolute roots to scope on: the artifact resources dir (never the shared
* tool store). Empty/nullish entries are ignored, and an empty list disables
* the reap entirely.
*/
installRoots: ReadonlyArray<string | null | undefined>
@@ -127,15 +64,24 @@ export interface ReapOutcome {
skipped: boolean
}
/** Shared interpreters and entry points are not owned by a desktop window.
* Do not parse argv to decide which Python flags or launcher forms qualify.
* The updater owns their lifecycle; quit only sweeps detached tools.
*/
function isSharedRuntime(imagePath: string): boolean {
const image = imagePath.replace(/^.*[\\/]/, '').toLowerCase()
return /^(?:pythonw?(?:[0-9.]+)?|node|hermes(?:-agent|-acp)?)(?:\.exe|\.cmd)?$/.test(image)
}
/**
* Enumerate running processes with their image paths, via PowerShell's
* Get-Process.
* Enumerate running processes with image path, parent pid and command line,
* via PowerShell's Win32_Process query (Get-Process does not carry the
* command line, and the ownership decision needs it).
*
* Live-verified at Medium Mandatory Level (admin=False): all package-rooted
* processes were enumerated with readable paths and killed, so this needs no
* elevation and no WMI. `.Path` throws for processes the caller cannot open,
* which is why each read is guarded and reported as a null path rather than
* aborting the sweep.
* Probed READ-ONLY on windows-11-arm at Medium Mandatory Level (admin=False,
* the integrity level the app actually runs at): all processes enumerated
* with readable pids/parents and ~45% null ExecutablePath/CommandLine —
* each null is reported as such rather than aborting the sweep.
*
* Bounded and best effort: this runs on the quit path, so it takes a hard
* timeout and returns an empty list rather than delaying shutdown.
@@ -145,9 +91,8 @@ export function listWindowsProcesses(
): RunningProcess[] {
const script =
'$ErrorActionPreference = "SilentlyContinue"; ' +
'Get-Process | ForEach-Object { ' +
'$p = $null; try { $p = $_.Path } catch { $p = $null }; ' +
'"{0}|{1}" -f $_.Id, $p }'
'Get-CimInstance Win32_Process | ForEach-Object { ' +
'"{0}|{1}|{2}|{3}" -f $_.ProcessId, $_.ParentProcessId, $_.ExecutablePath, $_.CommandLine }'
const stdout = execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
timeout: 10_000,
@@ -157,20 +102,29 @@ export function listWindowsProcesses(
const processes: RunningProcess[] = []
for (const line of String(stdout).split(/\r?\n/)) {
const separator = line.indexOf('|')
const first = line.indexOf('|')
const second = first >= 0 ? line.indexOf('|', first + 1) : -1
const third = second >= 0 ? line.indexOf('|', second + 1) : -1
if (separator <= 0) {
if (first <= 0 || second < 0 || third < 0) {
continue
}
const pid = Number.parseInt(line.slice(0, separator), 10)
const pid = Number.parseInt(line.slice(0, first), 10)
const parentPid = Number.parseInt(line.slice(first + 1, second), 10)
if (!Number.isInteger(pid)) {
continue
}
const imagePath = line.slice(separator + 1).trim()
processes.push({ pid, path: imagePath === '' ? null : imagePath })
const imagePath = line.slice(second + 1, third).trim()
processes.push({
pid,
parentPid: Number.isInteger(parentPid) ? parentPid : null,
path: imagePath === '' ? null : imagePath,
commandLine: line.slice(third + 1)
})
}
return processes
@@ -217,13 +171,63 @@ export function isUnderInstallRoot(
}
/**
* Kill every process whose image lives under the install root.
* The set of pids the reap must never touch: every shared runtime or unidentified
* process plus ALL of its descendants (Win32_Process parentage). The virtualenv
* launcher/worker chain — the gateway's python spawning venv shims, workers,
* and in-flight tools like git — is exactly how a live runtime's children come
* to be rooted in the payload, so parentage, not just the launcher argv, is
* the ownership boundary. A stale ParentProcessId that happens to point at a
* runtime pid errs on the protected side: conservative by design.
*/
export function protectedRuntimePids(running: RunningProcess[]): Set<number> {
const children = new Map<number, number[]>()
for (const entry of running) {
if (entry.parentPid == null) {
continue
}
const siblings = children.get(entry.parentPid)
if (siblings) {
siblings.push(entry.pid)
} else {
children.set(entry.parentPid, [entry.pid])
}
}
const protectedPids = new Set<number>()
for (const entry of running) {
if (!entry.path || !entry.commandLine?.trim() || isSharedRuntime(entry.path)) {
protectedPids.add(entry.pid)
}
}
const queue = [...protectedPids]
while (queue.length > 0) {
const parent = queue.pop() as number
for (const child of children.get(parent) ?? []) {
if (!protectedPids.has(child)) {
protectedPids.add(child)
queue.push(child)
}
}
}
return protectedPids
}
/**
* Reap package-rooted tools outside the protected runtime trees.
*
* Best effort by construction: this runs on the quit path, so a failure to
* enumerate or to kill is logged and swallowed rather than allowed to hang or
* crash the shutdown. A process whose path cannot be read is left alone — the
* cost of missing one pinner is the bug we already have, while killing a
* process we could not identify is unbounded damage.
* crash the shutdown. A process whose path OR command line cannot be read is
* left alone — the cost of missing one pinner is the bug we already have,
* while killing a process we could not identify is unbounded damage.
*/
export function reapPackageRootedProcesses(deps: ReapPackageRootedProcessesDeps): ReapOutcome {
const isWindows = deps.isWindows ?? process.platform === 'win32'
@@ -246,12 +250,24 @@ export function reapPackageRootedProcesses(deps: ReapPackageRootedProcessesDeps)
}
const excluded = new Set<number>(deps.excludePids ?? [])
const runtimeProtected = protectedRuntimePids(running)
const killed: number[] = []
const failed: number[] = []
let matched = 0
for (const candidate of running) {
if (!Number.isInteger(candidate.pid) || candidate.pid === deps.selfPid || excluded.has(candidate.pid)) {
if (
!Number.isInteger(candidate.pid) ||
candidate.pid === deps.selfPid ||
excluded.has(candidate.pid) ||
runtimeProtected.has(candidate.pid)
) {
continue
}
// Conservative skip: an unreadable command line cannot prove the process
// is not a live Hermes runtime, so it is never a candidate.
if (typeof candidate.commandLine !== 'string' || candidate.commandLine.trim() === '') {
continue
}
@@ -110,7 +110,7 @@ test('100 real child processes never exceed twelve simultaneous local slots', as
const limit = 12
const coordinator = new LocalBackendSpawnCoordinator(limit)
const livePids = new Set<number>()
const seenPids = new Set<number>()
let completed = 0
let maxLive = 0
await Promise.all(
@@ -124,7 +124,6 @@ test('100 real child processes never exceed twelve simultaneous local slots', as
assert.ok(child.pid)
livePids.add(child.pid)
seenPids.add(child.pid)
maxLive = Math.max(maxLive, livePids.size)
await new Promise<void>((resolve, reject) => {
@@ -139,13 +138,14 @@ test('100 real child processes never exceed twelve simultaneous local slots', as
})
livePids.delete(child.pid)
completed += 1
} finally {
release()
}
})
)
assert.equal(seenPids.size, 100)
assert.equal(completed, 100) // PID values may be reused after a child exits.
assert.equal(maxLive, limit)
assert.equal(livePids.size, 0)
assert.equal(coordinator.activeCount, 0)
+5 -4
View File
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict'
import path from 'node:path'
import { test } from 'vitest'
@@ -36,7 +37,7 @@ test('collectSshConfigHosts follows Include directives (read-only)', () => {
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null
readFile: p => files[p.replaceAll(path.sep, "/")] ?? null
})
assert.deepEqual(hosts.sort(), ['deep', 'home-abs', 'main', 'work-box'].sort())
@@ -54,7 +55,7 @@ test('collectSshConfigHosts does not loop on a self-include cycle', () => {
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null
readFile: p => files[p.replaceAll(path.sep, "/")] ?? null
})
assert.deepEqual(hosts.sort(), ['a', 'b'])
@@ -69,9 +70,9 @@ test('collectSshConfigHosts expands globbed includes via injected globSync', ()
const hosts = collectSshConfigHosts('/home/u/.ssh/config', {
homeDir: '/home/u',
readFile: p => files[p] ?? null,
readFile: p => files[p.replaceAll(path.sep, "/")] ?? null,
globSync: pattern =>
pattern.endsWith('config.d/*') ? ['/home/u/.ssh/config.d/10-work', '/home/u/.ssh/config.d/20-home'] : [pattern]
pattern.replaceAll(path.sep, '/').endsWith('config.d/*') ? ['/home/u/.ssh/config.d/10-work', '/home/u/.ssh/config.d/20-home'] : [pattern]
})
assert.deepEqual(hosts.sort(), ['home', 'root', 'work'].sort())
+12 -12
View File
@@ -87,7 +87,7 @@ test('controlSocketPath is stable, short, and host-distinct', () => {
assert.equal(a, a2, 'same triple → same socket (ControlMaster reuse)')
assert.notEqual(a, b, 'different host → different socket')
// 16 hex chars + .sock keeps the basename short for sun_path 104-byte limit
assert.match(a, /\/[0-9a-f]{16}\.sock$/)
assert.match(path.basename(a), /^[0-9a-f]{16}\.sock$/)
})
test('controlSocketPath default base stays under sun_path even with the temp-listener suffix', () => {
@@ -279,7 +279,7 @@ test('open() establishes the master when not already alive', async () => {
return { code: 0 }
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: '/tmp/d' })
await conn.open()
assert.deepEqual(ops, ['check', 'master'], 'probes liveness first, then opens the master')
})
@@ -311,7 +311,7 @@ test('open() is a no-op when the master is already alive and execs verify', asyn
return { code: 0 } // check succeeds → alive; verify exec succeeds → trusted
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: '/tmp/d' })
await conn.open()
assert.deepEqual(ops, ['check', 'verify'], 'alive master is exec-verified, then trusted without reopening')
})
@@ -346,7 +346,7 @@ test('open() evicts a wedged master (check passes, exec hangs) and dials fresh',
return { code: 0 }
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d', connectTimeoutMs: 50 })
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: '/tmp/d', connectTimeoutMs: 50 })
await conn.open()
assert.deepEqual(
@@ -372,7 +372,7 @@ test('close() removes the control socket when -O exit fails', async () => {
return { code: 255, stderr: 'mux: master gone' } // -O exit fails
})
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: dir })
await conn.open()
fs.writeFileSync(conn.controlPath, '') // simulate the lingering socket file
await conn.close()
@@ -384,7 +384,7 @@ test('open() creates the control-socket directory if it does not exist', async (
const dir = path.join(os.tmpdir(), `hermes-ssh-test-${process.pid}-${Date.now()}`)
assert.ok(!fs.existsSync(dir), 'precondition: control dir absent')
const spawnFn = scriptedSpawn(args => (args.includes('check') ? { code: 255 } : { code: 0 }))
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: dir })
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: dir })
try {
await conn.open()
@@ -451,7 +451,7 @@ test('exec() treats a hung ssh as a timeout (half-open connection)', async () =>
test('forward() issues -O forward with a loopback-bound -L spec', async () => {
const spawnFn = scriptedSpawn([{ code: 0 }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: '/tmp/d' })
await conn.forward(5000, 6000)
const args = spawnFn.calls[0]
assert.equal(args[0], '-O')
@@ -465,7 +465,7 @@ test('lifecycle logging passes through redaction', async () => {
const conn = new SshConnection(
{ host: 'box', user: 'me' },
{ spawnFn, controlDir: '/tmp/d', rememberLog: l => logs.push(l) }
{ spawnFn, mux: true, controlDir: '/tmp/d', rememberLog: l => logs.push(l) }
)
await conn.open()
@@ -920,7 +920,7 @@ test('runSsh delivers stdinData to the child and does not log it', async () => {
assert.equal(stdinWritten, 'secret-token-value', 'stdinData must be written to child.stdin')
})
test('open() rejects a control-dir that is a symlink', async () => {
test.runIf(process.platform !== 'win32')('open() rejects a control-dir that is a symlink', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ssh-test-'))
const real = path.join(tmp, 'real')
const link = path.join(tmp, 'link')
@@ -1006,7 +1006,7 @@ test('closing one scope addresses only that scope control master', async () => {
{ host: 'box', user: 'me' },
{
spawnFn: firstSpawn,
controlDir: '/tmp/d',
mux: true, controlDir: '/tmp/d',
ownershipId: 'installation',
scope: 'first'
}
@@ -1016,7 +1016,7 @@ test('closing one scope addresses only that scope control master', async () => {
{ host: 'box', user: 'me' },
{
spawnFn: secondSpawn,
controlDir: '/tmp/d',
mux: true, controlDir: '/tmp/d',
ownershipId: 'installation',
scope: 'second'
}
@@ -1037,7 +1037,7 @@ test('failed ControlMaster close disowns the master instead of retrying it', asy
// contract: a master that refuses -O exit is disowned — socket dropped,
// connection marked closed — so the next open dials fresh.
const spawnFn = scriptedSpawn([{ code: 255, stderr: 'master refused exit' }])
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, controlDir: '/tmp/d' })
const conn = new SshConnection({ host: 'box', user: 'me' }, { spawnFn, mux: true, controlDir: '/tmp/d' })
conn._opened = true
await conn.close()
assert.equal(conn._opened, false)
+1 -42
View File
@@ -102,53 +102,12 @@ function writeMsixExtensions() {
</uap3:Properties>
</uap3:AppExtension>
</uap3:Extension>
${aliases}${serviceExtensions()}`
${aliases}`
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, copilot)
}
/**
* The desktop6:Service fragment registering HermesGateway as a Windows
* Service (plan: gateway-msix-windows-service; Task 2).
*
* The service Executable is the payload's own launcher (hermes.exe —
* the distlib-minted PE that already serves the AppExecutionAliases):
* its `gateway run --service` mode is the SCM frontend
* (gateway/windows_service.py). NO separate shim binary.
*
* Settled decisions baked here (2026-09-03, ethie): user-context
* service (StartAccount omitted — desktop6 defaults to the installing
* user's context; localSystem explicitly rejected), demand StartupType
* (config-only posture: the service arrives STOPPED; `hermes gateway
* service on` flips it to automatic-at-logon).
*
* Content rules (the 0x80080204 playbook): xmlns:desktop6 rides the
* fragment root; ONE extension block; Executable must name a real in-
* package exe. Requires Win10 1903+ — the minVersion bump in
* gen-msix-manifest.mjs matches. Exported pure for tests.
*/
export function serviceExtensions({
name = 'HermesGateway',
executable = ['app', 'resources', 'agent-payload', 'bin', 'hermes.exe'].join(String.fromCharCode(92)),
variant = 'bundled',
} = {}) {
// light variant ships no payload → no service; the store variant is
// decided by Store policy separately (plan risk item) — both render
// service-less for now.
if (variant === 'light' || variant === 'store') return ''
return `<desktop6:Extension
xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"
Category="windows.service"
Executable="${executable}"
EntryPoint="Windows.FullTrustApplication">
<desktop6:Service
Name="${name}"
StartupType="demand" />
</desktop6:Extension>
`
}
/**
* One uap5:Extension block per payload CLI launcher, each naming its own
* Executable (the distlib-minted launcher exes under bin/) and the alias
@@ -14,45 +14,74 @@ Exit codes:
0 no update available (or no App Installer source — nothing to do)
2 update available (the caller decides whether to prompt/teardown)
1 error (update availability unknown; the caller reports it honestly)
API shape (pywinrt projection, verified against the installed winrt 3.2.1):
``Package.current`` is a static PROPERTY; the update check is the instance
method ``Package.check_update_availability_async`` — ``PackageManager`` has
no such method. ``PackageUpdateAvailability`` members (verified live in the
installed projection): UNKNOWN=0, NO_UPDATES=1, AVAILABLE=2, REQUIRED=3,
ERROR=4. Only the specific "no package identity" HRESULT (0x80073D54) means
not-packaged; every other failure is an unknown, never a "no update".
"""
import json
import sys
# HRESULT 0x80073D54 — "The process has no package identity." The one
# failure that positively identifies a non-packaged (dev) run.
_NO_PACKAGE_IDENTITY_HRESULT = -2147009196
def _load_projection():
from winrt.windows.applicationmodel import Package, PackageUpdateAvailability
return Package, PackageUpdateAvailability
def main() -> int:
try:
from winrt.windows.applicationmodel import Package, PackageUpdateAvailability
from winrt.windows.management.deployment import PackageManager
Package, PackageUpdateAvailability = _load_projection()
except ImportError as exc:
# The winrt module is absent (older payload without the dep): the
# update check cannot run. Report unknown, not "no update".
print(json.dumps({"available": None, "error": f"winrt import failed: {exc}"}))
return 1
# Property access, not a call: a packaged process gets the Package
# instance; a dev run raises the package-identity HRESULT.
try:
package = Package.current()
except Exception as exc: # noqa: BLE001 — the API raises on non-packaged runs
# Not running as a packaged app (dev run) — nothing to check.
print(json.dumps({"available": False, "reason": "not-packaged"}))
return 0
package = Package.current
except OSError as exc:
if getattr(exc, "winerror", None) == _NO_PACKAGE_IDENTITY_HRESULT:
print(json.dumps({"available": False, "reason": "not-packaged"}))
return 0
print(json.dumps({"available": None, "error": f"package identity failed: {exc}"}))
return 1
except Exception as exc: # noqa: BLE001 — projection shape surprises are unknowns
print(json.dumps({"available": None, "error": f"package identity failed: {exc}"}))
return 1
package_family_name = package.id.family_name
manager = PackageManager()
try:
result = manager.check_package_update_availability_async(package_family_name).get()
result = package.check_update_availability_async().get()
availability = PackageUpdateAvailability(result.availability)
except Exception as exc: # noqa: BLE001
print(json.dumps({"available": None, "error": f"check failed: {exc}"}))
return 1
availability = PackageUpdateAvailability(result.availability)
# Available / Required both mean "a newer package exists on the source".
available = availability in (
PackageUpdateAvailability.AVAILABLE,
PackageUpdateAvailability.REQUIRED,
)
print(json.dumps({"available": available, "availability": str(availability)}))
return 2 if available else 0
# Only AVAILABLE / REQUIRED mean "a newer package exists". NO_UPDATES is
# the one honest "no". UNKNOWN and ERROR are NOT "no update" — reporting
# them as such would suppress a real update prompt — so they surface as
# an unknown with the availability name as the extended error.
if availability == PackageUpdateAvailability.NO_UPDATES:
print(json.dumps({"available": False, "availability": availability.name}))
return 0
if availability in (PackageUpdateAvailability.AVAILABLE, PackageUpdateAvailability.REQUIRED):
print(json.dumps({"available": True, "availability": availability.name}))
return 2
print(json.dumps({"available": None, "error": f"availability unknown: {availability.name}"}))
return 1
if __name__ == "__main__":
+32 -49
View File
@@ -1,10 +1,15 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { test } from 'vitest'
import { CLI_LAUNCHER_SPECS, posixTrampolineScripts, renderWinWrapper } from '../../../scripts/desktop-cli/cli-entrypoints.mjs'
import {
CLI_LAUNCHER_SPECS,
posixTrampolineScripts,
renderWinWrapper
} from '../../../scripts/desktop-cli/cli-entrypoints.mjs'
import { appExecutionAliasExtensions } from './before-build.mjs'
const scriptDir = path.dirname(fileURLToPath(import.meta.url))
@@ -18,18 +23,23 @@ const RELS = {
}
test('three launcher specs mirror [project.scripts] in pyproject.toml', () => {
assert.deepEqual(CLI_LAUNCHER_SPECS.map((s) => s.name), ['hermes', 'hermes-agent', 'hermes-acp'])
assert.deepEqual(CLI_LAUNCHER_SPECS.map((s) => `${s.module}:${s.func}`), [
'hermes_cli.main:main',
'run_agent:main',
'acp_adapter.entry:main'
])
assert.deepEqual(
CLI_LAUNCHER_SPECS.map(s => s.name),
['hermes', 'hermes-agent', 'hermes-acp']
)
assert.deepEqual(
CLI_LAUNCHER_SPECS.map(s => `${s.module}:${s.func}`),
['hermes_cli.main:main', 'run_agent:main', 'acp_adapter.entry:main']
)
})
const scripts = posixTrampolineScripts(RELS)
test('one POSIX trampoline per entry, named plainly (no suffix)', () => {
assert.deepEqual(scripts.map((s) => s.name), ['hermes', 'hermes-agent', 'hermes-acp'])
assert.deepEqual(
scripts.map(s => s.name),
['hermes', 'hermes-agent', 'hermes-acp']
)
})
test('trampoline is $0-relative: shebang, symlink-chain resolution, own-dir cd', () => {
@@ -70,7 +80,7 @@ test('trampoline fails loudly (exit 2) when the bundled interpreter is missing',
})
test('each trampoline execs its own entry module', () => {
const modules = scripts.map((s) => /exec "\$PYTHON" -m (\S+) "\$@"/.exec(s.text)?.[1])
const modules = scripts.map(s => /exec "\$PYTHON" -m (\S+) "\$@"/.exec(s.text)?.[1])
assert.deepEqual(modules, ['hermes_cli.main', 'run_agent', 'acp_adapter.entry'])
})
@@ -116,45 +126,18 @@ test('light variant emits no alias fragments', () => {
assert.ok(scriptDir.length > 0)
})
// ── HermesGateway Windows Service fragment (Task 2, plan:
// gateway-msix-windows-service) ────────────────────────────────────────
import { serviceExtensions } from './before-build.mjs'
// ── HermesGateway Windows Service fragment (removed) ──────────────────────
// The MSIX SCM service feature was removed (settled 2026-09-03: the existing
// user-logon Scheduled Task supervises the gateway; a desktop6:Service
// cannot run as the installing user — the desktop6 schema requires
// StartAccount in localSystem|localService|networkService). This pins the
// removal so the invalid fragment cannot silently come back.
test('bundled variant registers HermesGateway, demand-start, launcher exe', () => {
const frag = serviceExtensions()
assert.ok(frag.includes('Category="windows.service"'), 'service category')
assert.ok(frag.includes('Name="HermesGateway"'), 'the service name the CLI verbs key off')
assert.ok(frag.includes('StartupType="demand"'), 'config-only posture: arrives stopped')
// Compose the expected path the same way the source does — no escaping
// ambiguity (the appExecutionAliasExtensions precedent).
const bs = String.fromCharCode(92)
const launcherPath = ['app', 'resources', 'agent-payload', 'bin', 'hermes.exe'].join(bs)
assert.ok(
frag.includes(`Executable="${launcherPath}"`),
'the service Executable is the payload launcher (no shim binary)'
)
assert.ok(!frag.includes('StartAccount'), 'user-context by default — localSystem rejected')
})
test('light and store variants render service-less', () => {
assert.equal(serviceExtensions({ variant: 'light' }), '')
assert.equal(serviceExtensions({ variant: 'store' }), '')
})
test('one desktop6 extension block with xmlns on the fragment root', () => {
const frag = serviceExtensions()
assert.equal(
(frag.match(/<desktop6:Extension/g) || []).length,
1,
'the 0x80080204 playbook: ONE extension block'
)
assert.ok(
frag.includes('xmlns:desktop6="http://schemas.microsoft.com/appx/manifest/desktop/windows10/6"'),
'namespace rides the fragment root (the copilot-fragment precedent)'
)
})
test('custom name propagates (per-install namespacing)', () => {
const frag = serviceExtensions({ name: 'HermesGateway_Tag1' })
assert.ok(frag.includes('Name="HermesGateway_Tag1"'))
test('the manifest extension writer registers no windows.service', () => {
// writeMsixExtensions is the one writer; its source must carry no
// desktop6:Service emission (this is a removal pin, not a shape snapshot:
// any NEW extension fragments may be added freely).
const source = fs.readFileSync(new URL('./before-build.mjs', import.meta.url), 'utf8')
assert.ok(!source.includes('windows.service'), 'no windows.service Category emitted')
assert.ok(!source.includes('desktop6:Service'), 'no desktop6:Service fragment emitted')
})
+4 -2
View File
@@ -105,8 +105,10 @@ const manifest = substituteManifestMacros(template, (m) => {
case "resourceLanguages": return resourceLanguageTag(options.languages)
case "capabilities": return `<Capabilities>\n${buildCapabilitiesXml(options.capabilities)}\n</Capabilities>`
case "extensions": return extensions
// Win10 1903 (build 18362) floor: desktop6:Service — the MSIX-shipped
// HermesGateway Windows Service — requires 1903+; settled 2026-09-03.
// Win10 1903 (build 18362) floor, settled 2026-09-03. (An earlier
// desktop6:Service fragment rode this floor; the service feature was
// removed — the Scheduled Task supervises the gateway instead — but the
// shipped floor stays put rather than churning the release minimum.)
// 1809 is a 2018 OS; the bump rides the release notes.
case "minVersion": return options.minVersion || "10.0.18362.0"
case "maxVersionTested": return options.maxVersionTested || options.minVersion || "10.0.18362.0"
@@ -594,7 +594,7 @@ test('darwin staging ships the Swift helper executable and the rewritten windows
stageGetWindowsInto(srcRoot, destRoot, { platform: 'darwin' })
assert.equal(fs.statSync(join(destRoot, 'main')).mode & 0o777, 0o755)
if (process.platform !== 'win32') assert.equal(fs.statSync(join(destRoot, 'main')).mode & 0o777, 0o755)
const staged = fs.readFileSync(join(destRoot, 'lib', 'windows.js'), 'utf8')
assert.match(staged, /Rewritten by stage-native-deps\.mjs/)
assert.ok(!staged.includes('node-pre-gyp'), 'pre-gyp loader must not survive staging')
@@ -1,10 +1,31 @@
import { describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { DesktopUpdateStatus } from '@/global'
import type { DesktopUpdateStatus, DesktopVersionInfo } from '@/global'
import { en } from '@/i18n/en'
import type { UpdateApplyState } from '@/store/updates'
import { deriveUpdateStatus } from './update-status'
import { deriveUpdateStatus, VersionHero } from './update-status'
// VersionHero is the shared About/overlay hero. Its module imports the real
// updates store graph; mock it shallowly — these tests exercise the hero's
// own rendering, not the store.
vi.mock('@/store/updates', async () => {
const { atom } = await import('nanostores')
return {
$backendUpdateApply: atom<UpdateApplyState | null>(null),
$backendUpdateChecking: atom<boolean>(false),
$backendUpdateStatus: atom<DesktopUpdateStatus | null>(null),
$updateApply: atom<UpdateApplyState | null>(null),
$updateChecking: atom<boolean>(false),
$updateStatus: atom<DesktopUpdateStatus | null>(null),
checkBackendUpdates: vi.fn(),
checkUpdates: vi.fn(),
openUpdateOverlayFor: vi.fn(),
startActiveUpdate: vi.fn()
}
})
const IDLE_APPLY: UpdateApplyState = {
applying: false,
@@ -89,3 +110,66 @@ describe('deriveUpdateStatus', () => {
expect(view.line).toBe(en.updates.latestBodyBackend)
})
})
describe('VersionHero bundle banners', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
const version = (over: Partial<DesktopVersionInfo>): DesktopVersionInfo =>
({ appVersion: '0.19.0', ...over }) as DesktopVersionInfo
const stubRelaunch = () => {
const relaunchApp = vi.fn().mockResolvedValue(undefined)
const original = window.hermesDesktop
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { ...original, openExternal: vi.fn(), relaunchApp }
})
return relaunchApp
}
// FAIL-BEFORE (C19): the shared About extraction dropped the swap-pending
// restart affordance even though main still produces bundleSwapPending and
// exposes the relaunchApp IPC — users could no longer finish an applied
// update from the UI.
it('swap-pending: restart banner wired to relaunchApp, not the installer link', () => {
const relaunchApp = stubRelaunch()
render(<VersionHero version={version({ bundleSwapPending: true })} />)
expect(screen.getByText(en.updates.bundleSwapPending)).toBeTruthy()
expect(screen.queryByText(en.updates.bundleOutOfSync)).toBeNull()
fireEvent.click(screen.getByRole('button', { name: en.updates.bundleSwapPendingAction }))
expect(relaunchApp).toHaveBeenCalledTimes(1)
})
it('out-of-sync without a pending swap: keeps the get-installer banner', () => {
stubRelaunch()
render(<VersionHero version={version({ bundleOutOfSync: true })} />)
expect(screen.getByText(en.updates.bundleOutOfSync)).toBeTruthy()
expect(screen.queryByText(en.updates.bundleSwapPending)).toBeNull()
expect(screen.queryByRole('button', { name: en.updates.bundleSwapPendingAction })).toBeNull()
})
it('a pending swap wins over the out-of-sync banner — restart, not reinstall', () => {
stubRelaunch()
render(<VersionHero version={version({ bundleOutOfSync: true, bundleSwapPending: true })} />)
expect(screen.getByText(en.updates.bundleSwapPending)).toBeTruthy()
expect(screen.queryByText(en.updates.bundleOutOfSync)).toBeNull()
})
it('no flags: no banner at all', () => {
render(<VersionHero version={version({})} />)
expect(screen.queryByText(en.updates.bundleOutOfSync)).toBeNull()
expect(screen.queryByText(en.updates.bundleSwapPending)).toBeNull()
})
})
+30 -16
View File
@@ -154,27 +154,41 @@ export function VersionHero({
{version?.appVersion ? u.version(version.appVersion) : u.versionUnavailable}
</p>
</div>
{version?.bundleOutOfSync && (
{(version?.bundleSwapPending || version?.bundleOutOfSync) && (
<div className="mx-auto w-full max-w-2xl rounded-xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-left text-sm">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="min-w-0">
<p className="font-medium">{u.bundleOutOfSync}</p>
<p className="mt-1 text-xs text-muted-foreground">{u.bundleOutOfSyncDesc}</p>
<Button asChild className="mt-2" size="sm" variant="textStrong">
<a
href={INSTALLER_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(INSTALLER_URL)
}}
rel="noreferrer"
target="_blank"
<p className="font-medium">{version.bundleSwapPending ? u.bundleSwapPending : u.bundleOutOfSync}</p>
<p className="mt-1 text-xs text-muted-foreground">
{version.bundleSwapPending ? u.bundleSwapPendingDesc : u.bundleOutOfSyncDesc}
</p>
{version.bundleSwapPending ? (
<Button
className="mt-2"
onClick={() => void window.hermesDesktop?.relaunchApp?.()}
size="sm"
variant="textStrong"
>
<ExternalLink className="size-3" />
{u.bundleOutOfSyncAction}
</a>
</Button>
<RefreshCw className="size-3" />
{u.bundleSwapPendingAction}
</Button>
) : (
<Button asChild className="mt-2" size="sm" variant="textStrong">
<a
href={INSTALLER_URL}
onClick={event => {
event.preventDefault()
void window.hermesDesktop?.openExternal?.(INSTALLER_URL)
}}
rel="noreferrer"
target="_blank"
>
<ExternalLink className="size-3" />
{u.bundleOutOfSyncAction}
</a>
</Button>
)}
</div>
</div>
</div>
+3 -33
View File
@@ -673,40 +673,7 @@ export const ar = defineLocale({
'عندما يحدّث Hermes نفسه من التطبيق دون موجه طرفية، احتفظ بتعديلات المصدر المحلية أو تجاهلها.'
},
about: {
heading: 'حول Hermes',
version: value => `الإصدار ${value}`,
versionUnavailable: 'الإصدار غير متاح',
bundleOutOfSync: 'إصدار التطبيق قديم',
bundleOutOfSyncDesc:
'تم تحديث وقت تشغيل Hermes، لكن تطبيق سطح المكتب نفسه لا يزال إصدارًا قديمًا — لن تظهر ميزات الواجهة الجديدة (مثل Bot Mode) حتى يتم تحديث التطبيق. شغّل التحديث أدناه لإعادة بناء التطبيق. إذا لم يختفِ هذا التحذير، فأعد التثبيت من أحدث مثبّت لسطح المكتب.',
bundleOutOfSyncAction: 'الحصول على المثبّت',
bundleSwapPending: 'أعد التشغيل لإكمال التحديث',
bundleSwapPendingDesc:
'تم تثبيت التطبيق المحدَّث بالفعل — يكفي إعادة تشغيل Hermes لتحميله. لن تتأثر المحادثات أو الإعدادات.',
bundleSwapPendingAction: 'إعادة تشغيل Hermes',
updates: 'التحديثات',
checkNow: 'التحقق الآن',
checking: 'جار التحقق...',
seeWhatsNew: 'عرض الجديد',
updateNow: 'تحديث الآن',
releaseNotes: 'ملاحظات الإصدار',
onLatest: 'أنت على أحدث إصدار',
installing: 'جار التثبيت...',
cantUpdate: 'تعذر التحديث',
cantReach: 'تعذر الوصول لخدمة التحديث',
tapCheck: 'اضغط للتحقق من التحديثات.',
updateReady: count => `${count} تحديث متاح`,
updateReadyUnknown: 'تحديث جديد جاهز.',
lastChecked: age => `آخر تحقق ${age}`,
justNowSuffix: 'الآن',
automaticUpdates: 'التحديثات التلقائية',
automaticUpdatesDesc: 'اسمح لـ Hermes بالتحقق من التحديثات وتثبيتها.',
branchCommit: (branch, commit) => `${branch} عند ${commit}`,
never: 'أبدا',
justNow: 'الآن',
minAgo: count => `قبل ${count} دقيقة`,
hoursAgo: count => `قبل ${count} ساعة`,
daysAgo: count => `قبل ${count} يوم`
},
config: {
@@ -2006,6 +1973,9 @@ export const ar = defineLocale({
}
},
updates: {
bundleSwapPending: 'أعد التشغيل لإكمال التحديث',
bundleSwapPendingDesc: 'تم تثبيت التطبيق المحدَّث بالفعل — يكفي إعادة تشغيل Hermes لتحميله. لن تتأثر المحادثات أو الإعدادات.',
bundleSwapPendingAction: 'إعادة تشغيل Hermes',
stages: {
idle: 'جار التحضير...',
prepare: 'جار التحضير...',
+3 -35
View File
@@ -682,42 +682,7 @@ export const en: Translations = {
fieldLabels: FIELD_LABELS,
fieldDescriptions: FIELD_DESCRIPTIONS,
about: {
heading: 'Hermes Desktop',
version: value => `Version ${value}`,
versionUnavailable: 'Version unavailable',
bundleOutOfSync: 'App build out of date',
bundleOutOfSyncDesc:
'The Hermes runtime was updated, but the desktop app itself is still an older build — new interface features (like Bot Mode) will be missing until it updates. Run the update below to rebuild the app. If that doesn\u2019t clear this warning, reinstall from the latest desktop installer.',
bundleOutOfSyncAction: 'Get the installer',
bundleSwapPending: 'Restart to finish the update',
bundleSwapPendingDesc:
'The updated app is already installed — Hermes only needs to restart to load it. Chats and settings are untouched.',
bundleSwapPendingAction: 'Restart Hermes',
updates: 'Updates',
checkNow: 'Check now',
checking: 'Checking…',
seeWhatsNew: "See what's new",
updateNow: 'Update now',
releaseNotes: 'Release notes',
onLatest: "You're on the latest version.",
installing: 'An update is currently installing.',
cantUpdate: "This build can't update itself from inside the app.",
cantReach: "We couldn't reach the update server.",
tapCheck: 'Tap "Check now" to look for updates.',
updateReady: count => `A new update is ready (${count} change${count === 1 ? '' : 's'} included).`,
updateReadyUnknown: 'A new update is ready.',
availableBodyRelease: tag => `Version ${tag} is ready to install.`,
lastChecked: age => `Last checked ${age}`,
justNowSuffix: ' · just now',
automaticUpdates: 'Automatic updates',
automaticUpdatesDesc:
'Hermes checks for updates automatically in the background and lets you know when one is ready.',
branchCommit: (branch, commit) => `Branch ${branch} · Commit ${commit}`,
never: 'never',
justNow: 'just now',
minAgo: count => `${count} min ago`,
hoursAgo: count => `${count} hours ago`,
daysAgo: count => `${count} days ago`
},
config: {
none: 'None',
@@ -2736,6 +2701,9 @@ export const en: Translations = {
},
updates: {
bundleSwapPending: 'Restart to finish the update',
bundleSwapPendingDesc: 'The updated app is already installed — Hermes only needs to restart to load it. Chats and settings are untouched.',
bundleSwapPendingAction: 'Restart Hermes',
stages: {
idle: 'Getting ready…',
prepare: 'Getting ready…',
+3 -33
View File
@@ -724,40 +724,7 @@ export const ja = defineLocale({
}
}),
about: {
heading: 'Hermes Desktop',
version: value => `バージョン ${value}`,
versionUnavailable: 'バージョンを取得できません',
bundleOutOfSync: 'アプリのビルドが古くなっています',
bundleOutOfSyncDesc:
'Hermes ランタイムは更新されましたが、デスクトップアプリ自体は古いビルドのままです。アプリを更新するまで、新しいインターフェース機能(Bot Mode など)は表示されません。下の更新を実行してアプリを再ビルドしてください。それでもこの警告が消えない場合は、最新のデスクトップインストーラーから再インストールしてください。',
bundleOutOfSyncAction: 'インストーラーを入手',
bundleSwapPending: '再起動して更新を完了',
bundleSwapPendingDesc:
'更新されたアプリはすでにインストール済みです。Hermes を再起動するだけで新しいビルドが読み込まれます。チャットや設定はそのまま保持されます。',
bundleSwapPendingAction: 'Hermes を再起動',
updates: '更新',
checkNow: '今すぐ確認',
checking: '確認中…',
seeWhatsNew: '新機能を見る',
updateNow: '今すぐ更新',
releaseNotes: 'リリースノート',
onLatest: '最新バージョンです。',
installing: '更新をインストール中です。',
cantUpdate: 'このビルドはアプリ内から更新できません。',
cantReach: '更新サーバーに接続できませんでした。',
tapCheck: '更新を探すには「今すぐ確認」を押してください。',
updateReady: count => `新しい更新の準備ができました (${count} 件の変更を含みます)。`,
updateReadyUnknown: '新しい更新の準備ができました。',
lastChecked: age => `前回確認: ${age}`,
justNowSuffix: ' · たった今',
automaticUpdates: '自動更新',
automaticUpdatesDesc: 'Hermes はバックグラウンドで自動的に更新を確認し、利用可能になったら通知します。',
branchCommit: (branch, commit) => `ブランチ ${branch} · コミット ${commit}`,
never: '未確認',
justNow: 'たった今',
minAgo: count => `${count} 分前`,
hoursAgo: count => `${count} 時間前`,
daysAgo: count => `${count} 日前`
},
config: {
none: 'なし',
@@ -2356,6 +2323,9 @@ export const ja = defineLocale({
},
updates: {
bundleSwapPending: '再起動して更新を完了',
bundleSwapPendingDesc: '更新されたアプリはすでにインストール済みです。Hermes を再起動するだけで新しいビルドが読み込まれます。チャットや設定はそのまま保持されます。',
bundleSwapPendingAction: 'Hermes を再起動',
stages: {
idle: '準備中…',
prepare: '準備中…',
+3 -30
View File
@@ -904,37 +904,7 @@ export const ru = defineLocale({
}
}),
about: {
heading: 'Hermes Desktop',
version: value => `Версия ${value}`,
versionUnavailable: 'Версия недоступна',
bundleOutOfSync: 'Сборка приложения устарела',
bundleOutOfSyncDesc:
'Рантайм Hermes обновлён, но само приложение — ещё старая сборка: новые функции интерфейса (например, Bot Mode) не появятся до обновления. Запустите обновление ниже, чтобы пересобрать приложение. Если предупреждение не исчезнет, переустановите с последнего установщика.',
bundleOutOfSyncAction: 'Скачать установщик',
updates: 'Обновления',
checkNow: 'Проверить сейчас',
checking: 'Проверка…',
seeWhatsNew: 'Смотреть, что нового',
updateNow: 'Обновить сейчас',
releaseNotes: 'Заметки о выпуске',
onLatest: 'У вас последняя версия.',
installing: 'Сейчас устанавливается обновление.',
cantUpdate: 'Эта сборка не может обновляться изнутри приложения.',
cantReach: 'Не удалось связаться с сервером обновлений.',
tapCheck: 'Нажмите «Проверить сейчас», чтобы найти обновления.',
updateReady: count =>
`Готово новое обновление (включено ${count} ${RU_PLURAL(count, 'изменение', 'изменения', 'изменений')}).`,
updateReadyUnknown: 'Готово новое обновление.',
lastChecked: age => `Проверено ${age}`,
justNowSuffix: ' · только что',
automaticUpdates: 'Автоматические обновления',
automaticUpdatesDesc: 'Hermes автоматически проверяет обновления в фоне и сообщает, когда они готовы.',
branchCommit: (branch, commit) => `Ветка ${branch} · Коммит ${commit}`,
never: 'никогда',
justNow: 'только что',
minAgo: count => `${count} ${RU_NOUN(count, 'минуту', 'минуты', 'минут')} назад`,
hoursAgo: count => `${count} ${RU_NOUN(count, 'час', 'часа', 'часов')} назад`,
daysAgo: count => `${count} ${RU_NOUN(count, 'день', 'дня', 'дней')} назад`
},
config: {
none: 'Нет',
@@ -2756,6 +2726,9 @@ export const ru = defineLocale({
}
},
updates: {
bundleSwapPending: 'Перезапустите, чтобы завершить обновление',
bundleSwapPendingDesc: 'Обновлённое приложение уже установлено — достаточно перезапустить Hermes, чтобы загрузить его. Чаты и настройки не пострадают.',
bundleSwapPendingAction: 'Перезапустить Hermes',
stages: {
idle: 'Готовимся…',
prepare: 'Готовимся…',
+3 -32
View File
@@ -565,39 +565,7 @@ export interface Translations {
fieldLabels: Record<string, string>
fieldDescriptions: Record<string, string>
about: {
heading: string
version: (value: string) => string
versionUnavailable: string
bundleOutOfSync: string
bundleOutOfSyncDesc: string
bundleOutOfSyncAction: string
bundleSwapPending: string
bundleSwapPendingDesc: string
bundleSwapPendingAction: string
updates: string
checkNow: string
checking: string
seeWhatsNew: string
updateNow: string
releaseNotes: string
onLatest: string
installing: string
cantUpdate: string
cantReach: string
tapCheck: string
updateReady: (count: number) => string
updateReadyUnknown: string
availableBodyRelease: (tag: string) => string
lastChecked: (age: string) => string
justNowSuffix: string
automaticUpdates: string
automaticUpdatesDesc: string
branchCommit: (branch: string, commit: string) => string
never: string
justNow: string
minAgo: (count: number) => string
hoursAgo: (count: number) => string
daysAgo: (count: number) => string
}
config: {
none: string
@@ -2329,6 +2297,9 @@ export interface Translations {
}
updates: {
bundleSwapPending: string
bundleSwapPendingDesc: string
bundleSwapPendingAction: string
stages: Record<string, string>
checking: string
checkFailedTitle: string
+3 -33
View File
@@ -705,40 +705,7 @@ export const zhHant = defineLocale({
}
}),
about: {
heading: 'Hermes Desktop',
version: value => `版本 ${value}`,
versionUnavailable: '版本不可用',
bundleOutOfSync: '應用程式建置版本過舊',
bundleOutOfSyncDesc:
'Hermes 執行環境已更新,但桌面應用程式本身仍是舊建置——在應用程式更新之前,新的介面功能(如 Bot Mode)不會顯示。請執行下方的更新以重新建置應用程式。如果此警告仍未消除,請從最新的桌面安裝程式重新安裝。',
bundleOutOfSyncAction: '取得安裝程式',
bundleSwapPending: '重新啟動以完成更新',
bundleSwapPendingDesc:
'更新後的應用程式已安裝完成,只需重新啟動 Hermes 即可載入新版本。聊天記錄和設定不會受到影響。',
bundleSwapPendingAction: '重新啟動 Hermes',
updates: '更新',
checkNow: '立即檢查',
checking: '檢查中…',
seeWhatsNew: '查看新增內容',
updateNow: '立即更新',
releaseNotes: '發行說明',
onLatest: '你已是最新版本。',
installing: '正在安裝更新。',
cantUpdate: '此版本無法從應用程式內自行更新。',
cantReach: '無法連線到更新伺服器。',
tapCheck: '點選「立即檢查」以尋找更新。',
updateReady: count => `新更新已就緒(包含 ${count} 項變更)。`,
updateReadyUnknown: '新更新已就緒。',
lastChecked: age => `上次檢查:${age}`,
justNowSuffix: ' · 剛剛',
automaticUpdates: '自動更新',
automaticUpdatesDesc: 'Hermes 會在背景自動檢查更新,並在有可用更新時通知你。',
branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
never: '從未',
justNow: '剛剛',
minAgo: count => `${count} 分鐘前`,
hoursAgo: count => `${count} 小時前`,
daysAgo: count => `${count} 天前`
},
config: {
none: '無',
@@ -2275,6 +2242,9 @@ export const zhHant = defineLocale({
},
updates: {
bundleSwapPending: '重新啟動以完成更新',
bundleSwapPendingDesc: '更新後的應用程式已安裝完成,只需重新啟動 Hermes 即可載入新版本。聊天記錄和設定不會受到影響。',
bundleSwapPendingAction: '重新啟動 Hermes',
stages: {
idle: '準備中…',
prepare: '準備中…',
+3 -33
View File
@@ -883,40 +883,7 @@ export const zh: Translations = {
}
}),
about: {
heading: 'Hermes Desktop',
version: value => `版本 ${value}`,
versionUnavailable: '版本不可用',
bundleOutOfSync: '应用构建版本过旧',
bundleOutOfSyncDesc:
'Hermes 运行时已更新,但桌面应用本身仍是旧构建——在应用更新之前,新的界面功能(如 Bot Mode)不会显示。请运行下方的更新以重新构建应用。如果此警告仍未消除,请从最新的桌面安装程序重新安装。',
bundleOutOfSyncAction: '获取安装程序',
bundleSwapPending: '重启以完成更新',
bundleSwapPendingDesc: '更新后的应用已安装完成,只需重启 Hermes 即可加载新版本。聊天记录和设置不会受到影响。',
bundleSwapPendingAction: '重启 Hermes',
updates: '更新',
checkNow: '立即检查',
checking: '检查中…',
seeWhatsNew: '查看新增内容',
updateNow: '立即更新',
releaseNotes: '发行说明',
onLatest: '你已是最新版本。',
installing: '正在安装更新。',
cantUpdate: '此版本无法在应用内自我更新。',
cantReach: '无法连接更新服务器。',
tapCheck: '点击"立即检查"以查找更新。',
updateReady: count => `已准备好新更新 (包含 ${count} 项更改)。`,
updateReadyUnknown: '新更新已就绪。',
lastChecked: age => `上次检查:${age}`,
justNowSuffix: ' · 刚刚',
automaticUpdates: '自动更新',
automaticUpdatesDesc: 'Hermes 会在后台自动检查更新,并在有可用更新时通知你。',
branchCommit: (branch, commit) => `分支 ${branch} · 提交 ${commit}`,
never: '从未',
justNow: '刚刚',
minAgo: count => `${count} 分钟前`,
hoursAgo: count => `${count} 小时前`,
daysAgo: count => `${count} 天前`,
availableBodyRelease: (tag: string) => `版本 ${tag} 已可安装。`,
},
config: {
none: '无',
@@ -2899,6 +2866,9 @@ export const zh: Translations = {
},
updates: {
bundleSwapPending: '重启以完成更新',
bundleSwapPendingDesc: '更新后的应用已安装完成,只需重启 Hermes 即可加载新版本。聊天记录和设置不会受到影响。',
bundleSwapPendingAction: '重启 Hermes',
stages: {
idle: '准备中…',
prepare: '准备中…',
+28 -1
View File
@@ -1,6 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { DesktopUpdateStatus } from '@/global'
import { en } from '@/i18n/en'
const storage = new Map<string, string>()
@@ -174,7 +175,7 @@ describe('maybeNotifyUpdateAvailable', () => {
expect(notifySpy).not.toHaveBeenCalled()
})
// FAIL-BEFORE: a shallow installer clone reports behind:null + updateAvailable
// FAIL-BEFORE (#C19): a shallow installer clone reports behind:null + updateAvailable
// (exact count unknowable without a merge-base). The guard treated null as 0
// and silently swallowed the notification entirely.
it('still notifies with generic copy when the exact behind count is unknown', () => {
@@ -182,6 +183,32 @@ describe('maybeNotifyUpdateAvailable', () => {
expect(notifySpy).toHaveBeenCalledTimes(1)
expect(notifySpy.mock.calls[0]?.[0]).toMatchObject({ message: 'A new update is available.' })
})
// FAIL-BEFORE (C19): an App Installer check legitimately carries no
// targetSha — the OS reports availability against the .appinstaller feed,
// so there is no commit identity to name. Requiring targetSha made the
// mechanism-specific App Installer toast unreachable forever.
it('notifies for an App Installer check even though it has no targetSha', () => {
maybeNotifyUpdateAvailable(
status({
behind: null,
currentVersion: '0.19.0',
mechanism: 'app-installer',
targetSha: undefined,
updateAvailable: true
})
)
expect(notifySpy).toHaveBeenCalledTimes(1)
expect(notifySpy.mock.calls[0]?.[0]).toMatchObject({ message: en.notifications.updateReadyMessageAppInstaller })
})
// Eligibility stays mechanism-specific: a git-style check must still prove
// there is a target commit before the toast fires — never a made-up SHA.
it('stays quiet for a git-style check that names no target commit', () => {
maybeNotifyUpdateAvailable(status({ targetSha: undefined }))
expect(notifySpy).not.toHaveBeenCalled()
})
})
describe('reportBackendContract', () => {
+13 -2
View File
@@ -212,8 +212,19 @@ export function reportInstallMethodWarning(message: string | undefined): void {
*/
export function maybeNotifyUpdateAvailable(status: DesktopUpdateStatus | null, target: UpdateTarget = 'client') {
// Either signal means "update ready": behind > 0 (git checkout) or
// updateAvailable (app-installer, shallow clone).
if (!status || status.supported === false || status.error || !status.targetSha) {
// updateAvailable (shallow clone, App Installer feed).
if (!status || status.supported === false || status.error) {
return
}
// Eligibility is mechanism-specific: a git-style check proves an update is
// waiting by naming the target commit (targetSha). An App Installer check
// legitimately has no commit identity — the OS reports availability against
// the .appinstaller feed — so its eligibility signal is updateAvailable
// itself. Never fabricate a SHA to satisfy the key.
const hasTargetIdentity = Boolean(status.targetSha) || status.mechanism === 'app-installer'
if (!hasTargetIdentity) {
return
}
+17 -1
View File
@@ -129,6 +129,20 @@ def _windows_cron_python_invocation(python_exe: str) -> tuple[str, dict[str, str
if sibling.exists():
interpreter = sibling
from hermes_cli._launchers import resolve_store_python
from hermes_cli.runtime_paths import selected_venv, site_packages as dependency_site
repo = Path(__file__).resolve().parents[1]
managed_python = resolve_store_python(repo)
if managed_python is not None:
# A packaged caller may hand us the old venv launcher; select bytes
# from the install record rather than interpreting relocated pyvenv.cfg.
dependencies = dependency_site(selected_venv(repo))
payload_dependencies = venv_dir / "Lib" / "site-packages"
if (venv_dir.parent / "manifest.json").is_file() and payload_dependencies.is_dir():
dependencies = payload_dependencies
return str(managed_python), {"PYTHONPATH": os.pathsep.join([str(repo), str(dependencies)])}
cfg = _read_windows_pyvenv_cfg(venv_dir)
home = cfg.get("home", "")
site_packages = venv_dir / "Lib" / "site-packages"
@@ -234,7 +248,9 @@ def _windows_cron_bootstrap_argv(
the venv on ``PYTHONPATH``, but ``.pth`` files are only processed by ``site.addsitedir()``, so
editable installs would be invisible; bootstrap via addsitedir + ``runpy.run_path`` (keeps
``__file__``/``sys.path[0]`` semantics). Plain invocation if the venv is unresolvable."""
site_packages = _sched.Path(env_overlay.get("VIRTUAL_ENV", "")) / "Lib" / "site-packages"
site_packages = next((Path(item) for item in env_overlay.get("PYTHONPATH", "").split(os.pathsep)
if Path(item).name == "site-packages"),
_sched.Path(env_overlay.get("VIRTUAL_ENV", "")) / "Lib" / "site-packages")
if not site_packages.is_dir():
# Warn: silent fallback would make "editable installs invisible" undiagnosable.
logger.warning(
+76
View File
@@ -0,0 +1,76 @@
# PM audit remediation status
This change integrates repairs from the aggregate branch audit. It is not a
release certificate. The audit compared `49945b14029e09fef608db9ede899377cdb54e11`
with merge base `433f7196760e5d76f77ea6d5464ee7f1b602a4ee`.
## Implemented repairs
| Findings | Implementation |
| --- | --- |
| C01, C11, C12, C24 | Remove the deleted TLS import; fix ACP file decoding, proxy path ownership, and the monitoring SDK call. |
| C02 | Restore code-execution schema, registration, and child lifecycle handling. |
| C03, C04, C17 | Keep update metadata commands read-only; synchronize plugin updates; compare feed identities correctly. |
| C05, C27, C28 | Include managed Python in the install closure; repair installer stage dispatch and runtime selection. |
| C06, C07, C09 | Remove the unsupported SCM surface. Retain Scheduled Task supervision and protect unrelated runtimes on desktop quit. |
| C08, C19 | Correct the App Installer checker and restore meaningful update states. |
| C10 | Repair uninstall dispatch and owned-link removal. |
| C13, C14 | Prepare one complete dependency environment before publication. Preserve declared constraints and explicit pins; permit compatible transitive upgrades. Infrastructure failures do not disable plugins. |
| C15, C18 | Retain recoverable tool-store entries on failed publication. Use atomic facts/config writes and context-local receipts. |
| C16, C29 | Migrate removed dependency helpers and restore missing-dependency hints. Run the embedded Hindsight service from its separate environment. |
| C20, C21 | Correct platform-test selection and make the removed-import guard inspect actual source. |
| C22, C23 | Publish immutable release artifacts before feeds and resolve FFmpeg artifacts by target. |
| C25 | Preserve paused downloads, resume handles, and single-worker ownership. |
| C26 | Wire the boot and plugin-cadence paths to their existing owners. |
Implementation does not mean that every platform acceptance test is complete.
The updater, backup, setup, voice-text helpers, and several plugins now use
one implementation per reconciled concern. Runtime paths have a shared owner
in `hermes_cli/runtime_paths.py`.
## Verification limits
The first broad native-Windows run completed its file list with 43,358 passed,
257 failed, and 1,390 skipped tests. Eight files had no completed result.
Repairs overlapped that run, so it does not certify the final tree.
A later run selected all changed Python test files plus the broad run's failed
files. Its 206 files reported 4,630 passed, one failed, and 330 skipped tests.
There were no files without completed results. The remaining failure was a
Hindsight setup fixture that crossed into dependency installation. The fixture
now isolates that boundary. The final six-file rerun reported 234 passed and
two skipped tests, including Hindsight setup, PM state, admission, environment
sanitization, and terminal timeout output. A separate four-file store review
rerun reported 83 passed and one skipped test.
Desktop renderer tests reported 7,225 passed. Electron tests reported 2,295
passed and 23 skipped. Both desktop TypeScript checks also passed after the
locked development packages were restored. These are unit/type checks, not an
installer or signed-release lifecycle test.
A real Hindsight side-environment daemon started in a disposable home, answered
its health endpoint with HTTP 200, and stopped successfully. This verifies
startup, health, and shutdown, not an LLM retain/recall operation.
## Remaining acceptance work
- Run the full Python suite on one fixed final tree and the relevant native
platform CI lanes. Targeted reruns do not substitute for this gate.
- Complete packaged install, update, relaunch, and uninstall checks. Verify
that the messaging gateway survives ordinary desktop quit.
- Complete the remaining updater-strategy and warning-surface contracts from
the audit. Manual update checks alone do not prove those surfaces.
- Define and verify safe collection of obsolete dependency generations while
older processes still use them. The current conservative retention avoids
deleting a live generation but does not bound storage use.
- Strengthen publication recovery for process death between plugin-config and
runtime-facts writes. Exception rollback is tested; those two files are not
one crash-atomic transaction.
- Complete receipt correlation and failure-reporting checks across nested PM
and updater operations.
- Reconcile the remaining documentation and complete the final aggregate diff
review. The bounded pre-commit review does not cover every moved updater line.
The external audit directory contains the original reports, per-batch logs,
review adjudication, and the exact test-file selection. No remote release,
service installation, or push is implied by this commit.
+2 -1
View File
@@ -2622,7 +2622,8 @@ class BasePlatformAdapter(ABC):
logger.info("[%s] Sending image: %s (alt=%s)", self.name,
safe_url_for_log(image_url), alt_text[:30] if alt_text else "")
if image_url.startswith("file://"):
sender, url_kw = self.send_image_file, {"image_path": _unquote(image_url[7:])}
from urllib.request import url2pathname
sender, url_kw = self.send_image_file, {"image_path": url2pathname(image_url[7:])}
elif self._is_animation_url(image_url):
sender, url_kw = self.send_animation, {"animation_url": image_url}
else:
+2 -2
View File
@@ -15,6 +15,7 @@ import json
import logging
import os
import re
from pathlib import Path
from typing import Any, Dict, Optional
from gateway.platforms._shared import get_scoped_secret as _get_wsecret
@@ -310,9 +311,8 @@ def resolve_whatsapp_bridge_dir() -> Path:
"""Bridge directory for CLI and adapter. A read-only install tree (e.g. Docker
/opt/hermes) is mirrored to HERMES_HOME so npm install works."""
import shutil
from pathlib import Path as _Path
from hermes_constants import get_hermes_home
install_bridge = _Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
install_bridge = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge"
hermes_home_bridge = get_hermes_home() / "scripts" / "whatsapp-bridge"
try:
(install_bridge / ".write_test").touch()
+47 -14
View File
@@ -27,7 +27,7 @@ from collections import OrderedDict
from contextvars import copy_context
from pathlib import Path
from datetime import datetime
from typing import Callable, Dict, Optional, Any, List, Tuple, cast
from typing import Callable, Dict, Optional, Any, List, Tuple, cast, TYPE_CHECKING
from agent.async_utils import safe_schedule_threadsafe
from agent.conversation_compression import (
@@ -41,6 +41,9 @@ from agent.turn_context import compression_made_progress
from hermes_cli.config import _is_ssh_remote_tilde_cwd, cfg_get
from hermes_cli.fallback_config import get_fallback_chain
if TYPE_CHECKING:
from agent.session_activity import ActivityProvenance
# Per-session AIAgent cache bounds (agents are heavy); see _enforce_agent_cache_cap/_session_expiry_watcher.
_AGENT_CACHE_MAX_SIZE = 128
_AGENT_CACHE_IDLE_TTL_SECS = 3600.0 # evict agents idle for >1h
@@ -1467,20 +1470,24 @@ _ensure_ssl_certs()
# pm startup: same contract as the CLI dispatch path (hermes_cli/main.py)
# — the gateway daemon never passes through the CLI fast-launch checks, so
# the store's tools (git/bash/ffmpeg/...) must be on PATH here. O(1) stamp
# checks, no network, no installs; warns, never blocks.
try:
import pm
# checks, no network, no installs; warns, never blocks. Runs from main(),
# not import time: importers of this module (relay runtime, platform
# actions, enrollment) need helpers, not PATH provisioning or a pm verdict
# on their behalf.
def _run_pm_startup() -> None:
try:
import pm
pm.adopt()
problems = pm.check()
if problems:
logging.getLogger("gateway.run").warning(
f"install out of sync ({'; '.join(problems)}) — run `hermes pm install`"
)
else:
pm.activate()
except Exception:
logging.getLogger("gateway.run").debug("pm startup check failed", exc_info=True)
pm.adopt()
problems = pm.check()
if problems:
logging.getLogger("gateway.run").warning(
f"install out of sync ({'; '.join(problems)}) — run `hermes pm install`"
)
else:
pm.activate()
except Exception:
logging.getLogger("gateway.run").debug("pm startup check failed", exc_info=True)
sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -4431,6 +4438,16 @@ def _housekeeping_org_skill_sync() -> None:
maybe_pull_org_skills()
def _housekeeping_plugin_update_check() -> None:
"""Plugin update-check cadence (plugins_cadence): due-gated by
plugins.auto_update_check_hours, read-only, receipt-surfaced; the
opt-in auto-apply rides the manual update pipeline. A network error
costs one warning and a stamped marker — never an apply."""
from hermes_cli.plugins_cadence import maybe_run_gateway_check
maybe_run_gateway_check(log=logger)
def _housekeeping_auto_archive() -> None:
"""Stale-session auto-archive on a live timer (the startup hook fires once); maybe_auto_archive()
is gated by sessions.min_interval_hours. Opens its own SessionDB — SQLite connections are thread-bound."""
@@ -4516,6 +4533,7 @@ def _start_gateway_housekeeping(
(60, "Sync pull tick", _housekeeping_skill_sync),
(60, "Org sync pull tick", _housekeeping_org_skill_sync),
(60, "Auto-archive tick", _housekeeping_auto_archive),
(60, "Plugin update check", _housekeeping_plugin_update_check),
(1, "Deferred FTS retry tick", _housekeeping_deferred_fts_retry),
(1, "gateway housekeeping memory trim", _housekeeping_memory_trim)]
@@ -5346,6 +5364,21 @@ def main():
for _step in (_register_identity, _arm_watchdog, _utf8_stdio):
_best_effort(_step)
# pm startup contract (PATH provisioning for the store's tools), then
# the post-update bootstrap: the same one-pass record-gated maintenance
# registry the CLI dispatch path runs (hermes_cli/main.py) — this
# entrypoint bypasses that dispatch, so run it here too. Never raises.
_best_effort(_run_pm_startup)
try:
from hermes_cli.boot_bootstrap import (
default_project_root,
maybe_run_boot_bootstrap,
)
maybe_run_boot_bootstrap(default_project_root())
except Exception:
logger.debug("boot bootstrap failed", exc_info=True)
import argparse
parser = argparse.ArgumentParser(description="Hermes Gateway - Multi-platform messaging")
parser.add_argument("--config", "-c", help="Path to gateway config file")
+1
View File
@@ -28,6 +28,7 @@ from gateway.slash_commands_goals import GatewayGoalCommandsMixin
from gateway.slash_commands_model import GatewayModelCommandsMixin
from gateway.slash_commands_session import GatewaySessionCommandsMixin
from gateway.slash_commands_status import GatewayStatusCommandsMixin
from gateway.slash_commands_status import HISTORY_UNREADABLE
from hermes_cli.config import atomic_config_write, cfg_get
from utils import atomic_json_write, is_truthy_value
-179
View File
@@ -1,179 +0,0 @@
"""`gateway run --service`: the Windows SCM frontend for the gateway.
The MSIX registers HermesGateway as a Windows Service whose Executable
is the payload's own launcher (hermes.exe — the distlib-minted PE; no
rust, no separate shim binary, plan: gateway-msix-windows-service).
When the SCM starts the service, this module IS the service process:
SCM START → this frontend reports Running in well under a second
(stdlib + pywin32 imports only — the heavy hermes import happens in
the CHILD it spawns: `hermes gateway run` with no --service), then
spawns the gateway as a child and waits.
SCM STOP → writes the EXISTING planned-stop marker
(write_planned_stop_marker(child_pid)) — the gateway's own watcher
thread (the #33778 windows-graceful-stop path) drains it exactly like
`hermes gateway stop` does. The frontend waits out the drain window
(below the SCM's WaitToKillServiceTimeout, with margin — Task 3),
then force-kills the child and reports Stopped.
The graceful-drain logic is NOT duplicated here — it is the gateway's
own, exercised daily by `hermes gateway stop`. This file is only the SCM
protocol translation layer.
"""
from __future__ import annotations
import os
import subprocess
import sys
import time
from typing import Optional
SERVICE_NAME = "HermesGateway"
# Task 3's contract: the SCM force-kills a service that hasn't reported
# Stopped within WaitToKillServiceTimeout (30s default, but hardening
# guides set 5s). The frontend enforces its own deadline BELOW the
# window so the force-kill decision stays hermes-owned (receipt-visible
# in the gateway's own log tail), never an SCM surprise.
_SCM_WINDOW_FALLBACK_S = 30
_STOP_MARGIN_S = 5
def run_as_service() -> int:
"""The SCM frontend loop. Returns the process exit code."""
try:
import win32serviceutil # noqa: F401
import servicemanager
except ImportError:
# Started outside the SCM (a user ran --service by hand): say so,
# never hang. The real gateway doesn't need this flag.
print(
"gateway --service is the Windows service frontend; it is started "
"by the Service Control Manager (or `hermes gateway service on`). "
"For a foreground gateway run: hermes gateway run",
file=sys.stderr,
)
return 2
import win32service
import win32serviceutil as svcutil
class _GatewayFrontend(win32serviceutil.ServiceFramework):
_svc_name_ = SERVICE_NAME
_svc_display_name_ = "Hermes Gateway"
_svc_description_ = (
"Hermes messaging gateway — bots keep running without the "
"desktop app. Managed by hermes; enable via "
"`hermes gateway service on`."
)
def __init__(self, args):
super().__init__(args)
self.stop_requested = False
self.child: Optional[subprocess.Popen] = None
self.sewh = None
def SvcStop(self):
"""SCM stop: nudge the child GRACEFULLY (the planned-stop
marker — the gateway's own drain path), then honor the
frontend's own deadline in SvcRun."""
self.ReportServiceStatus(win32service.SERVICE_STOP_PENDING)
self.stop_requested = True
if self.child and self.child.poll() is None:
_write_stop_marker(self.child.pid)
def SvcDoRun(self):
import servicemanager
servicemanager.LogMsg(
servicemanager.EVENTLOG_INFORMATION_TYPE,
servicemanager.PYS_SERVICE_STARTED,
(self._svc_name_, ""),
)
try:
self._run_child()
except Exception:
import traceback
servicemanager.LogErrorMsg(
"HermesGateway frontend failure:\n" + traceback.format_exc()
)
finally:
servicemanager.LogMsg(
servicemanager.EVENTLOG_INFORMATION_TYPE,
servicemanager.PYS_SERVICE_STOPPED,
(self._svc_name_, ""),
)
def _run_child(self):
# The REAL gateway: this launcher, no --service, so every
# code path (watchdog, planned-stop watcher, graceful drain)
# is the one `hermes gateway run` uses daily.
argv = [
sys.executable,
"-m",
"hermes_cli.main",
"gateway",
"run",
]
self.child = subprocess.Popen(
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=os.getcwd(),
)
# Wait for exit or the graceful-stop request.
drain_deadline = None
while True:
rc = self.child.poll()
if rc is not None:
return
if self.stop_requested:
if drain_deadline is None:
drain_deadline = time.monotonic() + _drain_window_s()
elif time.monotonic() >= drain_deadline:
# Drain window exhausted — force-kill the child.
# (Deliberately NOT a tree-walk taskkill: the
# #85265 class. The child's own children die with
# it under job semantics; the receipt-visible
# unclean-stop class is what remains.)
self.child.kill()
self.child.wait()
return
time.sleep(0.25)
svcutil.Initialize(_GatewayFrontend, None)
svcutil.StartServiceCtrlDispatcher()
return 0
def _drain_window_s() -> float:
"""The frontend's stop deadline: the gateway's configured drain
timeout, capped below the SCM's WaitToKillServiceTimeout."""
fallback = float(_SCM_WINDOW_FALLBACK_S - _STOP_MARGIN_S)
try:
raw = os.environ.get("HERMES_RESTART_DRAIN_TIMEOUT", "").strip()
if raw:
return min(float(raw), fallback)
except (TypeError, ValueError):
pass
return fallback
def _write_stop_marker(child_pid: int) -> None:
"""The graceful nudge: the SAME marker `hermes gateway stop` writes;
the child's planned-stop watcher thread consumes it and drains."""
try:
from gateway.status import write_planned_stop_marker
write_planned_stop_marker(child_pid)
except Exception:
# Marker machinery unavailable (partial payload): fall back to
# terminate, which the child's #53107 hardening handles.
try:
os.kill(child_pid, 15) # SIGTERM-shaped nudge on windows = TerminateProcess
except Exception:
pass
+13 -3
View File
@@ -100,8 +100,18 @@ def harden_import_path(src_root: str | None = None) -> None:
# (or ``from hermes_bootstrap import apply_windows_utf8_bootstrap``) at
# the very top of their module, before importing anything else. The
# import side effect does the right thing.
# Upstream's ``activate_durable_lazy_target`` (tools.lazy_deps durable
# lazy-install dir) is deliberately NOT merged: lazy_deps was removed on
# this branch (pm replaced it). Do not reintroduce.
apply_windows_utf8_bootstrap()
suppress_platform_ver_console()
# Every entry point imports this module before its dependency graph.
from pathlib import Path
from hermes_cli.runtime_paths import activate_dependencies
try:
activate_dependencies(Path(__file__).resolve().parent)
except RuntimeError as exc:
# The repair command must remain usable even when the committed tree
# disappeared. Other commands must not silently run the wrong libraries.
if sys.argv[1:3] not in (["pm", "install"], ["pm", "doctor"]):
print(f"hermes: {exc}; run `hermes pm install` to repair", file=sys.stderr)
raise SystemExit(1) from None
+8 -2
View File
@@ -217,13 +217,14 @@ def ensure_windows_bin_launchers(
mint_launcher,
resolve_store_python,
stage_launcher,
venv_site_packages,
)
from hermes_constants import project_venv_dir
venv_dir = project_venv_dir(root)
site_packages = venv_site_packages(venv_dir) if venv_dir else None
from hermes_cli.runtime_paths import site_packages as dependency_site
site_packages = dependency_site(venv_dir) if venv_dir else None
store_python = resolve_store_python(root)
@@ -280,6 +281,11 @@ def ensure_windows_bin_launchers(
else:
final = stage_launcher(name, root, target)
if final is not None:
# Windows resolves .exe before .cmd. A surviving venv-bound
# launcher would shadow the successfully staged fallback.
obsolete = target / f"{name}.exe"
if final.suffix == ".cmd" and exe_is_venv_bound(obsolete, venv_dir):
obsolete.unlink()
restored.append(str(final))
if restored:
# Guarded like everything else in this never-raises helper: a
+12 -53
View File
@@ -15,11 +15,9 @@ Two consumers:
- ``hermes_cli/_install_repair.py`` calls the same machinery per-name when
a launcher is missing or still boots through the venv.
Store-root resolution mirrors ``pm/paths.py`` (HERMES_RUNTIME_DIR →
install-stamp.json ``runtimeDir`` → the pm default root); it is re-implemented
here because this module must stay importable from a bare interpreter
(stdlib + distlib only) during repair/bootstrap where ``pm`` imports are not
guaranteed. If pm's layout changes, keep the two in lockstep.
Store paths come from the stdlib-only runtime_paths owner, shared with PM.
Launchers import hermes_bootstrap before the entry point: the dependency
selection is read at boot, not frozen when the launcher is minted.
The exe form uses distlib's ScriptMaker with a customized script template
that inserts the repo root and the venv's site-packages into ``sys.path``
@@ -35,6 +33,9 @@ import json
import os
from pathlib import Path
from hermes_constants import project_venv_dir
from hermes_cli.runtime_paths import site_packages, store_root
#: Launcher command names — keep in lockstep with scripts/install.ps1
#: Stage-Path and hermes_cli/_install_repair.py.
WINDOWS_BIN_LAUNCHERS = ("hermes", "hermes-acp")
@@ -51,36 +52,6 @@ def _is_windows() -> bool:
return os.name == "nt"
def project_venv_dir(repo_root: Path) -> Path | None:
"""The uv-sync target venv of the install (``venv`` preferred, ``.venv``
for dev checkouts)."""
for name in ("venv", ".venv"):
candidate = Path(repo_root) / name
if candidate.is_dir():
return candidate
return None
def store_root(repo_root: Path) -> Path:
"""The pm byte store, resolved exactly like pm/paths.store_root()."""
env = os.environ.get("HERMES_RUNTIME_DIR")
if env:
return Path(env)
stamp = Path(repo_root) / "install-stamp.json"
if stamp.is_file():
try:
runtime_dir = json.loads(stamp.read_text(encoding="utf-8-sig")).get(
"runtimeDir"
)
except (OSError, ValueError):
runtime_dir = None
if runtime_dir:
return Path(runtime_dir)
from hermes_constants import get_default_hermes_root
return Path(get_default_hermes_root()) / "tools"
def resolve_store_python(repo_root: Path) -> Path | None:
"""The interpreter the store installed from the ``python`` package
(facts.json entry first, newest ``python-*`` entry as fallback), or
@@ -109,19 +80,6 @@ def resolve_store_python(repo_root: Path) -> Path | None:
return None
def venv_site_packages(venv_dir: Path) -> Path | None:
"""The venv directory uv sync fills (Windows ``Lib\\site-packages``;
POSIX ``lib/python3.X/site-packages``)."""
venv_dir = Path(venv_dir)
if _is_windows():
candidate = venv_dir / "Lib" / "site-packages"
return candidate if candidate.is_dir() else None
for candidate in sorted(venv_dir.glob("lib/python3.*/site-packages")):
if candidate.is_dir():
return candidate
return None
def _load_script_maker():
"""distlib's ScriptMaker — standalone first, then pip's vendored copy."""
try:
@@ -216,6 +174,7 @@ def mint_launcher(
"import sys\n"
f"sys.path.insert(0, {esc(repo_root)!r})\n"
f"{site_line}"
"import hermes_bootstrap\n"
"if __name__ == '__main__':\n"
" from %(module)s import %(import_name)s\n"
" sys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\n"
@@ -236,7 +195,7 @@ def mint_launcher(
# distlib ran but produced no exe (unexpected) — fall through to cmd.
site = f";{site_packages}" if site_packages else ""
code = f"import sys; from {module} import {func}; sys.exit({func}())"
code = f"import sys; import hermes_bootstrap; from {module} import {func}; sys.exit({func}())"
body = (
"@echo off\r\n"
"chcp 65001 >nul\r\n"
@@ -260,13 +219,13 @@ def stage_launcher(name: str, repo_root: Path, out_dir: Path) -> Path | None:
Never raises; returns the written path or None."""
repo_root = Path(repo_root)
venv_dir = project_venv_dir(repo_root)
site_packages = venv_site_packages(venv_dir) if venv_dir else None
dependencies = site_packages(venv_dir) if venv_dir else None
store_python = resolve_store_python(repo_root)
if store_python is not None:
path = mint_launcher(name, repo_root, out_dir, store_python, site_packages)
path = mint_launcher(name, repo_root, out_dir, store_python, dependencies)
if path is not None:
return path
return _write_runtime_cmd(name, repo_root, site_packages, out_dir)
return _write_runtime_cmd(name, repo_root, dependencies, out_dir)
def ensure_install_launchers(repo_root: Path, out_dir: Path) -> list[str]:
@@ -316,7 +275,7 @@ def _write_runtime_cmd(
")\r\n"
f'endlocal & set "PYTHONPATH=%HERMES_REPO%{site}" & set "PM_PY=%PM_PY%"\r\n'
'set "PYTHONHOME="\r\n'
f'set "PM_ENTRY=import sys; from {module} import {func}; sys.exit({func}())"\r\n'
f'set "PM_ENTRY=import sys; import hermes_bootstrap; from {module} import {func}; sys.exit({func}())"\r\n'
'"%PM_PY%" -c "%PM_ENTRY%" %*\r\n'
)
return _write_atomic(
+12 -1688
View File
File diff suppressed because it is too large. Load diff
+456
View File
@@ -0,0 +1,456 @@
"""SQLite-safe restore and archive-member publish plumbing for backups.
Owns the restore side of ``hermes_cli.backup``: the page-copy SQLite restore
(``_safe_restore_db`` plus the foreign-holder scan) and the zip-member publish
helpers used by ``hermes import`` and ``/snapshot restore``. Backup
*creation* (``run_backup``, full-zip writing) stays in ``hermes_cli.backup``,
which composes these helpers.
"""
import logging
import os
import shutil
import sqlite3
import stat
import sys
import tempfile
import zipfile
from pathlib import Path
from typing import List, Optional, Tuple
from utils import (
_preserve_file_mode, _preserve_file_owner, _restore_file_mode, _restore_file_owner, atomic_replace,
)
logger = logging.getLogger(__name__)
def _foreign_db_holder_pids(db_path: Path) -> Optional[List[int]]:
"""PIDs of OTHER processes holding *db_path* or its WAL/SHM open.
Linux-only ``/proc/<pid>/fd`` scan (no psutil dependency), preserving the
kernel's ``(deleted)`` suffix so an already-unlinked sidecar generation —
the #90950 split-brain fingerprint — still counts as held. Returns
``None`` when the scan is unavailable (non-Linux, or /proc unreadable);
callers must treat ``None`` as "unknown", not as "no holders".
"""
if not sys.platform.startswith("linux"):
return None
def _canonical(path: str) -> str:
return os.path.normcase(
os.path.abspath(path.removesuffix(" (deleted)"))
)
canonical_db = _canonical(os.fspath(db_path))
watched = {canonical_db, canonical_db + "-wal", canonical_db + "-shm"}
pids: List[int] = []
try:
own_pid = os.getpid()
for pid_str in os.listdir("/proc"):
if not pid_str.isdigit():
continue
pid = int(pid_str)
if pid == own_pid:
continue
fd_dir = f"/proc/{pid}/fd"
try:
fds = os.listdir(fd_dir)
except OSError:
continue
for fd in fds:
try:
target = os.readlink(f"{fd_dir}/{fd}")
except OSError:
continue
if _canonical(target) in watched:
pids.append(pid)
break
except OSError:
return None
return pids
def _safe_restore_db(src: Path, dst: Path) -> bool:
"""Restore a SQLite database from snapshot *src* into live *dst*.
Uses SQLite's backup() API to write snapshot pages into the live
database file, preserving the file's inode and WAL state so that
any other process still holding the DB open (gateway, dashboard,
another CLI session) sees the restored data on the next read —
instead of continuing to serve stale cached pages from a replaced
inode.
The old approach was ``unlink() + move()``, which replaced the file
under any live connection. SQLite connections cache pages in
per-connection page caches keyed by inode; after an unlink+move the
old inode still existed (the live connection held a reference), so
that connection continued serving the pre-restore data while new
connections saw the restored snapshot — a partial/inconsistent
state (issue #65942).
By writing pages through the backup API the file inode is preserved,
the WAL journal is updated correctly, and all connections (old and
new) converge on the restored data.
Falls back to the unlink+move approach on failure ONLY when no other
process or in-process connection holds the file: replacing the inode
under a live holder is the #90950 split-brain, so that branch fails
closed (returns ``False``) and the caller reports the file as skipped.
"""
dst_conn: Optional[sqlite3.Connection] = None
try:
dst_conn = sqlite3.connect(str(dst))
try:
# Force a WAL checkpoint so the backup starts from a clean
# state rather than writing on top of a deep WAL.
dst_conn.execute("PRAGMA wal_checkpoint(TRUNCATE)")
except Exception:
pass
src_conn = sqlite3.connect(f"file:{src}?mode=ro", uri=True)
try:
src_conn.backup(dst_conn)
finally:
src_conn.close()
dst_conn.close()
# Restore original file permissions from the snapshot
try:
mode = src.stat().st_mode
dst.chmod(mode)
except Exception:
pass
return True
except Exception as exc:
logger.warning("SQLite safe restore failed for %s -> %s: %s", src, dst, exc)
# Release our own handle on *dst* before the fallback: on Windows an
# open connection blocks unlink() with WinError 32, which would make
# the fallback below dead code exactly when it is needed.
if dst_conn is not None:
try:
dst_conn.close()
except Exception:
pass
# Fallback: unlink+move (the old approach). This still works for
# the common case where no other process holds the DB open.
from hermes_cli.sqlite_safe_read import (
LiveConnectionError,
offline_file_access,
)
try:
holders = _foreign_db_holder_pids(dst)
if holders:
# Replacing the inode under a live holder is the #90950
# corruption class: the holder keeps writing through a
# deleted-inode fd (split brain), and removing its sidecars
# detaches the WAL index it is checkpointing through. The
# backup-API path above is the live-safe route; if it failed,
# fail closed rather than corrupt.
logger.error(
"Refusing unlink+move restore of %s: process(es) %s still "
"hold the database or its WAL open. Stop them and retry.",
dst, holders,
)
return False
# The foreign-pid scan above deliberately excludes THIS process,
# but an in-process SessionDB (the agent's own handle during
# /snapshot restore, a second SessionDB instance, a read pool)
# is exactly as much of a live holder: unlinking the DB and its
# sidecars under it leaves this process on deleted-inode fds —
# the same #90950 split brain, produced first-party (proven live
# on main: `/proc/self/fd` shows `state.db-wal (deleted)` right
# after this fallback ran under a tracked connection).
# ``offline_file_access`` fails CLOSED when any tracked
# connection to *dst* is live and holds the connection-lifecycle
# lock across the whole swap so no new connection can appear
# mid-replace.
with offline_file_access(dst, what="unlink+move restore of"):
tmp = dst.parent / f".{dst.name}.snap_restore"
shutil.copy2(src, tmp)
dst.unlink(missing_ok=True)
# Drop the destination's sidecars before installing the
# snapshot. The snapshot is a checkpointed ``sqlite3.backup()``
# image (see ``_safe_copy_db``) that owns no WAL, so any
# ``-wal``/``-shm`` still sitting here describes the database we
# just unlinked — an ungracefully killed gateway leaves them
# behind, which is exactly when a restore gets run. SQLite
# replays that foreign WAL over the restored file on the next
# open and the database comes up "malformed" (or silently
# resurrects post-snapshot rows). Same reasoning as
# ``_EXCLUDED_SUFFIXES``, applied to the restore destination.
for _sidecar_suffix in ("-wal", "-shm", "-journal"):
dst.with_name(dst.name + _sidecar_suffix).unlink(missing_ok=True)
shutil.move(str(tmp), str(dst))
return True
except LiveConnectionError as exc2:
logger.error(
"Refusing unlink+move restore of %s: %s Close the in-process "
"database handles (or restart Hermes) and retry.",
dst, exc2,
)
return False
except Exception as exc2:
logger.error("Fallback restore also failed for %s -> %s: %s", src, dst, exc2)
return False
def _validate_backup_zip(zf: zipfile.ZipFile) -> tuple[bool, str]:
"""Check that a zip looks like a Hermes backup.
Returns (ok, reason).
"""
names = zf.namelist()
if not names:
return False, "zip archive is empty"
# Look for telltale files that a hermes home would have
markers = {"config.yaml", ".env", "state.db"}
found = set()
for n in names:
# Could be at the root or one level deep (if someone zipped the directory)
basename = Path(n).name
if basename in markers:
found.add(basename)
if not found:
return False, (
"zip does not appear to be a Hermes backup "
"(no config.yaml, .env, or state databases found)"
)
return True, ""
def _detect_prefix(zf: zipfile.ZipFile) -> str:
"""Detect if the zip has a common directory prefix wrapping all entries.
Some tools zip as `.hermes/config.yaml` instead of `config.yaml`.
Returns the prefix to strip (empty string if none).
"""
names = [n for n in zf.namelist() if not n.endswith("/")]
if not names:
return ""
# Find common prefix
parts_list = [Path(n).parts for n in names]
# Check if all entries share a common first directory
first_parts = {p[0] for p in parts_list if len(p) > 1}
if len(first_parts) == 1:
prefix = first_parts.pop()
# Only strip if it looks like a hermes dir name
if prefix in {".hermes", "hermes"}:
return prefix + "/"
return ""
def _default_new_file_mode() -> Optional[int]:
"""Return the mode ``open(path, "wb")`` gives a file it has to create.
``tempfile.mkstemp`` always creates at 0600, so staging an import through a
temp file would tighten every *newly created* file to owner-only — the same
hazard ``utils._restore_file_mode`` documents for Docker/NAS volume mounts
that rely on broader permissions. The umask can only be read by setting it,
so this is resolved once per import rather than once per member. The probe
installs a *restrictive* mask rather than 0 so that anything another thread
creates inside the two-syscall window is owner-only, never world-writable.
Returns ``None`` if the umask cannot be read, in which case the caller
leaves mkstemp's mode alone.
"""
try:
current = os.umask(0o077)
os.umask(current)
except OSError:
return None
return 0o666 & ~current
def _extract_member_atomically(
zf: zipfile.ZipFile,
member: str,
target: Path,
new_file_mode: Optional[int] = None,
) -> None:
"""Restore one zip member onto *target* with no truncation window.
``open(target, "wb")`` truncates the user's existing file to zero *before*
any replacement bytes exist. A Ctrl-C, an ENOSPC, a corrupt member, or a
crash between the truncate and the write therefore leaves that file empty
with nothing behind it — during ``hermes import``, which is the
disaster-recovery path a user reaches for *because* they already lost
something. Staging into the target's own directory and publishing with a
rename means the target only ever moves from its old contents to the
complete new contents.
``atomic_replace`` rather than a bare ``os.replace``: it resolves a
symlinked target first, so a deployment that links ``config.yaml`` into a
dotfiles repo keeps the link instead of having it silently swapped for a
regular file (GitHub #16743), and it falls back to copy/fsync/unlink on
``EXDEV``/``EBUSY`` for cross-device and bind-mount installs. That
fallback uses ``shutil.copyfile``, which does truncate in place, so on the
cross-device path the guarantee above degrades to today's behaviour rather
than improving on it; closing that belongs in ``utils.atomic_replace``,
where every atomic writer in the repo would benefit, not here.
Permission bits *and* ownership are carried across the replace so routing
through mkstemp does not change the file the caller would otherwise have
produced. ``os.replace`` swaps in a temp file owned by the *writing* user,
so without the chown a ``sudo hermes import`` would silently re-own every
restored file to root — on the disaster-recovery path, and on exactly the
Docker/NAS installs ``utils._restore_file_owner`` documents. Both concerns
delegate to the shared ``utils`` helpers rather than being re-derived here.
The temp file is removed on any failure so a partial import leaves no
residue.
The one bit of the old file *not* carried across is setuid/setgid. The
replacement bytes come out of the zip, so preserving those would let an
archive take over the identity an existing privileged file executes as —
and unlike the other ``utils`` writers, which re-serialize content this
process produced, the trust boundary here is an untrusted archive. The
mask is applied once, before the temp file is chmod'd, so neither the
pre-replace ``fchmod`` nor the post-replace restore can re-elevate the
target.
"""
# ``_preserve_file_mode`` returns None when the target does not exist (or
# cannot be stat'd), in which case the umask-derived create-mode applies —
# the same shape as ``atomic_yaml_write``'s ``create_mode`` fallback.
mode = _preserve_file_mode(target)
owner = _preserve_file_owner(target)
if mode is None:
mode = new_file_mode
else:
# Deliberately NOT a faithful mode copy: setuid/setgid are dropped.
# ``_preserve_file_mode`` returns ``stat.S_IMODE``, i.e. all twelve
# bits, and the content replacing this file comes from the archive.
# Carrying the elevated bits across would let archive-controlled bytes
# take over an existing setuid/setgid file, so ``hermes import`` would
# hand whoever produced the zip the identity that file runs as. Nothing
# constrains that to Hermes' own state either: the ``_external/`` branch
# of ``run_import`` publishes members anywhere under ``$HOME``. The
# sticky bit is kept — it is inert on a regular file.
mode &= ~(stat.S_ISUID | stat.S_ISGID)
# Truncate the stem: mkstemp adds ~16 characters, and a member already near
# NAME_MAX would otherwise fail here on a write that used to succeed.
fd, tmp_name = tempfile.mkstemp(
dir=str(target.parent), prefix=f".{target.name[:80]}.", suffix=".partial"
)
try:
with os.fdopen(fd, "wb") as dst:
if mode is not None:
# Apply the mode to the temp file BEFORE the replace so the
# target never transits through mkstemp's 0600, and so
# ``atomic_replace``'s EXDEV/EBUSY ``shutil.copystat`` fallback
# copies the intended bits rather than 0600. fchmod is
# Unix-only; Windows takes the path-based chmod.
if hasattr(os, "fchmod"):
os.fchmod(dst.fileno(), mode)
else:
os.chmod(tmp_name, mode)
# Stream instead of ``src.read()``: a multi-gigabyte state.db member
# must not be held in memory in one piece.
with zf.open(member) as src:
shutil.copyfileobj(src, dst)
dst.flush()
os.fsync(dst.fileno())
real_path = Path(atomic_replace(tmp_name, target))
# Owner first, mode second — the ordering ``atomic_yaml_write`` uses,
# because chown drops setuid/setgid and a mode restore that ran first
# would be partly undone. Here ``mode`` no longer carries those bits,
# so the two agree: neither step can re-elevate the restored file.
_restore_file_owner(real_path, owner)
_restore_file_mode(real_path, mode)
except BaseException:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
def _count_session_rows(path: Path) -> Optional[Tuple[int, int]]:
"""Return ``(sessions, messages)`` stored in the session database *path*.
Read-only and best effort. ``None`` means "unknown" — a missing file, a
database that is not a Hermes session store, or one that cannot be read.
Callers must never read ``None`` as "zero rows": acting on an unreadable
database would mask the very loss this count exists to surface. Same
contract as :func:`_count_cron_jobs`.
"""
if not path.is_file():
return None
try:
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
except sqlite3.Error:
return None
try:
sessions = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
messages = conn.execute("SELECT COUNT(*) FROM messages").fetchone()[0]
return int(sessions), int(messages)
except (sqlite3.Error, TypeError, ValueError):
return None
finally:
conn.close()
def _import_db_member(
zf: zipfile.ZipFile,
member: str,
target: Path,
new_file_mode: Optional[int] = None,
) -> None:
"""Publish a SQLite ``.db`` member onto *target* without replacing its inode.
``_extract_member_atomically`` publishes with a rename. For an ordinary
file that is the safest write available; for a live SQLite database it is
the #65942 / #90950 corruption class. A gateway, dashboard, or WebUI
process holding the database open keeps its descriptor on the now-unlinked
inode: it goes on serving pre-import pages and writing sessions that no
other process will ever see, and any sidecar WAL left beside the new file
describes the database that was just unlinked. Nothing fails, so nothing
is reported — the sessions simply are not there afterwards (issue #100960).
``hermes import`` is the disaster-recovery path, so that failure mode lands
on users who have already lost something once. Route the member through
the same ``_safe_restore_db`` page copy that ``/snapshot restore`` has used
since #65942: the live inode is preserved, every open connection converges
on the imported data, and the sidecars are handled there. A target that
does not exist yet has no holders and no inode worth preserving, so it
takes the ordinary atomic publish.
Raises ``OSError`` when the database could not be replaced safely, so the
caller reports a skipped file instead of counting a silent success.
"""
if not target.exists():
_extract_member_atomically(zf, member, target, new_file_mode)
return
# The database keeps its own mode/ownership: the bytes come from the
# archive but the file does not, so the archive has no say in either.
mode = _preserve_file_mode(target)
owner = _preserve_file_owner(target)
fd, tmp_name = tempfile.mkstemp(
dir=str(target.parent), prefix=f".{target.name[:80]}.", suffix=".dbimport"
)
try:
with os.fdopen(fd, "wb") as dst:
# Stream: a multi-gigabyte state.db member must not be held in
# memory in one piece.
with zf.open(member) as src:
shutil.copyfileobj(src, dst)
dst.flush()
os.fsync(dst.fileno())
if not _safe_restore_db(Path(tmp_name), target):
raise OSError(
"live-safe restore refused or failed; the existing database was "
"left untouched. Stop the gateway/dashboard processes holding it "
"open and re-run the import."
)
_restore_file_owner(target, owner)
_restore_file_mode(target, mode)
finally:
try:
os.unlink(tmp_name)
except OSError:
pass
+2 -5
View File
@@ -403,11 +403,8 @@ def get_git_banner_state(repo_dir: Optional[Path] = None) -> Optional[dict]:
def _baked_banner_state() -> Optional[dict]:
"""Banner state from the baked build SHA (Docker image path), or None."""
def _baked():
from hermes_cli.build_info import get_build_sha
return get_build_sha(short=8)
baked = _quiet(_baked)
"""Banner state from the build-time install stamp (packaged builds), or None."""
baked = _quiet(_stamped_sha8)
return {"upstream": baked, "local": baked, "ahead": 0} if baked else None
+7 -54
View File
@@ -33,7 +33,7 @@ branch's vocabulary: stamps via ``hermes_cli.steward``, managed tools via
"""
from __future__ import annotations
import hashlib
from hermes_cli.runtime_paths import install_state_dir, installs_root
import json
import logging
import os
@@ -143,36 +143,6 @@ def current_install_identity(project_root: Path) -> str | None:
# the per-install state folder: installs/<SHA16>/ under the DEFAULT home
# ---------------------------------------------------------------------------
def _install_key(project_root: Path) -> str:
try:
canonical = str(Path(project_root).resolve())
except OSError:
canonical = str(project_root)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
INSTALLS_DIR_NAME = "installs"
def installs_root() -> Path:
"""The parent of every per-install state folder.
Anchored to the DEFAULT home, not the active profile home: profiles
share one folder per install, with per-profile bootstrap records as
files INSIDE it (bootstrap/<profile>.json) rather than per-profile
folders. That keeps profile semantics while collapsing the anchor
count to one.
"""
from hermes_cli.profiles import _get_default_hermes_home
return _get_default_hermes_home() / INSTALLS_DIR_NAME
def install_state_dir(project_root: Path) -> Path:
"""``installs/<SHA16>/`` for this install. Derivation only — no I/O."""
return installs_root() / _install_key(project_root)
def ensure_install_dir(project_root: Path) -> Path:
"""The state folder, created with its identity record on first touch.
@@ -482,9 +452,9 @@ def run_boot_bootstrap(project_root: Path) -> dict:
if drift_message:
summary["sealed_runtime_drift"] = drift_message
for scope, steps, deferred in (
("home", post_update.HOME_STEPS, False),
("machine", post_update.MACHINE_STEPS, True),
for scope, steps in (
("home", post_update.BOOT_HOME_STEPS),
("machine", post_update.BOOT_MACHINE_STEPS),
):
identity = needs_bootstrap(project_root, scope)
if not identity:
@@ -504,26 +474,9 @@ def run_boot_bootstrap(project_root: Path) -> dict:
"post-update bootstrap (%s scope): code changed to %s, running steps",
scope, identity[:12],
)
if deferred:
# Slow machine steps (network installers) must not block
# boot readiness: record first, then run detached. A crash
# mid-step leaves the record written — intended: the record
# gates "did we trigger for this identity", and the steps
# re-gate themselves (pm.check stamp comparisons) next change.
_write_record(record, identity, {"deferred": True})
import threading
threading.Thread(
target=post_update.run_steps,
args=(steps,),
name=f"hermes-bootstrap-{scope}",
daemon=True,
).start()
summary[scope] = "deferred"
else:
results = post_update.run_steps(steps)
_write_record(record, identity, results)
summary[scope] = results
results = post_update.run_steps(steps)
_write_record(record, identity, results)
summary[scope] = results
finally:
lock.release()
return summary
+1 -1
View File
@@ -1027,7 +1027,7 @@ class CLICommandsMixin:
def _handle_image_command(self, cmd_original: str):
"""Handle /image <path> — attach a local image file for the next prompt."""
from cli import _IMAGE_EXTENSIONS, _resolve_attachment_path, _split_path_input
from cli import _DIM, _IMAGE_EXTENSIONS, _RST, _cprint, _resolve_attachment_path, _split_path_input
raw_args = (cmd_original.split(None, 1)[1].strip() if " " in cmd_original else "")
if not raw_args:
hint = "/path/to/image.png"
+4 -3
View File
@@ -224,7 +224,8 @@ class CLIVoiceMixin:
f"(first use may download it from Hugging Face)...{_RST}")
else:
_cprint(f"{_DIM}Transcribing...{_RST}")
from tools.voice_mode import is_voice_stop_phrase, transcribe_recording
from tools.voice_mode_transcript import is_voice_stop_phrase
from tools.voice_mode import transcribe_recording
result = transcribe_recording(wav_path, model=stt_model)
if result.get("success") and result.get("transcript", "").strip():
transcript = result["transcript"].strip()
@@ -446,7 +447,7 @@ class CLIVoiceMixin:
result = transcribe_recording(wav_path, model=self._voice_stt_model())
transcript = (result.get("transcript") or "").strip() if result.get("success") else ""
if transcript:
from tools.voice_mode import is_voice_stop_phrase
from tools.voice_mode_transcript import is_voice_stop_phrase
if is_voice_stop_phrase(transcript):
_cprint(f"\n{_DIM}Stop phrase detected — ending voice chat.{_RST}")
self._disable_voice_mode()
@@ -554,7 +555,7 @@ class CLIVoiceMixin:
if not voice_on:
return False
try:
from tools.voice_mode import is_voice_stop_phrase
from tools.voice_mode_transcript import is_voice_stop_phrase
if not is_voice_stop_phrase(user_input):
return False
except Exception:
+7 -7
View File
@@ -1551,6 +1551,13 @@ DEFAULT_CONFIG = {
# 2026-09-14 removal date (see COMPAT_MANIFEST.md, `hermes plugins compat`). Stopgap only: the
# old paths raise ImportError once the compat layer is actually removed.
"allow_deprecated_imports": False,
# Read-only plugin update-check cadence, hours (gateway tick; 0 disables). Applying stays
# explicit: `hermes plugins update <name>`, or auto_apply below (git-class plugins only,
# scan-gated by that same pipeline).
"auto_update_check_hours": 24,
# Opt-in unattended apply for the cadence check. Git-row plugins ONLY; every apply runs the
# same security scan / consent pipeline as the manual update command.
"auto_apply": False,
},
# Shell-script hooks: event name (pre_tool_call, post_tool_call, pre_llm_call, subagent_stop,
# ...) -> list of {matcher, command, timeout}. First run of a new command prompts for consent;
@@ -1863,13 +1870,6 @@ DEFAULT_CONFIG = {
# Named-profile allowlist for multiplex mode. None = serve all; [] = default only.
"multiplex_profile_allowlist": None,
# The MSIX HermesGateway Windows Service posture (windows MSIX
# installs only; see `hermes gateway service on|off|status`).
# False = the service exists but stays demand-start/stopped
# (config-only posture — settled 2026-09-03); True = automatic
# at logon, bots keep running without the desktop app.
"service": False,
# Seconds to let a SIGTERM-interrupted gateway agent unwind before adapter/database
# teardown. Keep short so service-manager shutdowns don't exhaust their stop budget.
"signal_interrupt_grace_timeout": 1,
-174
View File
@@ -6,7 +6,6 @@ Check bodies live in the ``doctor_*`` siblings.
import os
import sys
from pathlib import Path
from hermes_cli.config import get_env_path, get_hermes_home, get_project_root
from hermes_cli.env_loader import load_hermes_dotenv
@@ -65,15 +64,6 @@ _PROVIDER_ENV_HINTS = (
"TOKENHUB_API_KEY", "TOKENPLAN_API_KEY",
)
def _python_install_cmd() -> str:
return "uv pip install"
def _system_package_install_cmd(pkg: str) -> str:
if sys.platform == "darwin":
return f"brew install {pkg}"
return f"sudo apt install {pkg}"
@doctor_check()
def _check_auth_providers(should_fix: bool, f: Finding) -> None:
"""Refresh-free OAuth status snapshot (doctor must never trigger a token refresh)."""
@@ -193,162 +183,6 @@ def run_doctor(args):
_print_summary(should_fix, total)
def _pm_tool_path(name: str) -> Path | None:
"""Answer a tool's binary from the pm store (facts.json), not PATH.
Doctor probes tools (git, rg, ffmpeg, ...) that pinned installs run
out of the store; nothing puts the store on an interactive shell's
PATH, so a PATH-only probe reports a healthy managed install as
"not found". This answers from facts.json + the store: the recorded
entry's binary when it exists on disk, None when unstaged or the
recorded binary is gone (deleted = report missing, never guess).
Contract tests: tests/hermes_cli/test_doctor_pm_store_probe.py.
"""
try:
import json
from pm.paths import store_root
except Exception:
return None
facts_path = store_root() / "facts.json"
if not facts_path.is_file():
return None
try:
facts = json.loads(facts_path.read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return None
if not isinstance(facts, dict):
return None
entry_name = (facts.get("packages") or {}).get(name) or {}
if not isinstance(entry_name, dict):
return None
entry_dir = entry_name.get("entry")
if not isinstance(entry_dir, str) or not entry_dir:
return None
entry = store_root() / entry_dir
if not entry.is_dir():
return None # recorded but deleted
# resolve the binary: the store entry's package definition knows the
# relative binary path; without importing packages, probe common shapes
binary_names = {
"ripgrep": ("rg.exe", "rg"),
"git": ("cmd/git.exe", "bin/git"),
"node": ("node.exe", "bin/node"),
"gh": ("bin/gh.exe", "bin/gh"),
}.get(name, (f"{name}.exe", name))
for rel in binary_names:
candidate = entry / rel
if candidate.is_file():
return candidate
return None
def _has_provider_env_config(content: str) -> bool:
"""Return True when ~/.hermes/.env contains provider auth/base URL settings."""
return any(key in content for key in _PROVIDER_ENV_HINTS)
def _honcho_is_configured_for_doctor() -> bool:
"""Return True when Honcho is configured, even if this process has no active session."""
try:
from plugins.memory.honcho.client import HonchoClientConfig
cfg = HonchoClientConfig.from_global_config()
return bool(cfg.enabled and (cfg.api_key or cfg.base_url))
except Exception:
return False
def _is_kanban_worker_env_gate(item: dict) -> bool:
"""Return True when Kanban is unavailable only because this is not a worker process."""
if item.get("name") != "kanban":
return False
if os.environ.get("HERMES_KANBAN_TASK"):
return False
tools = item.get("tools") or []
return bool(tools) and all(str(tool).startswith("kanban_") for tool in tools)
def _doctor_tool_availability_detail(toolset: str) -> str:
"""Optional explanatory suffix for toolsets whose doctor status needs context."""
if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"):
return "(runtime-gated; loaded only for dispatcher-spawned workers)"
return ""
def _doctor_web_capability_rows() -> list[tuple[str, str, str]]:
"""Return doctor rows for web search/extract provider readiness (#78412).
Each row is ``(status, label, detail)`` where *status* is ``ok`` or ``warn``.
Uses the same active-provider resolvers as the tools, but reports readiness
from ``is_available()`` so an explicitly selected but unconfigured backend
does not look healthy.
"""
rows: list[tuple[str, str, str]] = []
try:
from agent.web_search_registry import (
get_active_extract_provider,
get_active_search_provider,
)
from tools.web_tools import _ensure_web_plugins_loaded, _provider_is_ready
# Doctor runs in a fresh process — bundled web providers register
# during plugin discovery, which nothing has triggered yet here.
# Without this the registry is empty and every row reads
# "no provider selected or registered" (idempotent, cheap on rerun).
_ensure_web_plugins_loaded()
except Exception:
return rows
for capability, getter in (
("web search", get_active_search_provider),
("web extract", get_active_extract_provider),
):
try:
provider = getter()
except Exception:
provider = None
if provider is None:
rows.append(
(
"warn",
capability,
"(no provider selected or registered)",
)
)
continue
name = getattr(provider, "name", None) or type(provider).__name__
if _provider_is_ready(provider):
rows.append(("ok", capability, f"({name})"))
else:
rows.append(
(
"warn",
capability,
f"({name} selected; provider not configured)",
)
)
return rows
def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
"""Adjust runtime-gated tool availability for doctor diagnostics."""
updated_available = list(available)
updated_unavailable = []
for item in unavailable:
name = item.get("name")
if _is_kanban_worker_env_gate(item):
if "kanban" not in updated_available:
updated_available.append("kanban")
continue
if name == "honcho" and _honcho_is_configured_for_doctor():
if "honcho" not in updated_available:
updated_available.append("honcho")
continue
updated_unavailable.append(item)
return updated_available, updated_unavailable
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
@@ -368,14 +202,6 @@ def check_warn(text: str, detail: str = ""):
print(f" {color('⚠', Colors.YELLOW)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
# MERGE-CHECK: HEAD side of this conflict was our legacy monolithic run_doctor body (pre-#102117
# structure + pm-era fixes: inline connectivity probes, memory-provider/profiles checks, state-db
# stats, s6/TCC checks, _pm_venv_active). Upstream decomposed doctor.py into DOCTOR_CHECKS + the
# hermes_cli/doctor_* siblings, so per the merge brief the upstream structure wins and the legacy
# body is dropped. pm-era helpers kept alive above (_pm_tool_path, _doctor_web_capability_rows,
# _apply_doctor_tool_availability_overrides, _is_kanban_worker_env_gate, _honcho_is_configured_for_doctor,
# _has_provider_env_config, _python_install_cmd, _system_package_install_cmd) for the parent to wire
# into the doctor_* siblings. Parent: verify pm wiring / utf-8-sig reads landed in the siblings.
_PLUGIN_COMPAT_LAZY = {
'FTS_STORAGE_VERSION': ('hermes_state_common', 'FTS_STORAGE_VERSION'),
'OPENROUTER_MODELS_URL': ('hermes_constants', 'OPENROUTER_MODELS_URL'),
+34
View File
@@ -391,6 +391,37 @@ _CONFIG_DRIFT_STEPS = (
)
def _check_channel_record_hygiene() -> None:
"""Stale per-install channel records (``update.installs.<sha16>``).
Same report-don't-delete posture as the state sweep. Three shapes
(hermes_cli.update_channel.stale_channel_records): a record whose path
holds a DIFFERENT install now (``replaced``), a record whose path is
gone (``missing``), and a record no live install-state folder claims
(``unclaimed``). Keep-on-doubt: doctor names the config key, the user
removes it.
"""
try:
from hermes_cli.config import load_config
from hermes_cli.update_channel import stale_channel_records
stale = stale_channel_records(load_config() or {})
except Exception as exc:
check_warn("Channel-record hygiene unreadable", f"({exc})")
return
if not stale:
return
for sha16, record, reason in stale:
recorded = record.get("path") or "<no path>"
if reason == "replaced":
detail = f"(the install at {recorded} is a different install now — stale channel entry)"
elif reason == "missing":
detail = f"(nothing at {recorded} — safe to remove update.installs.{sha16})"
else: # unclaimed
detail = f"(no live install claims {sha16} — safe to remove update.installs.{sha16})"
check_warn(f"Stale channel record: {sha16}", detail)
@doctor_check()
def _check_config_drift(should_fix: bool, f: Finding) -> None:
"""Config version, stale root keys, HERMES_MAX_ITERATIONS ghost, deprecations, structure.
@@ -404,6 +435,9 @@ def _check_config_drift(should_fix: bool, f: Finding) -> None:
for step in _CONFIG_DRIFT_STEPS if config_path else (_drift_deprecations,):
with warn_on_error(""):
step(f, should_fix, config_path)
# Stale per-install update-channel records (update.installs.<sha16>):
# report-don't-delete, same posture as the state sweep.
_check_channel_record_hygiene()
@doctor_check("xAI retirement check skipped", "({e})")
+56 -37
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import os
import shutil
import ssl
import subprocess
import sys
from pathlib import Path
@@ -176,51 +177,33 @@ def _check_s6_supervision(issues: list[str]) -> None:
def check_certificates(should_fix: bool = False, issues: "list | None" = None) -> None:
"""Verify the certifi CA bundle is loadable before the first HTTPS call tracebacks.
"""Verify the actual TLS policy is usable before the first HTTPS call tracebacks.
``--fix`` repairs a broken bundle (e.g. a brew Python upgrade rebuilt the venv) by force-reinstalling
certifi into THIS interpreter's environment and re-verifying.
The policy is ``agent.ssl_verify``: the platform verifier (truststore) is
installed process-wide and every stack builds SSL contexts through it. There
is no certifi bundle to validate anymore; the check is that the platform
store is in force and a default context constructs.
"""
try:
from agent.ssl_guard import verify_ca_bundle
from agent.errors import SSLConfigurationError
from agent.ssl_verify import install_truststore
except Exception as e:
return check_warn("SSL certificate check skipped", str(e))
return check_warn("TLS trust check skipped", str(e))
if issues is None:
issues = []
platform_store = install_truststore()
if not platform_store:
check_warn("TLS platform trust store unavailable",
"OpenSSL default trust paths are in use; certificates trusted only by the OS store (a corporate root) will not verify")
try:
verify_ca_bundle()
return check_ok("SSL CA certificate bundle is valid")
except SSLConfigurationError as e:
first_error = str(e)
ssl.create_default_context()
except Exception as e:
return check_warn("SSL certificate check skipped", str(e))
check_fail("SSL CA certificate bundle is broken", first_error)
pip_cmd = f"{sys.executable} -m pip install --force-reinstall certifi"
if not should_fix:
issues.append(f"Repair the CA bundle: run `hermes doctor --fix`, or `{pip_cmd}`")
_fail_and_issue("TLS default SSL context cannot be constructed", str(e),
"Recreate the venv or reinstall Hermes — the TLS stack is broken.", issues)
return
print(" → Repairing: force-reinstalling certifi...")
try:
result = subprocess.run([sys.executable, "-m", "pip", "install", "--force-reinstall", "certifi"],
capture_output=True, text=True, timeout=300)
failure = ("certifi reinstall failed", (result.stderr or result.stdout or "")[-500:]) if result.returncode != 0 else None
except Exception as exc:
failure = ("certifi repair could not run pip", str(exc))
if failure:
return _fail_and_issue(*failure, f"Reinstall certifi manually: {pip_cmd}", issues)
# Drop cached certifi modules so where() resolves the fresh install without a restart.
import importlib
for mod_name in [m for m in sys.modules if m == "certifi" or m.startswith("certifi.")]:
sys.modules.pop(mod_name, None)
importlib.invalidate_caches()
try:
verify_ca_bundle()
check_ok("SSL CA certificate bundle repaired (certifi reinstalled)")
except SSLConfigurationError as e:
_fail_and_issue("SSL CA certificate bundle still broken after reinstall", str(e),
"certifi reinstall did not restore the CA bundle — check for a custom CA env var "
"(SSL_CERT_FILE/REQUESTS_CA_BUNDLE) pointing at a missing file, or recreate the venv.", issues)
if platform_store:
check_ok("TLS platform trust store configured; default SSL context available")
else:
check_ok("TLS default SSL context available (OpenSSL trust paths)")
def _check_gateway_service_linger(issues: list[str]) -> None:
@@ -367,6 +350,28 @@ def _check_security_advisories(should_fix: bool, f: Finding) -> None:
check_warn(f"{h.package}=={h.installed_version} still installed (advisory {h.advisory.id} acknowledged)")
def _staged_venv_dir() -> "Path | None":
"""pm's provisioned runtime venv, or None when nothing is staged.
``pm.packages.Venv().venv_dir()`` is pm's public authority for where
the runtime venv lives (sealed installs: the mutable venv in the
writable hermes root, seeded from the payload; dev installs: the repo
venv). A resolved path without a venv marker is not a provisioned
venv — pm also returns the intended location before first sync, and
doctor must not read an empty directory as staged dependencies.
"""
try:
import pm # noqa: F401 — imports pm.packages, registering the definitions
from pm.packages import Venv
venv_dir = Venv().venv_dir()
except Exception:
return None
if venv_dir is not None and (Path(venv_dir) / "pyvenv.cfg").is_file():
return Path(venv_dir)
return None
@doctor_check()
def _check_python_environment(should_fix: bool, f: Finding) -> None:
"""Interpreter, linked SQLite, venv, macOS TCC anchors/FDA/grants, version-file drift."""
@@ -387,7 +392,21 @@ def _check_python_environment(should_fix: bool, f: Finding) -> None:
if src:
check_info(f"SQLite source id: {(src[:48] + '…') if len(src) > 48 else src}")
_report_database_journal_modes()
check_bool(sys.prefix != sys.base_prefix, "Virtual environment active", ("Not in virtual environment", "(recommended)"))
# Staged dependencies vs the running interpreter, reported as two
# distinct facts. When pm has provisioned the runtime venv, its
# resolved location IS the answer ("dependencies staged"); whether
# THIS process runs inside THAT venv is a separate comparison of
# resolved prefixes (sys.prefix != base_prefix alone would also be
# true for an unrelated venv). Only when nothing is staged does the
# legacy interpreter probe stand alone.
staged = _staged_venv_dir()
if staged is not None:
running_here = Path(sys.prefix).resolve() == staged.resolve()
check_ok(f"Runtime venv staged ({staged})",
"(active in this process)" if running_here else "(this process runs outside it)")
else:
check_bool(sys.prefix != sys.base_prefix, "Virtual environment active",
("Not in virtual environment", "(recommended)"))
# macOS TCC interpreter anchor (#95596): dylib-complete re-land of the mechanism reverted in #95563.
# Silent on non-macOS.
check_macos_tcc_anchor(should_fix=should_fix)
+87 -2
View File
@@ -101,9 +101,84 @@ def _memory_store_flags(hermes_home: Path) -> tuple:
return get_builtin_memory_store_flags({"memory": _doctor_memory_config(hermes_home)})
def check_legacy_desktop_checkout() -> None:
"""Report the unused legacy checkout under an embedded desktop install.
Before the embedded runtime existed, the desktop app installed a git
checkout at $HERMES_HOME/hermes-agent. An embedded app never uses it,
so it sits on disk (1-2 GB of tree + venv). Doctor only REPORTS the
checkout and its size — it never suggests a deletion command, and
never deletes anything itself: a pristineness probe cannot prove no
other client uses the tree or that every local commit is published,
so the review-and-decide step belongs to the user.
"""
from hermes_cli.steward import STEWARD_DESKTOP, sealed_steward
try:
from hermes_cli.main import PROJECT_ROOT
except Exception:
return
if sealed_steward(Path(PROJECT_ROOT)) != STEWARD_DESKTOP:
return
from hermes_cli.doctor import HERMES_HOME, _DHH
checkout = HERMES_HOME / "hermes-agent"
if not (checkout / ".git").exists():
return
_section("Legacy Desktop Checkout")
def _git(*args: str):
try:
return subprocess.run(
["git", "-C", str(checkout), *args],
capture_output=True, text=True, encoding="utf-8", timeout=10,
)
except (OSError, subprocess.SubprocessError):
return None
status = _git("status", "--porcelain")
branch = _git("rev-parse", "--abbrev-ref", "HEAD")
stashes = _git("stash", "list")
# Conservative pristineness: every probe must succeed AND come back
# clean. Any probe failure counts as "has local work".
pristine = (
status is not None and status.returncode == 0 and status.stdout.strip() == ""
and branch is not None and branch.returncode == 0 and branch.stdout.strip() == "main"
and stashes is not None and stashes.returncode == 0 and stashes.stdout.strip() == ""
)
size_note = ""
try:
total = sum(f.stat().st_size for f in checkout.rglob("*") if f.is_file())
size_note = f" (~{total / 1_000_000_000:.1f} GB)"
except OSError:
pass
if pristine:
check_warn(
f"Unused checkout at {_DHH}/hermes-agent{size_note}",
"(the desktop app runs embedded and does not use it; the tree is clean)",
)
print(" Review it and decide whether to keep or remove it — doctor does not delete anything.")
else:
check_info(
f"A checkout exists at {_DHH}/hermes-agent but holds local work "
"(changes, a branch, or stashes). The desktop app does not use "
"it; review it before you remove anything."
)
@doctor_check()
def _check_directory_structure(should_fix: bool, f: Finding) -> None:
"""HERMES_HOME, expected subdirs, SOUL.md, and the enabled built-in memory files."""
try:
check_legacy_desktop_checkout()
except Exception:
pass # best-effort report; must never break the directory check
from hermes_cli.doctor import HERMES_HOME, _DHH
hermes_home = HERMES_HOME
ensure_dir(f, should_fix, hermes_home, f"{_DHH} directory exists", f"Created {_DHH} directory", f"{_DHH} not found")
@@ -259,11 +334,21 @@ def _check_state_db(should_fix: bool, f: Finding) -> None:
def _gh_authenticated() -> bool:
"""Check if gh CLI is authenticated via token file or device flow."""
"""Check if gh CLI is authenticated via token file or device flow.
Availability is resolved through shutil.which (the same probe every
other doctor tool check uses); the spawn itself treats any OS-level
launch failure (a Store/MSIX shim, a deleted binary) as "not
authenticated" rather than crashing the Skills Hub check.
"""
from hermes_cli.doctor_tools import _safe_which
if not _safe_which("gh"):
return False
try:
result = subprocess.run(["gh", "auth", "status", "--json", "authenticated"], capture_output=True, timeout=10)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
except (OSError, subprocess.TimeoutExpired):
return False
+70 -2
View File
@@ -8,6 +8,7 @@ import os
import shutil
import subprocess
import sys
from pathlib import Path
from hermes_cli.doctor_platform import _system_package_install_cmd
from hermes_cli.doctor_report import Finding, _fail_and_issue, check_bool, check_info, check_ok, check_warn, doctor_check
from hermes_cli.vercel_auth import describe_vercel_auth
@@ -22,6 +23,71 @@ def _safe_which(cmd: str) -> str | None:
return None
def _pm_tool_path(name: str) -> Path | None:
"""Answer a tool's binary from the pm store (facts.json), not PATH.
Doctor probes tools (git, rg, ...) that pinned installs run out of
the store; nothing puts the store on an interactive shell's PATH, so
a PATH-only probe reports a healthy managed install as "not found".
This answers from pm's registry/store/package authority: the recorded
entry's binary when it exists on disk, None when unstaged or the
recorded binary is gone (deleted = report missing, never guess).
Contract tests: tests/hermes_cli/test_doctor_pm_store_probe.py.
"""
try:
import pm # noqa: F401 — imports pm.packages, registering the definitions
from pm import paths, registry, store
from pm.lock import Facts
fact = Facts(paths.facts_path()).get(name) or {}
entry_name = fact.get("entry")
if not entry_name:
return None
package = registry.get_package(name)
binary = package.binary(store.Store(paths.store_root()).entry(entry_name), store.current_target())
except Exception:
return None
if binary is None or not binary.is_file():
return None
return binary
def _pm_package_for_command(command: str) -> str | None:
"""The pm package that provisions *command*, derived from pm's own
package definitions (binary_rel → executable basename) — no restated
name table, so new pm packages are covered without doctor changes."""
try:
import pm # noqa: F401 — imports pm.packages, registering the definitions
from pm import registry, store
from pm.packages import BinaryPackage
target = store.current_target()
for package_name in registry.all_packages():
package = registry.get_package(package_name)
if isinstance(package, BinaryPackage):
rel = package._rel(target)
if rel and Path(rel).name.removesuffix(".exe").removesuffix(".cmd") == command:
return package_name
except Exception:
return None
return None
def _doctor_tool(name: str) -> tuple[str | None, str]:
"""Resolve the tool Hermes would actually run: the pm store first
(pinned installs run tools out of the store, which nothing puts on
PATH), then PATH. *name* is the command ("rg"); its pm package
("ripgrep") is resolved from pm's own definitions. Returns
``(path, ok-row detail)``."""
for package_name in (name, _pm_package_for_command(name)):
if not package_name:
continue
staged = _pm_tool_path(package_name)
if staged:
return str(staged), "(pm store)"
return _safe_which(name), ""
def _run_ok(cmd: list[str], timeout: int, **kw) -> bool:
"""True when *cmd* exits 0 within *timeout*; a timeout counts as failure."""
try:
@@ -124,8 +190,10 @@ def _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]
@doctor_check()
def _check_git_and_rg(should_fix: bool, f: Finding) -> None:
check_bool(_safe_which("git"), "git", ("git not found", "(optional)"))
if not check_bool(_safe_which("rg"), ("ripgrep (rg)", "(faster file search)"),
git, git_detail = _doctor_tool("git")
rg, rg_detail = _doctor_tool("rg")
check_bool(git, ("git", git_detail), ("git not found", "(optional)"))
if not check_bool(rg, ("ripgrep (rg)", f"{rg_detail} (faster file search)".strip()),
("ripgrep (rg) not found", "(file search uses grep fallback)")):
check_info(f"Install for faster search: {_system_package_install_cmd('ripgrep')}")
+30 -6
View File
@@ -51,21 +51,45 @@ def _git_output(project_root: Path, *args: str) -> str:
def _get_git_commit(project_root: Path) -> str:
"""Short git commit hash, or '(unknown)'. Docker images exclude ``.git``, so fall back to the build SHA
the Dockerfile bakes into ``<project_root>/.hermes_build_sha``."""
"""Short git commit hash, or '(unknown)'.
The requested *project_root* is authoritative: when it has no git,
the install-stamp fallback is only valid for the RUNNING install
(``hermes_cli.version_info`` has no per-root API — it reads
HERMES_INSTALL_ROOT / the running code root, never an arbitrary
directory). For any other root the provenance is unknown.
"""
value = _git_output(project_root, "rev-parse", "--short=8", "HEAD")
if value:
return value
if project_root.resolve() != get_project_root():
return "(unknown)"
try:
from hermes_cli.build_info import get_build_sha # deferred: keeps dump cheap on non-dump paths
return get_build_sha(short=8) or "(unknown)"
from hermes_cli.version_info import get_code_identity # deferred: keeps dump cheap on non-dump paths
return get_code_identity().get("short_sha") or "(unknown)"
except Exception:
return "(unknown)"
def _get_git_commit_date(project_root: Path) -> str:
"""Return the date the HEAD commit was authored (YYYY-MM-DD), or '' (Docker images have no .git)."""
return _git_output(project_root, "log", "-1", "--format=%cd", "--date=short", "HEAD")
"""Return the date the HEAD commit was authored (YYYY-MM-DD), or ''.
Same authority rule as :func:`_get_git_commit`: the stamp fallback
describes the running install only.
"""
value = _git_output(project_root, "log", "-1", "--format=%cd", "--date=short", "HEAD")
if value:
return value
if project_root.resolve() != get_project_root():
return ""
try:
from datetime import datetime, timezone
from hermes_cli.version_info import get_version_info # deferred: keeps dump cheap on non-dump paths
commit_date = get_version_info().commit_date
return datetime.fromtimestamp(commit_date, tz=timezone.utc).strftime("%Y-%m-%d") if commit_date else ""
except Exception:
return ""
def _redact(value: str) -> str:
+2 -141
View File
@@ -2569,8 +2569,8 @@ def get_python_path() -> str:
from hermes_constants import venv_python_path
except ImportError:
# Update-boundary: a gateway restarted mid-update can hold a stale hermes_constants
# without this symbol; see _reload_hermes_constants() in hermes_cli/managed_uv.py.
from hermes_cli.managed_uv import _reload_hermes_constants
# without this symbol; reload through the runtime repair owner.
from hermes_cli.runtime_repair import _reload_hermes_constants
venv_python_path = _reload_hermes_constants().venv_python_path
venv_python = venv_python_path(venv, windows=is_windows())
@@ -6153,17 +6153,10 @@ def _cmd_migrate_legacy(args):
remove_legacy_hermes_units(interactive=not yes, dry_run=dry_run)
def _cmd_service(args):
"""Windows SCM frontend (MSIX HermesGateway service): translate the SCM
protocol onto the payload launcher (sealed-install surface)."""
_windows_scm_service_command(getattr(args, "gateway_service_action", None))
_GATEWAY_SUBCOMMANDS = {
None: _cmd_run, "run": _cmd_run, "setup": _cmd_setup, "install": _cmd_install,
"uninstall": _cmd_uninstall, "start": _cmd_start, "stop": _cmd_stop, "restart": _cmd_restart,
"status": _cmd_status, "list": _cmd_list, "migrate-legacy": _cmd_migrate_legacy,
"service": _cmd_service,
}
@@ -6208,38 +6201,6 @@ def __getattr__(name): # PEP 562 — lazy so no import cycles
# ---- END PLUGIN-COMPAT ----
def _try_scm_service_restart() -> bool:
"""Restart the HermesGateway Windows Service when it's the running
gateway's manager: sealed MSIX install + service present + running.
Returns True when the SCM restart was issued (the caller must NOT arm
a detached watcher); False on any other machine shape (source
installs, service absent or stopped — the normal paths apply).
"""
if sys.platform != "win32":
return False
try:
import win32serviceutil
except ImportError:
return False
try:
from gateway.windows_service import SERVICE_NAME
from pm.ensure import sealed
if not sealed():
return False
status = win32serviceutil.QueryServiceStatus(SERVICE_NAME)
running = bool(status and status[1] == 4) # SERVICE_RUNNING
if not running:
return False
win32serviceutil.RestartService(SERVICE_NAME)
print(f" ⏻ Restarting {SERVICE_NAME} service (graceful drain → respawn)")
return True
except Exception:
# Not service-managed (most installs): the ordinary restart paths
# own it. Never fail the update over a service lookup.
return False
def _pm_runtime_venv_dir() -> Path | None:
"""The venv pm provisioned for this install, resolved from pm's own
records (facts.json + store layout) — never from interpreter state.
@@ -6283,103 +6244,3 @@ def _systemd_watchdog_service_fields(
return "simple", ""
return "notify", f"NotifyAccess=main\nWatchdogSec={seconds}s\n"
def _windows_scm_service_command(action: str | None) -> None:
"""`hermes gateway service on|off|status` — the MSIX HermesGateway
Windows Service (SCM-registered at MSIX install, demand-start;
plan: gateway-msix-windows-service, Task 5; config-only posture).
"on" = automatic-at-logon + start now (persisted via gateway.service).
"off" = demand-start + stop now.
"status" = SCM state + the config key.
"""
if sys.platform != "win32":
print_error("`hermes gateway service` manages the MSIX HermesGateway "
"Windows Service — not available on this platform.")
return
try:
import win32service
import win32serviceutil
except ImportError:
print_error("pywin32 is required for service management.")
return
from gateway.windows_service import SERVICE_NAME
def _config_get() -> bool:
try:
from hermes_cli.config import get_config_value
return bool(get_config_value("gateway.service", False))
except Exception:
return False
def _scm_status() -> tuple[bool, str]:
"""(installed, state-name) — service absent = (False, 'not installed')."""
try:
status = win32serviceutil.QueryServiceStatus(SERVICE_NAME)
states = {
1: "stopped",
2: "start pending",
3: "stop pending",
4: "running",
5: "continue pending",
6: "pause pending",
7: "paused",
}
return True, states.get(status[1], f"unknown ({status[1]})")
except Exception:
return False, "not installed"
if action in (None, "status"):
installed, state = _scm_status()
enabled = _config_get()
print(f"HermesGateway service: {state}" if installed else
"HermesGateway service: not installed (MSIX bundles only)")
print(f" config gateway.service: {'on' if enabled else 'off'}")
if installed and state == "running" and not enabled:
print_warning("service is running but the config key is off — "
"run `hermes gateway service on` to persist")
return
if action == "on":
installed, _ = _scm_status()
if not installed:
print_error("HermesGateway service is not installed — it ships "
"with the MSIX bundle (desktop app install).")
return
try:
# Automatic at logon + start now, gracefully.
win32serviceutil.ChangeServiceConfig(
SERVICE_NAME, starttype=win32service.SERVICE_AUTO_START
)
win32serviceutil.StartService(SERVICE_NAME)
from cli import save_config_value
save_config_value("gateway.service", True)
print(f"✓ {SERVICE_NAME}: automatic at logon + starting now")
print(" Bots keep running without the desktop app.")
except Exception as exc:
print_error(f"Could not enable service: {exc}")
return
if action == "off":
installed, _ = _scm_status()
if not installed:
print_error("HermesGateway service is not installed.")
return
try:
win32serviceutil.ChangeServiceConfig(
SERVICE_NAME, starttype=win32service.SERVICE_DEMAND_START
)
win32serviceutil.StopService(SERVICE_NAME) # graceful: frontend marker path
from cli import save_config_value
save_config_value("gateway.service", False)
print(f"✓ {SERVICE_NAME}: demand-start + stopping (graceful drain)")
except Exception as exc:
print_error(f"Could not disable service: {exc}")
return
print_error(f"Unknown service action: {action!r} (on|off|status)")
+1 -1
View File
@@ -88,7 +88,7 @@ def gui_install_summary(hermes_home: "Path | None" = None) -> dict:
# removed; sealed trees (nix / desktop-app / docker) get data-only.
from hermes_cli import steward as steward_mod
steward, code_removal_allowed = steward_mod.classify_install(_agent_root(home))
steward, code_removal_allowed = steward_mod.classify_install(home / "hermes-agent")
return {
"hermes_home": str(home),
+1 -1
View File
@@ -151,7 +151,7 @@ def _boot_in_flight(config: dict | None) -> bool:
for manifest in runtimes_root().glob("*/*/manifest.json"):
try:
if _json.loads(manifest.read_text(encoding="utf-8-sig")).get("verified_version"):
if manifest_verified(manifest):
return True
except (ValueError, OSError):
continue
+19 -6
View File
@@ -2015,11 +2015,9 @@ def select_provider_and_model(args=None):
# added here — internal import paths are not a stable API.
_FROZEN_UPDATER_SURFACE: dict[str, tuple[str, ...]] = {
"hermes_cli.update_cmd": (
"_abort_dependency_sync_if_self_locked", "_assess_parked_branch_switch",
"_assess_parked_branch_switch",
"_capture_active_lazy_features", "_capture_active_tool_dependencies",
"_cold_start_windows_gateway_after_update", "_defer_update_for_self_lock",
"_dependency_sync_would_rewrite", "_detect_self_loaded_native_modules",
"_detect_venv_python_processes", "_discard_stashed_changes",
"_cold_start_windows_gateway_after_update", "_detect_venv_python_processes", "_discard_stashed_changes",
"_filter_non_gateway_concurrent_instances", "_fleet_probe_expected_runtimes",
"_get_origin_url", "_handoff_reapable_backend_pids", "_ledger_manual_serve_holders",
"_ledger_reapable_backend_pids", "_leftover_pausable_gateway_pids", "_npm_lockfile_changed",
@@ -2029,10 +2027,10 @@ _FROZEN_UPDATER_SURFACE: dict[str, tuple[str, ...]] = {
"_refresh_active_lazy_features", "_refresh_active_memory_provider_dependencies",
"_refresh_bootstrap_cache_scripts", "_refresh_windows_gateway_launchers",
"_relaunch_stopped_serves", "_reload_updated_runtime_modules",
"_restore_active_tool_dependencies", "_restore_stashed_changes",
"_restore_stashed_changes",
"_resume_windows_gateways_after_update", "_run_logged_subprocess", "_run_pre_update_backup",
"_stash_local_changes_if_needed", "_stop_process_trees", "_sync_with_upstream_if_needed",
"_upgrade_pip_before_lazy_refresh", "_venv_launcher_ancestors",
"_venv_launcher_ancestors",
"_wait_for_windows_update_gateway_exit", "_warn_orphaned_update_autostashes",
"_write_update_incomplete_marker",
),
@@ -2189,6 +2187,17 @@ def _update_preflight_handled(args) -> bool:
managed_error("update Hermes Agent")
return True
# Informational flags never enter the updater: --install-id prints the
# identifier and --set-channel atomically persists a valid channel
# record; both exit here, before the update lock, git, network,
# backups, or process pause. Implementation lives in the channel topic
# module, not this file.
if getattr(args, "install_id", False) or getattr(args, "set_channel", None):
from hermes_cli.update_channel import handle_channel_flags
handle_channel_flags(args)
return True
# --plan is read-only and deployment-kind aware, so it runs BEFORE the
# docker/nix/apt refusal gates: on an image/package-managed install the
# plan itself reports "not updatable in place" plus the right mechanism.
@@ -3358,6 +3367,10 @@ def main():
except Exception:
pass
if _first_positional_argv() != "update":
from hermes_cli.boot_bootstrap import default_project_root, maybe_run_boot_bootstrap
maybe_run_boot_bootstrap(default_project_root())
if _try_termux_fast_tui_launch():
return
if _try_termux_fast_cli_launch():
+2 -1
View File
@@ -1247,7 +1247,8 @@ def _install_desktop_workspace_deps(npm: str, env: dict) -> None:
# (e.g. electron-winstaller's select-7z-arch.js) resolve it even when the
# desktop updater chain lost shell PATH customizations. Wrapping the NixOS
# env keeps its PYTHON hint while restoring managed Node ahead of PATH.
nixos_env = with_hermes_node_path(_nixos_build_env())
from pm import env_for
nixos_env = env_for("npm", base_env=with_hermes_node_path(_nixos_build_env()))
install_result = _run_npm_install_deterministic(npm, PROJECT_ROOT, capture_output=False, env=nixos_env)
if install_result.returncode == 0:
return
+7 -6
View File
@@ -262,10 +262,10 @@ def _recover_core_update_marker_locked() -> None:
try:
from hermes_cli import _install_repair as _ir
# ensure_uv bootstraps uv itself when missing (the early pass's stdlib-only lookup
# cannot), so a venv whose uv vanished mid-update still heals.
from hermes_cli.managed_uv import ensure_uv
ensure_uv()
# The early stdlib-only pass cannot restore a missing uv; PM can.
from pm.ensure import uv as pm_uv
pm_uv(realize=True)
# Shared stdlib executor: late path and pre-import early pass run exactly the same
# reinstall. Its own stdout→stderr redirect nests harmlessly inside ours.
_ir.run_core_install(PROJECT_ROOT)
@@ -414,8 +414,9 @@ def _default_venv_install_target() -> tuple[list[str], dict[str, str] | None]:
"""Return ``(install_cmd_prefix, env)`` for the project venv when possible."""
from hermes_cli.main import PROJECT_ROOT
try:
from hermes_cli.managed_uv import ensure_uv
uv_bin = ensure_uv()
from pm.ensure import uv as pm_uv
uv_bin, _ = pm_uv(realize=True)
except Exception:
uv_bin = None
if uv_bin:
+2 -27
View File
@@ -21,34 +21,9 @@ _IMPORT_NAMES = {
def _provider_extras(provider_name: str, manifest: dict, plugin_dir=None) -> list[str]:
"""The pyproject extras a provider needs on THIS install.
``plugin.yaml``'s ``extra:`` names the baseline. Hindsight's
``local_embedded`` mode needs the daemon+embedder wheel on top
(hindsight-local, #70636) — mode lives in its config file, which the
manifest can't express.
A provider manifest with legacy ``pip_dependencies`` (no ``extra:``,
no pyproject of its own — the third-party plugin shape, e.g. a
directory-installed external provider) is bridged: pm materializes
the specs into a generated pyproject.toml, and the provider becomes
a workspace member installed by the venv sync. Returns [] either
way — no EXTRA to sync, the union carries the deps."""
extras = []
"""The declared core extra; manifest requirements join the staged union."""
declared = manifest.get("extra")
if isinstance(declared, str) and declared:
extras.append(declared)
elif plugin_dir is not None and manifest.get("pip_dependencies"):
try:
from pm.workspace import materialize_legacy_pyproject
materialize_legacy_pyproject(Path(plugin_dir))
except Exception:
pass
# hindsight local_embedded needs hindsight-all, whose protobuf floor
# conflicts with mem0/modal — it cannot be a venv extra. It stays a
# provider-owned install until plugin side-venvs land (plan step 5).
return extras
return [declared] if isinstance(declared, str) and declared else []
def _curses_select(
+1 -4
View File
@@ -193,11 +193,8 @@ def _has_agent_browser() -> bool:
# the setup/status surfaces can't diverge from what browser tools actually
# find at runtime; validate=False keeps this a cheap existence check with
# no subprocess spawn.
# MERGE-CHECK: upstream moved the cascade to tools.browser_tool_install and added a
# Termux carve-out (_requires_real_termux_browser_install) — termux symbols are
# deliberately removed on this branch, so we keep our tools.browser_tool probe.
try:
from tools.browser_tool import _find_agent_browser
from tools.browser_tool_install import _find_agent_browser
except Exception:
# Runtime probe unavailable: fall back to binary presence rather than crashing. Rungs: PATH;
# Hermes-managed Node dirs ($HERMES_HOME/node, prepended to PATH at runtime but usually absent
+1 -1
View File
@@ -158,7 +158,7 @@ def scan_plugin(plugin_dir: Optional[Path], manifest: Optional[Dict[str, Dict[st
src = p.read_text(encoding="utf-8-sig", errors="replace")
except OSError:
continue
hits += scan_source(src, str(p.relative_to(plugin_dir)), manifest)
hits += scan_source(src, p.relative_to(plugin_dir).as_posix(), manifest)
return hits
+124
View File
@@ -0,0 +1,124 @@
"""Validate a proposed plugin set, then publish config and runtime selection."""
from __future__ import annotations
import os
from pathlib import Path
from typing import Iterable, Optional
class AdmissionRefused(RuntimeError):
"""The candidate set was refused; config and environment untouched."""
def candidate_member_dirs(
candidate_enabled: Iterable[str],
candidate_disabled: Iterable[str] = (),
*,
active_plugins_dir: Optional[Path] = None,
extra_dirs: Iterable[Path] = (),
) -> list[Path]:
"""Member dirs implied by the PROPOSED enabled sets: per plugins dir,
its enabled names — the active dir's replaced by the candidate set
(removals excluded), other homes unchanged — filtered to dirs that
actually declare python deps. ``extra_dirs`` (the install target)
join when they declare deps."""
from pm.workspace import enabled_member_dirs, _is_member_candidate
active = Path(active_plugins_dir) if active_plugins_dir else None
members = enabled_member_dirs(
proposed_home=active.parent if active else None,
enabled=candidate_enabled, disabled=candidate_disabled,
)
for directory in extra_dirs:
directory = Path(directory)
if directory not in members and _is_member_candidate(directory):
members.append(directory)
return members
def _config_path() -> Path:
from hermes_cli.config import get_hermes_home
return get_hermes_home() / "config.yaml"
def _atomic_write_bytes(path: Path, data: bytes) -> None:
import tempfile
fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".admission-")
try:
with os.fdopen(fd, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, path)
finally:
Path(temporary).unlink(missing_ok=True)
def _config_commit(candidate_enabled: set, candidate_disabled: set):
"""The before_publish hook: commit the config EXACTLY ONCE while pm
holds the install lock. Returns an undo callable — invoked by
sync_venv only when the subsequent facts write fails — that restores
the previous config bytes atomically (or removes a config this
commit created)."""
path = _config_path()
existed = path.is_file()
previous = path.read_bytes() if existed else None
commit_plugin_sets(candidate_enabled, candidate_disabled)
def undo() -> None:
if previous is None:
path.unlink(missing_ok=True)
else:
_atomic_write_bytes(path, previous)
return undo
def commit_plugin_sets(enabled: set, disabled: set) -> None:
"""Write BOTH plugin keys in ONE atomic config save."""
from hermes_cli.config import read_raw_config
from utils import atomic_roundtrip_yaml_save
path = _config_path()
config = read_raw_config()
plugins_cfg = config.setdefault("plugins", {})
if not isinstance(plugins_cfg, dict):
raise ValueError(f"plugins must be a mapping in {path}")
plugins_cfg["enabled"] = sorted(enabled)
plugins_cfg["disabled"] = sorted(disabled)
atomic_roundtrip_yaml_save(path, config)
def admit_plugin_set_change(
candidate_enabled: set,
candidate_disabled: set,
*,
active_plugins_dir: Optional[Path] = None,
extra_dirs: Iterable[Path] = (),
) -> None:
"""Validate the proposed sets against the active environment and
commit — env selection and config move together, inside pm's single
locked transaction.
Raises :class:`AdmissionRefused` BEFORE anything is published when
the candidate union fails to resolve (conflict, frozen feature set,
…) or when the config write itself fails. The active environment and
the previous config bytes are kept EXACTLY — no rollback re-resolve.
"""
from pm.ensure import sync_venv
members = candidate_member_dirs(
candidate_enabled, candidate_disabled, active_plugins_dir=active_plugins_dir, extra_dirs=extra_dirs
)
try:
sync_venv(
explicit=True,
plugin_dirs=members,
before_publish=lambda: _config_commit(candidate_enabled, candidate_disabled),
)
except Exception as exc:
raise AdmissionRefused(str(exc)) from exc
+101 -16
View File
@@ -12,6 +12,7 @@ Pure, injectable clock/check/update seams for hermetic tests.
from __future__ import annotations
import threading
import time
from pathlib import Path
from typing import Callable, Optional
@@ -20,11 +21,18 @@ _MARKERS_DIR = "plugin-update-checks"
_DEFAULT_INTERVAL_HOURS = 24
def _markers_dir() -> Path:
# resolved per call (tests monkeypatch get_hermes_home)
def _marker_path() -> Path:
"""The last-run marker, resolved per call (tests monkeypatch
get_hermes_home). Derivation only — NO mkdir: a due-check or any
read path must not create user state."""
from hermes_constants import get_hermes_home
d = get_hermes_home() / _MARKERS_DIR
return get_hermes_home() / _MARKERS_DIR / "last-run"
def _markers_dir() -> Path:
"""The markers dir, created on demand — WRITE paths only."""
d = _marker_path().parent
d.mkdir(parents=True, exist_ok=True)
return d
@@ -72,7 +80,7 @@ def check_due(now: Optional[float] = None, interval_hours: Optional[float] = Non
if interval_hours <= 0:
return False
now = time.time() if now is None else now
marker = _markers_dir() / "last-run"
marker = _marker_path()
try:
last = marker.stat().st_mtime
except OSError:
@@ -80,6 +88,12 @@ def check_due(now: Optional[float] = None, interval_hours: Optional[float] = Non
return (now - last) >= interval_hours * 3600
#: Minimal singleflight for one home: two gateway ticks overlapping in
#: this process (boot pass + periodic tick) must not double-fetch or
#: double-write receipts. A plain non-blocking lock — not a manager.
_tick_lock = threading.Lock()
def run_scheduled_check(
*,
run_checks_fn: Callable[..., list],
@@ -91,8 +105,9 @@ def run_scheduled_check(
) -> Optional[list]:
"""One cadence tick: gate → check → receipt → (opt-in) apply.
Returns the check results, or None when not due / disabled. NEVER
raises — a cadence failure is logged, never fatal.
Returns the check results, or None when not due / disabled / another
tick is already in flight. NEVER raises — a cadence failure is
logged, never fatal.
"""
import logging
@@ -100,24 +115,43 @@ def run_scheduled_check(
log = logging.getLogger(__name__)
if not check_due(now=now, config_get=config_get):
return None
if not _tick_lock.acquire(blocking=False):
log.debug("plugin update check already in flight — skipping tick")
return None
try:
return _run_check_locked(
run_checks_fn=run_checks_fn,
plugins_dir=plugins_dir,
apply_updates_fn=apply_updates_fn,
log=log,
config_get=config_get,
)
finally:
_tick_lock.release()
def _run_check_locked(
*,
run_checks_fn: Callable[..., list],
plugins_dir: Path,
apply_updates_fn: Optional[Callable[[str], None]],
log,
config_get: Callable = None,
) -> list:
check_ok = True
try:
results = run_checks_fn(plugins_dir)
except Exception:
log.warning("plugin update check failed", exc_info=True)
results = []
check_ok = False
# the receipt — the surface every medium reads
try:
from pm import receipt
receipt.begin("plugin-check")
token = receipt.begin("plugin-check")
receipt.record_plugin_checks(results)
updates = [
r for r in results
if getattr(r, "update_available", None) is True
]
receipt.finalize("ok" if not updates else "updates-available")
updates = [r for r in results if getattr(r, "update_available", None) is True]
receipt.finalize("failed" if not check_ok else "updates-available" if updates else "ok", token=token)
except Exception:
log.debug("plugin-check receipt write failed", exc_info=True)
@@ -147,7 +181,7 @@ def run_scheduled_check(
if getattr(r, "klass", "") == "git":
try:
apply_updates_fn(r.name)
except Exception:
except (Exception, SystemExit):
log.warning("auto-apply of %s failed", r.name, exc_info=True)
# stamp the marker AFTER a completed run (even a failed one — a
@@ -157,4 +191,55 @@ def run_scheduled_check(
marker.write_text(str(int(time.time())), encoding="utf-8")
except OSError:
pass
return results
return results
# ---------------------------------------------------------------------------
# the production caller: real seams for the gateway boot/housekeeping tick
# ---------------------------------------------------------------------------
def maybe_run_gateway_check(
*,
run_checks_fn: Optional[Callable[..., list]] = None,
apply_updates_fn: Optional[Callable[[str], None]] = None,
plugins_dir: Optional[Path] = None,
log=None,
now: Optional[float] = None,
) -> Optional[list]:
"""The tick every gateway boot / housekeeping pass calls.
Fills in the REAL seams ``run_scheduled_check`` leaves injectable:
the same read-only ``plugins_updates.run_checks`` the manual
``hermes plugins check-updates`` uses (urllib feed fetch, git
ls-remote, PyPI latest), the real plugins dir, and the manual
``hermes plugins update <name>`` flow as the opt-in apply path —
auto-apply rides the identical security/consent/scan pipeline.
Returns the check results, or None when not due / disabled. Due-gated
by ``plugins.auto_update_check_hours`` (0 disables); a network error
costs one warning and a stamped marker — never an apply.
"""
if plugins_dir is None:
from hermes_cli import plugins_cmd
plugins_dir = plugins_cmd._plugins_dir()
if run_checks_fn is None:
# ONE shared network-default implementation — plugins_updates
# owns default_fetch / default_ls_remote / _default_pypi_latest;
# the manual `hermes plugins check-updates` passes the same
# defaults. No boilerplate re-derivation here.
from hermes_cli.plugins_updates import run_checks
run_checks_fn = run_checks
if apply_updates_fn is None:
from hermes_cli import plugins_cmd
from functools import partial
apply_updates_fn = partial(plugins_cmd.cmd_update, interactive=False)
return run_scheduled_check(
run_checks_fn=run_checks_fn,
plugins_dir=plugins_dir,
apply_updates_fn=apply_updates_fn,
log=log,
now=now,
)
+126 -141
View File
@@ -340,13 +340,17 @@ def _missing_env_specs(manifest: dict) -> list[dict]:
def _install_plugin_python_deps(
manifest: dict, target: Path, console
) -> tuple[bool, Optional[str]]:
"""Consent + try-resolve for plugin python deps (settled 2026-09-02).
"""Consent gate for plugin python deps (settled 2026-09-02; C13 rework).
y/n prompt (auto-NO when non-interactive) → materialize the legacy
bridge if needed → resolve through the pm workspace union. Returns
(installed, reason): installed=True when the deps resolve; on
conflict the resolver's message is the reason. Never raises — the
caller keeps the plugin installed-but-disabled on failure.
Node sidecar: y/n prompt → ``npm ci`` into the plugin's OWN
node_modules (separate question, failure never blocks enable).
Python deps: NO resolution here — the resolve runs inside the ONE
admission transaction when the enable commits
(:func:`_admit_and_save_plugin_sets`), so the environment and the
config always change together or not at all. Returns (consented,
reason): consented=True when the user accepted (or no prompt was
needed); a decline/skip returns False and NOTHING is installed.
Never raises — the caller keeps the plugin installed-but-disabled.
"""
deps = manifest.get("python_dependencies") or []
if not isinstance(deps, list):
@@ -413,39 +417,9 @@ def _install_plugin_python_deps(
)
return False, "dependency install declined"
# Route through pm.sync_venv — the ONE sync authority: it owns the
# lazy-off gate, the frozen-set refusal, the workspace union (with
# bisect + write-back), and the universal receipt. plugins install
# never drives uv itself (settled: it only drops the folder; the
# sync step does the rest). The plugin is NOT yet in plugins.enabled,
# so the resolve runs the WOULD-BE union: enabled members + this
# plugin, as a dry check via resolve_union (bisect decisions for
# the not-yet-enabled plugin are advisory here — the real sync
# after enable re-runs the union through sync_venv).
try:
from pm.ensure import lazy_installs_allowed
from pm.workspace import enabled_member_dirs, materialize_legacy_pyproject, resolve_union
# Legacy manifest-only plugins need their generated pyproject
# before the union can see their deps (no-op when lazy-off).
materialize_legacy_pyproject(target)
if not lazy_installs_allowed():
return False, "lazy installs are disabled — install the deps manually"
from pm.packages import Venv
members = enabled_member_dirs()
survivors, decisions = resolve_union(
members + ([target] if target not in members else []),
venv_dir=Venv().venv_dir(),
)
if target not in survivors:
reason = next(
(d["reason"] for d in decisions if d["plugin"] == target.name),
"did not resolve",
)
return False, reason
except Exception as exc:
return False, str(exc)
# Consent only — the python-deps resolution itself runs inside the ONE
# admission transaction at enable-commit time (C13): env + config move
# together or not at all.
return True, None
@@ -680,7 +654,7 @@ def _swap_in_plugin(tmp_target: Path, target: Path, backup: Path, old_metadata:
_write_install_metadata(new_metadata)
except Exception:
if target.exists():
shutil.rmtree(target)
_rmtree_force(target)
if replaced_existing and backup.exists():
os.replace(backup, target)
if old_metadata:
@@ -850,58 +824,56 @@ def cmd_install(
f"plugin.json, or __init__.py. It may not be a valid Hermes plugin.")
_prompt_plugin_env_vars(installed_manifest, console)
deps_ok, deps_reason = _install_plugin_python_deps(
installed_manifest, target, console
)
should_enable = enable
if should_enable is None:
should_enable = _is_tty() and _ask_yes(f" Enable '{installed_name}' now? [y/N]: ")
deps_ok, deps_reason = (True, None)
if should_enable:
deps_ok, deps_reason = _install_plugin_python_deps(installed_manifest, target, console)
_display_after_install(target, identifier)
should_enable = enable
# ONE admission transaction for the enable (C13): resolve the candidate
# union (enabled members + this target) and commit the config in the
# same step — env and config change together or not at all. No
# duplicate sync here: nothing was resolved before this point.
if should_enable and not deps_ok:
# Explicitly requested enable, but the deps failed to resolve:
# enabling would break the venv union on the next sync. Refuse
# with the resolver's reason.
# Consent declined/skipped: nothing was installed or changed, so
# enabling is refused without touching config or environment.
console.print(
f"[red]✗[/red] Cannot enable [bold]{installed_name}[/bold]: "
f"its Python dependencies did not resolve: {deps_reason}"
f"{deps_reason}"
)
console.print(
"[dim]The plugin stays installed but disabled; re-enable "
"after resolving the conflict.[/dim]"
)
should_enable = False
if should_enable is None:
if deps_ok and sys.stdin.isatty() and sys.stdout.isatty():
try:
answer = input(
f" Enable '{installed_name}' now? [y/N]: ",
).strip().lower()
should_enable = answer in {"y", "yes"}
except (EOFError, KeyboardInterrupt):
should_enable = False
else:
should_enable = False
if not deps_ok:
console.print(
f"[yellow]Plugin [bold]{installed_name}[/bold] installed "
f"but NOT enabled — Python dependencies did not resolve:[/yellow]"
)
console.print(f" [dim]{deps_reason}[/dim]")
console.print(
"[dim]Re-enable after resolving the conflict "
f"(hermes plugins enable {installed_name}).[/dim]\n"
)
if should_enable:
from hermes_cli.plugins_admission import AdmissionRefused
enabled = _get_enabled_set()
disabled = _get_disabled_set()
enabled.add(installed_name)
disabled.discard(installed_name)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(
f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.",
)
try:
_admit_and_save_plugin_sets(
enabled,
disabled,
extra_dirs=[target],
console=console,
action=f"Enable '{installed_name}'",
)
except AdmissionRefused:
console.print(
"[dim]The plugin stays installed but disabled; re-enable "
"after resolving the conflict.[/dim]"
)
else:
console.print(
f"[green]✓[/green] Plugin [bold]{installed_name}[/bold] enabled.",
)
else:
console.print(
f"[dim]Plugin installed but not enabled. "
@@ -940,7 +912,7 @@ def _pull_plugin_update(target: Path, pinned_msg, not_git_msg, before_pull=None)
return output
def cmd_update(name: str) -> None:
def cmd_update(name: str, *, interactive: bool = True) -> None:
"""Update an installed plugin by pulling latest from its git remote."""
from rich.markup import escape
console = _console()
@@ -959,32 +931,6 @@ def cmd_update(name: str) -> None:
_rescan_after_update(target, name, console)
_post_pull_housekeeping(target, console)
scan_result = scan_plugin(target, source=name)
allowed, reason = should_allow_plugin_install(scan_result)
if allowed is not True:
console.print()
console.print(
f"[yellow]⚠ Security scan flagged the updated plugin:[/yellow] {reason}",
)
console.print(format_scan_report(scan_result))
if scan_result.verdict == "dangerous":
enabled = _get_enabled_set()
disabled = _get_disabled_set()
if name in enabled or name not in disabled:
enabled.discard(name)
disabled.add(name)
_save_enabled_set(enabled)
_save_disabled_set(disabled)
console.print(
f"[red]Plugin '{name}' has been disabled.[/red] Review the "
f"findings, then re-enable with `hermes plugins enable {name}` "
f"if you trust them.",
)
# Same stale-bytecode class as the main checkout (#6207/#60242): the
# pull just changed .py files under this plugin dir, so drop any
# __pycache__ compiled from the previous revision.
_clear_plugin_bytecode(target)
# Member-manifest plugins: the pull may have changed its pyproject
# pins — re-sync the union so new deps land (Task 5, plugin
# auto-update plan). The venv stamp now includes member pyproject
@@ -1007,9 +953,6 @@ def cmd_update(name: str) -> None:
"to retry the dependency sync.[/dim]"
)
# Copy any new .example files
_copy_example_files(target, console)
# Update-time re-consent (#64228): if the new version declares
# capabilities the granted set lacks, surface the diff and require
# re-consent for the additions. The stored consent hash detects a
@@ -1021,7 +964,10 @@ def cmd_update(name: str) -> None:
if declared_caps:
from hermes_cli.plugin_capabilities import declared_set_changed, pending_capabilities
if pending_capabilities(plugin_id, declared_caps) or declared_set_changed(plugin_id, declared_caps):
_run_capability_consent(console, plugin_id, declared_caps, context="update")
if interactive:
_run_capability_consent(console, plugin_id, declared_caps, context="update")
else:
console.print(f"[yellow]Plugin {plugin_id} has new capabilities; review them with `hermes plugins capabilities {plugin_id}`.[/yellow]")
out = output.strip()
if "Already up to date" in out:
@@ -1114,8 +1060,35 @@ def _save_enabled_set(enabled: set) -> None:
def _save_plugin_sets(enabled: set, disabled: set) -> None:
_save_enabled_set(enabled)
_save_disabled_set(disabled)
from hermes_cli.plugins_admission import admit_plugin_set_change
admit_plugin_set_change(enabled, disabled, active_plugins_dir=_plugins_dir())
def _admit_and_save_plugin_sets(
enabled: set, disabled: set, *, extra_dirs=(), console=None, action: str = "enable"
) -> None:
"""ONE admission authority for proposed enabled/disabled sets (C13):
the candidate union is resolved against the ACTIVE environment and
the config commits inside the same locked pm transaction (sync's
``before_publish`` hook) — a refusal or a config-write failure
publishes nothing: previous config bytes AND previous environment
stay exactly in place. Raises :class:`AdmissionRefused` (UI callers
catch and surface it — admission never auto-disables to fit)."""
from hermes_cli.plugins_admission import AdmissionRefused, admit_plugin_set_change
try:
admit_plugin_set_change(
enabled, disabled, active_plugins_dir=_plugins_dir(), extra_dirs=extra_dirs
)
except AdmissionRefused as exc:
if console is not None:
console.print(f"[red]✗[/red] {action} refused: {exc}")
console.print(
"[dim]config.yaml and the active environment are unchanged. "
"Run `hermes pm install` to resolve dependencies, then retry.[/dim]"
)
raise
_BASIC_AUTH_PLUGIN_KEYS = frozenset({"basic", "dashboard_auth/basic"})
@@ -1144,7 +1117,7 @@ def _discard_key_and_leaf(names: set, key: str) -> None:
def _set_plugin_enabled(name: str, *, enable: bool) -> None:
"""Move *name* between the enabled allow-list and the disabled deny-list and persist both."""
"""Persist the proposed selection through the same dependency transaction."""
enabled = _get_enabled_set()
disabled = _get_disabled_set()
(enabled.add if enable else enabled.discard)(name)
@@ -1221,7 +1194,7 @@ def cmd_enable(name: str, allow_tool_override: Optional[bool] = None) -> None:
manifest_name = next((e[0] for e in _discover_all_plugins() if e[5] == key), None)
if manifest_name is not None:
disabled.discard(manifest_name)
_save_plugin_sets(enabled, disabled)
_admit_and_save_plugin_sets(enabled, disabled, console=console, action=f"Enable '{key}'")
console.print(f"[green]✓[/green] Plugin [bold]{key}[/bold] enabled. Takes effect on next session.")
# Built-in tool override is a privileged grant; bundled plugins are trusted.
@@ -1612,40 +1585,15 @@ def cmd_trust_update_url(name: str) -> None:
def cmd_check_updates(args: Any | None = None) -> None:
"""Read-only: is any installed plugin outdated? NEVER mutates."""
import urllib.error
import urllib.request
from rich.console import Console
from rich.table import Table
from hermes_cli.plugins_updates import _default_pypi_latest, run_checks
console = Console()
plugins_dir = _plugins_dir()
def _fetch(url: str) -> str:
with urllib.request.urlopen(url, timeout=10.0) as resp:
data = resp.read(1 * 1024 * 1024)
return data.decode("utf-8", errors="replace")
from hermes_cli.plugins_updates import run_checks
def _ls_remote(source: str) -> str:
git_exe = _resolve_git_executable()
proc = subprocess.run(
[git_exe or "git", "ls-remote", source, "HEAD"],
capture_output=True,
text=True,
timeout=30,
)
if proc.returncode != 0:
raise RuntimeError((proc.stderr or "ls-remote failed").strip()[:200])
# 'sha\trefs/heads/...' or empty
out = (proc.stdout or "").strip()
return out.split("\t")[0] if out else ""
results = run_checks(
plugins_dir, fetch=_fetch, ls_remote=_ls_remote,
pip_pypi_latest=_default_pypi_latest,
)
results = run_checks(plugins_dir)
if getattr(args, "json", False):
print(json.dumps([r.to_json() for r in results], indent=2))
@@ -1896,7 +1844,10 @@ def _persist_plugin_selection(plugin_keys, chosen, disabled) -> tuple[bool, set]
changed = new_enabled != _get_enabled_set() or new_disabled != disabled
if changed:
_save_plugin_sets(new_enabled, new_disabled)
# C13: the composite UI's candidate goes through the ONE admission
# authority — refusal raises AdmissionRefused BEFORE any config
# write; the caller surfaces it and the selection stays unsaved.
_admit_and_save_plugin_sets(new_enabled, new_disabled, action="Save plugin selection")
return changed, new_enabled
@@ -2010,7 +1961,17 @@ def _run_composite_ui(curses, plugin_keys, plugin_labels, plugin_selected, disab
curses.wrapper(_draw)
flush_stdin()
changed, new_enabled = _persist_plugin_selection(plugin_keys, chosen, disabled)
from hermes_cli.plugins_admission import AdmissionRefused
try:
changed, new_enabled = _persist_plugin_selection(plugin_keys, chosen, disabled)
except AdmissionRefused as exc:
console.print(f"[red]✗[/red] Plugin selection refused, not saved: {exc}")
console.print(
"[dim]config.yaml and the active environment are unchanged. "
"Run `hermes pm install` to resolve, then retry.[/dim]"
)
return
if changed:
console.print(
f"\n[green]\u2713[/green] General plugins: {len(new_enabled)} enabled, "
@@ -2049,7 +2010,7 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disable
except (ValueError, KeyboardInterrupt, EOFError):
return
print()
_persist_plugin_selection(plugin_keys, chosen, disabled)
_save_plugin_selection_fallback(plugin_keys, chosen, disabled)
if categories:
print(color("\n Provider Plugins", Colors.YELLOW))
@@ -2067,6 +2028,17 @@ def _run_composite_fallback(plugin_keys, plugin_labels, plugin_selected, disable
print()
def _save_plugin_selection_fallback(plugin_keys, chosen, disabled) -> None:
"""The text fallback's save: same admission authority, refusal printed."""
from hermes_cli.plugins_admission import AdmissionRefused
try:
_persist_plugin_selection(plugin_keys, chosen, disabled)
except AdmissionRefused as exc:
print(f" Plugin selection refused, not saved: {exc}")
print(" config.yaml and the active environment are unchanged.")
def dashboard_install_plugin(identifier: str, *, force: bool, enable: bool) -> dict[str, Any]:
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
warnings: list[str] = []
@@ -2091,7 +2063,15 @@ def dashboard_install_plugin(identifier: str, *, force: bool, enable: bool) -> d
return {"ok": False, "error": str(exc)}
if enable:
_set_plugin_enabled(installed_name, enable=True)
from hermes_cli.plugins_admission import AdmissionRefused
try:
_set_plugin_enabled(installed_name, enable=True)
except AdmissionRefused as exc:
return {
"ok": False, "error": f"enable refused: {exc}",
"plugin_name": installed_name, "enabled": False,
}
ap = target / "after-install.md"
return {
"ok": True, "plugin_name": installed_name, "warnings": warnings,
@@ -2166,7 +2146,12 @@ def dashboard_set_agent_plugin_enabled(name: str, *, enabled: bool) -> dict[str,
dis = _get_disabled_set()
if ((name in en and name not in dis) if enabled else (name not in en and name in dis)):
return {"ok": True, "name": name, "unchanged": True}
_set_plugin_enabled(name, enable=enabled)
from hermes_cli.plugins_admission import AdmissionRefused
try:
_set_plugin_enabled(name, enable=enabled)
except AdmissionRefused as exc:
return {"ok": False, "error": str(exc), "name": name, "unchanged": True}
_toggle_plugin_toolset(name, enable=enabled)
return {"ok": True, "name": name, "unchanged": False}
+115 -11
View File
@@ -16,6 +16,7 @@ from __future__ import annotations
import importlib.metadata
import json
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
@@ -30,6 +31,7 @@ from hermes_cli.plugins_provenance import (
_FETCH_TIMEOUT = 10.0
_MAX_FEED_BYTES = 1 * 1024 * 1024
_FULL_GIT_SHA_RE = re.compile(r"[0-9a-fA-F]{40}")
@dataclass
@@ -135,9 +137,39 @@ def check_provenanced(
return result
result.latest = feed.get("version")
result.min_hermes = feed.get("min_hermes")
result.update_available = (
result.latest is not None and result.latest != result.current
)
# Like-for-like identity only (audit C17): a feed that ships a full
# git SHA compares SHA vs recorded revision; otherwise the feed's
# semantic version compares against the installed manifest's
# version. A SHA is never compared to a semantic version, and an
# uncomparable pair reads as unknown — not as an update.
feed_git = (feed.get("artifacts") or {}).get("git")
if feed_git and _FULL_GIT_SHA_RE.fullmatch(feed_git):
if not result.current or not _FULL_GIT_SHA_RE.fullmatch(
str(result.current)
):
result.update_available = None
result.reason = (
"feed declares a git sha but the install records no "
"full revision sha to compare"
)
return result
# Case-equivalent hex only after format validation.
result.latest = feed_git.lower()
result.update_available = feed_git.lower() != result.current.lower()
return result
installed_version = _read_manifest_field(prov.path, "version")
if installed_version is None:
result.update_available = None
result.reason = (
f"feed declares version {result.latest!r} but the installed "
"plugin.yaml records no version to compare"
)
return result
# Like-for-like fields: the semantic branch compares version vs
# version, so `current` reports the installed version, not the
# recorded revision sha.
result.current = installed_version
result.update_available = result.latest != installed_version
return result
# ── 3. no update_url anywhere + git row → ls-remote ────────────
@@ -160,7 +192,10 @@ def parse_feed_yml(text: str) -> dict:
min_hermes, artifacts{git,bundle,bundle_sha256}, notes_url."""
import yaml
data = yaml.safe_load(text)
try:
data = yaml.safe_load(text)
except yaml.YAMLError as exc:
raise ValueError(f"invalid feed YAML: {exc}") from exc
if not isinstance(data, dict):
raise ValueError("feed must be a YAML mapping")
version = data.get("version")
@@ -173,12 +208,27 @@ def parse_feed_yml(text: str) -> dict:
out[key] = value.strip()
artifacts = data.get("artifacts")
if isinstance(artifacts, dict):
git = artifacts.get("git")
if git is not None and not isinstance(git, str):
raise ValueError("feed artifacts.git must be a string")
out["artifacts"] = {
k: v for k, v in artifacts.items() if isinstance(v, str)
}
return out
def _owning_distribution(ep) -> Optional[str]:
"""The name of the distribution the entry point belongs to — metadata,
never a guess derived from the import module (audit C17)."""
dist = getattr(ep, "dist", None)
if dist is None:
return None
try:
return dist.metadata.get("Name") or None
except Exception:
return None
def check_pip_plugins(
*,
installed_version: Callable[[str], str], # dist name -> version
@@ -193,9 +243,19 @@ def check_pip_plugins(
)
results: list[CheckResult] = []
for ep in entry_points:
dist_name = getattr(ep, "dist_name", None) or (
ep.value.split(":")[0].split(".")[0] if ep.value else ep.name
)
dist_name = getattr(ep, "dist_name", None) or _owning_distribution(ep)
if not dist_name:
results.append(
CheckResult(
name=ep.name,
klass="pip",
reason=(
"entry point has no owning distribution metadata; "
"cannot determine what to check"
),
)
)
continue
try:
current = installed_version(dist_name)
except importlib.metadata.PackageNotFoundError:
@@ -233,17 +293,61 @@ def check_pip_plugins(
return results
def default_fetch(url: str) -> str:
"""The real feed fetcher: url -> text (raises on failure).
ONE implementation shared by the manual ``hermes plugins
check-updates`` and the cadence tick — callers never re-derive it.
"""
import urllib.request
with urllib.request.urlopen(url, timeout=_FETCH_TIMEOUT) as resp:
data = resp.read(_MAX_FEED_BYTES)
return data.decode("utf-8", errors="replace")
def default_ls_remote(source: str) -> str:
"""The real git probe: source -> HEAD sha (raises on failure).
Uses the same resolved git executable the CLI path resolves. Lazily
imports plugins_cmd (which lazily imports this module) — no import
cycle at module load.
"""
from hermes_cli.plugins_cmd import _resolve_git_executable
proc = subprocess.run(
[_resolve_git_executable() or "git", "ls-remote", source, "HEAD"],
capture_output=True,
text=True,
timeout=30,
)
if proc.returncode != 0:
raise RuntimeError((proc.stderr or "ls-remote failed").strip()[:200])
# 'sha\trefs/heads/...' or empty
out = (proc.stdout or "").strip()
return out.split("\t")[0] if out else ""
def run_checks(
plugins_dir: Path,
*,
fetch: Callable[[str], str],
ls_remote: Callable[[str], str],
fetch: Optional[Callable[[str], str]] = None,
ls_remote: Optional[Callable[[str], str]] = None,
include_pip: bool = True,
pip_installed_version: Callable[[str], str] = importlib.metadata.version,
pip_pypi_latest: Optional[Callable[[str], Optional[str]]] = None,
pip_entry_points: Optional[list] = None,
) -> list[CheckResult]:
"""All checks for one plugins dir. NEVER mutates anything."""
"""All checks for one plugins dir. NEVER mutates anything.
``fetch``/``ls_remote`` default to :func:`default_fetch` /
:func:`default_ls_remote` — the single shared network implementation
the manual command and the cadence tick both ride.
"""
if fetch is None:
fetch = default_fetch
if ls_remote is None:
ls_remote = default_ls_remote
results = [
check_provenanced(p, fetch=fetch, ls_remote=ls_remote)
for p in plugins_provenance(plugins_dir)
@@ -271,4 +375,4 @@ def _default_pypi_latest(dist: str) -> Optional[str]:
data = json.loads(resp.read(_MAX_FEED_BYTES))
return (data.get("info") or {}).get("version")
except Exception:
return None
return None
+58 -2
View File
@@ -297,6 +297,27 @@ def step_provision_runtimes() -> dict:
return {"ok": True, "refreshed": refreshed}
def step_report_runtime_drift() -> dict:
"""CHECK-ONLY machine step for automatic boot.
``pm.check()`` is O(1) stamp comparisons against the installed-state
ledger — no network, no installs. Drift is reported loudly (the same
verdict the CLI startup block prints) so the user knows to run
``hermes pm install``; boot itself never installs anything. The
installing pass is MACHINE_STEPS below, owned by the explicit update
flow — automatic boot must not run network installers.
"""
import pm
problems = pm.check()
if not problems:
return {"ok": True, "skipped": "current"}
logger.warning(
"install out of sync (%s) — run `hermes pm install`", "; ".join(problems)
)
return {"ok": True, "drift": problems}
def step_expose_cli() -> dict:
"""Keep the user-facing ``hermes`` launchers alive across updates.
@@ -341,6 +362,21 @@ def step_expose_cli() -> dict:
return _symlink_sealed_launchers(root.parent / "bin")
return {"ok": True, "skipped": "bundle-owns-launchers"}
# pm-store-managed install: the launchers are owned by
# hermes_cli._launchers (staged by install.ps1 / _install_repair —
# boot the STORE python with a repo-first PYTHONPATH, never
# venv/Scripts|bin python). This step's venv-wrapper shape is NOT
# that shape; writing it here would rewrite a store launcher into a
# venv boot. No alternate writer: the existing owner maintains
# these (per-name, at install and at process-start repair).
try:
from hermes_cli._launchers import resolve_store_python
if resolve_store_python(root) is not None:
return {"ok": True, "skipped": "store-launchers-owned"}
except Exception as exc: # noqa: BLE001 — never kill boot over a probe
logger.debug("store-python probe failed: %s", exc)
# Capability probe for the checkout shape: the wrapper bodies bake
# these two paths into text, so both must exist to have anything to
# point at. A checkout with a nuked venv lands here — skip; the
@@ -473,8 +509,20 @@ HOME_STEPS: tuple = (
("expose_cli", step_expose_cli),
)
# Machine steps may be slow (network installers); boot bootstrap runs them
# AFTER writing the machine record, detached from boot readiness.
# What AUTOMATIC boot runs for the home scope: HOME_STEPS minus the
# bundled-skills sync — startup skill syncing already has exactly one
# owner per entrypoint (main.py's _sync_bundled_skills_for_startup for
# the CLI, gateway/run.py's configure-logging sync for the gateway), and
# `hermes update`'s explicit pass keeps the full HOME_STEPS. Adding the
# sync here made boot a third writer racing those two.
BOOT_HOME_STEPS: tuple = tuple(
step for step in HOME_STEPS if step[0] != "sync_skills"
)
# Machine steps may be slow (network installers); they run ONLY in the
# explicit update phase (``python -m hermes_cli.post_update --update-phase``,
# i.e. `hermes update`'s fresh-interpreter step pass) — never on automatic
# boot.
#
# There is no separate cua-driver refresh step: pinned managed tools ride
# provision_runtimes' pm sweep — one authority on every tool's version.
@@ -482,6 +530,14 @@ MACHINE_STEPS: tuple = (
("provision_runtimes", step_provision_runtimes),
)
# What AUTOMATIC boot runs for the machine scope: the same pm.check()
# verdict, reported instead of installed (boot must not block on, or
# trigger, network installs — the user runs `hermes pm install` or the
# explicit update pass when they want the repair).
BOOT_MACHINE_STEPS: tuple = (
("report_runtime_drift", step_report_runtime_drift),
)
def run_steps(steps: Iterable) -> dict:
"""Run steps in order; one failure never stops the rest.
+5 -23
View File
@@ -30,29 +30,11 @@ ENV_EXAMPLE_FILENAME = ".env.EXAMPLE"
# ``distribution_owned:``. config.yaml is dist-owned but preserved on update by default.
DEFAULT_DIST_OWNED: Tuple[str, ...] = ("SOUL.md", "config.yaml", "mcp.json", "skills", "cron", MANIFEST_FILENAME)
# Paths NEVER part of a distribution: user-owned, protected on update. Keep consistent with
# ``profiles.py`` export exclusions plus the ``local/`` convention for user customizations.
# MERGE-CHECK: upstream made this a self-contained frozenset superseding our
# DEFAULT_EXPORT_EXCLUDE_ROOT import; its entries are a functional superset (includes
# memories/sessions/plans/workspace/home), so we take theirs to avoid coupling to
# profiles.py internals while preserving the same protections.
USER_OWNED_EXCLUDE: frozenset = frozenset({
# Credentials & runtime secrets
"auth.json", ".env",
# Databases & runtime state
"state.db", "state.db-shm", "state.db-wal",
"hermes_state.db", "response_store.db",
"response_store.db-shm", "response_store.db-wal",
"gateway.pid", "gateway_state.json", "processes.json",
"auth.lock", "active_profile", ".update_check",
"errors.log", ".hermes_history",
# User data
"memories", "sessions", "logs", "plans", "workspace", "home",
"image_cache", "audio_cache", "document_cache",
"browser_screenshots", "checkpoints", "sandboxes",
"backups", "cache",
# User customization namespace
"local",
# Distribution-specific user data extends the shared profile/runtime exclusions.
from hermes_cli.profiles import DEFAULT_EXPORT_EXCLUDE_ROOT
USER_OWNED_EXCLUDE: frozenset = DEFAULT_EXPORT_EXCLUDE_ROOT | frozenset({
"memories", "sessions", "plans", "workspace", "home", "backups", "cache", "local",
})
+139
View File
@@ -0,0 +1,139 @@
"""Dependency-environment paths shared by PM and pre-import launchers.
Only stdlib and hermes_constants: environment selection must work before
any dependency from that environment has been imported.
"""
from __future__ import annotations
import hashlib
import json
import os
from pathlib import Path
from hermes_constants import get_default_hermes_root, project_venv_dir
def install_key(project_root: Path) -> str:
canonical = str(Path(project_root).resolve())
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
def installs_root() -> Path:
return get_default_hermes_root() / "installs"
def install_state_dir(project_root: Path) -> Path:
return installs_root() / install_key(project_root)
def runtime_facts_path(project_root: Path) -> Path:
return install_state_dir(project_root) / "facts.json"
def base_venv(project_root: Path) -> Path:
root = Path(project_root).resolve()
manifest_path = root.parent / "manifest.json"
if manifest_path.is_file():
manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
if (root.parent / manifest.get("repo", "")).resolve() == root:
venv = (root.parent / manifest["venv"]).resolve()
if not venv.is_relative_to(root.parent):
raise RuntimeError("payload environment escapes its root")
return venv
return project_venv_dir(root) or root / "venv"
def store_root(project_root: Path) -> Path:
"""Read the stamped byte store before PM dependencies are available."""
override = os.environ.get("HERMES_RUNTIME_DIR")
if override:
return Path(override).resolve()
root = Path(project_root).resolve()
for directory in (root, *root.parents):
stamp = directory / "install-stamp.json"
if stamp.is_file():
try:
data = json.loads(stamp.read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return get_default_hermes_root() / "tools"
value = data.get("runtimeDir") if isinstance(data, dict) else None
return Path(value).resolve() if value else get_default_hermes_root() / "tools"
return get_default_hermes_root() / "tools"
def selected_venv(project_root: Path) -> Path:
"""Use the committed environment, or the original install before first sync.
A broken committed selection is an error, not permission to load an older
dependency set silently. Reading this function never creates user state.
"""
path = runtime_facts_path(project_root)
try:
data = json.loads(path.read_text(encoding="utf-8-sig"))
except FileNotFoundError:
return base_venv(project_root)
except (OSError, ValueError) as exc:
raise RuntimeError(f"cannot read dependency environment: {path}") from exc
try:
fact = data.get("packages", {}).get("venv", {})
value = fact.get("environment")
except AttributeError as exc:
raise RuntimeError(f"invalid dependency environment record: {path}") from exc
if value is None:
return base_venv(project_root)
if not isinstance(value, str):
raise RuntimeError("invalid dependency environment path")
environment = Path(value).resolve()
generations = install_state_dir(project_root) / "environments"
if not environment.is_relative_to(generations.resolve()) or not (environment / "pyvenv.cfg").is_file():
raise RuntimeError(f"dependency environment is missing or outside this install: {environment}")
return environment
def site_packages(venv: Path) -> Path:
import sys
return venv / ("Lib/site-packages" if os.name == "nt" else
f"lib/python{sys.version_info.major}.{sys.version_info.minor}/site-packages")
def activate_dependencies(project_root: Path) -> None:
"""Select the committed tree at process boot, before third-party imports.
A process with no extension selection keeps its original launch contract.
Already-running processes are never switched after a dependency install.
"""
import sys
if not runtime_facts_path(project_root).is_file():
return
selected = site_packages(selected_venv(project_root))
if not selected.is_dir():
raise RuntimeError(f"dependency environment has no site-packages: {selected}")
import site
sys.path[:] = [entry for entry in sys.path
if Path(entry).name not in ("site-packages", "dist-packages")
and Path(entry).resolve() != project_root.resolve()]
# uv editable members are activated by .pth files, not by sys.path alone.
site.addsitedir(str(selected))
sys.path[:] = [str(project_root.resolve()), str(selected),
*[entry for entry in sys.path if Path(entry).resolve() != selected.resolve()]]
os.environ["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)])
def activation_environment(project_root: Path) -> dict[str, str]:
"""Read the installed PM environment; do not provision or switch imports."""
from pm.ensure import env_for
from pm.registry import all_packages
env = env_for(*all_packages())
selected = site_packages(selected_venv(project_root))
env.pop("PYTHONHOME", None)
env.pop("VIRTUAL_ENV", None)
env["PYTHONPATH"] = os.pathsep.join([str(project_root.resolve()), str(selected)])
return env
if __name__ == "__main__":
print(json.dumps(activation_environment(Path(__file__).resolve().parents[1])))
+2 -5
View File
@@ -847,12 +847,9 @@ def _release_repair_lock(lock: _RepairLock) -> None:
def _windows_runtime_holders() -> tuple[bool, str]:
if platform.system() != "Windows":
return False, ""
main_module = sys.modules.get("hermes_cli.main")
detector = getattr(main_module, "_detect_venv_python_processes", None)
if detector is None:
return True, "cannot verify Windows venv holders from this update context"
try:
holders = detector()
from hermes_cli.update_cmd_windows import _detect_venv_python_processes
holders = _detect_venv_python_processes()
except Exception as exc:
return True, f"could not verify Windows venv holders: {exc}"
if holders:
-1137
View File
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -103,8 +103,11 @@ def _pip_install_vercel(package):
"""uv when Hermes has one ($HERMES_HOME/bin is never on PATH, so which() misses it and
bootstrapping mid-wizard is fine), else pip — a `uv venv` venv may not even have pip."""
import subprocess
from hermes_cli.managed_uv import ensure_uv
uv_bin = ensure_uv()
from pm.ensure import uv as pm_uv
# Missing uv can be provisioned by PM during setup.
uv_bin, _ = pm_uv(realize=True)
cmd = ([uv_bin, "pip", "install", "--python", sys.executable, package] if uv_bin
else [sys.executable, "-m", "pip", "install", package])
return subprocess.run(cmd, **_RUN_KW)
-26
View File
@@ -61,17 +61,6 @@ def build_gateway_parser(
"corrupt shared gateway state."
),
)
gateway_run.add_argument(
"--service",
action="store_true",
help=(
"Windows SCM frontend mode: run as the HermesGateway Windows "
"Service (started by the Service Control Manager; spawns the "
"real gateway as a child and reports STOP via the graceful "
"planned-stop path). Not for interactive use — the SCM invokes "
"this, or `hermes gateway service on` does."
),
)
gateway_run.add_argument(
"--no-supervise",
action="store_true",
@@ -116,21 +105,6 @@ def build_gateway_parser(
_add_system_flag(gateway_status)
_add_compat_platform_flag(gateway_status)
# gateway service (MSIX HermesGateway Windows Service — the SCM one)
gateway_service = gateway_subparsers.add_parser(
"service",
help="Manage the HermesGateway Windows Service (MSIX installs)",
description=(
"The bundled MSIX installs a HermesGateway Windows Service "
"(demand-start, stopped). 'on' = automatic at logon + start "
"now; 'off' = demand + stop; 'status' = SCM state + config."
),
)
gateway_service_sub = gateway_service.add_subparsers(dest="gateway_service_action")
gateway_service_sub.add_parser("on", help="Enable: automatic at logon + start now")
gateway_service_sub.add_parser("off", help="Disable: demand-start + stop now")
gateway_service_sub.add_parser("status", help="Show SCM state + config key")
# gateway install
gateway_install = gateway_subparsers.add_parser(
"install", help="Install gateway as a systemd/launchd background service")
+5 -5
View File
@@ -149,14 +149,14 @@ def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool =
venv_root = Path(sys.executable).parent.parent
install_flags = _post_setup_no_window_flags(streams_to_console=not capture_output)
# Managed uv first: $HERMES_HOME/bin is never on PATH, so a bare which() misses the uv Hermes
# installed; ensure_uv() (not a pure lookup) because installing uv is in scope during setup.
from hermes_cli.managed_uv import ensure_uv
uv_bin = ensure_uv()
# Resolve uv and its target environment through PM, not ambient PATH.
from pm.ensure import uv as pm_uv
uv_bin, uv_env = pm_uv(realize=True, venv=venv_root)
try: # a failed uv run falls through to pip — it may have failed for a reason pip can handle
result = uv_bin and _run_text([uv_bin, "pip", "install", *args], timeout=timeout,
capture_output=capture_output, creationflags=install_flags,
env={**os.environ, "VIRTUAL_ENV": str(venv_root)})
env=uv_env)
if result and result.returncode == 0:
return result
except (subprocess.TimeoutExpired, FileNotFoundError):
+12 -47
View File
@@ -119,7 +119,7 @@ def find_shell_configs() -> list:
def remove_path_from_shell_configs():
"""Remove Hermes PATH entries from shell configuration files."""
removed_from = []
for config_path in (c for c in (Path.home() / n for n in _SHELL_RC_NAMES) if c.exists()):
for config_path in find_shell_configs():
try:
content = config_path.read_text(encoding="utf-8-sig")
original_content = content
@@ -222,7 +222,10 @@ def remove_node_symlinks(hermes_home: Path) -> list:
# os.readlink + manual join handles dangling links too (Path.resolve() on a dangling
# link still returns the target path); the link must point into OUR node dir.
target = (link.parent / os.readlink(link)).resolve()
return _unlink_if(target == node_dir or node_dir in target.parents, link)
if target != node_dir and node_dir not in target.parents:
return False
link.unlink()
return True
candidates = (bin_dir / name for name in ("node", "npm", "npx") for bin_dir in _node_symlink_candidate_dirs())
return _remove_each(candidates, _unlink_ours)
@@ -251,10 +254,13 @@ def uninstall_gateway_service():
except Exception as e:
log_warn(f"Could not check for gateway processes: {e}")
system = platform.system()
# Termux/Android has no systemd and no launchd — nothing left to do.
if os.getenv("TERMUX_VERSION") or "com.termux/files/usr" in os.getenv("PREFIX", ""):
return stopped_something
# 2. Linux: uninstall systemd services (both user and system scopes)
if system == "Linux":
# 2. Per-platform service removal (systemd / launchd / Scheduled Task).
remover, warn_label = _GATEWAY_SERVICE_REMOVERS.get(platform.system(), (None, ""))
if remover is not None:
try:
stopped_something = remover() or stopped_something
except Exception as e:
@@ -899,7 +905,7 @@ def _perform_uninstall(
lambda: remove_portable_tooling_windows(hermes_home), "Removed {}",
"No Windows installer artifacts to remove")
# 4b. Remove managed-runtime trees a PRE-SPLIT install left in
# 4c. Remove managed-runtime trees a PRE-SPLIT install left in
# HERMES_HOME. Current installs keep these inside the checkout, so
# step 4 already removed them — but a checkout outside the home
# (~/src/hermes-agent) used to leave its node/uv behind, surviving
@@ -914,21 +920,6 @@ def _perform_uninstall(
else:
log_info("No legacy runtime trees to remove")
# 4c. Remove Windows-only installer artifacts that are NOT user data:
# PortableGit, bundled Node, gateway-service dir. Installer put them
# under HERMES_HOME but they're install tooling, not config — safe to
# remove even in "keep data" mode. If we're doing a full uninstall
# the step-5 rmtree(hermes_home) would sweep them anyway; calling
# this helper there is a no-op since they'll already be gone.
if _is_windows():
log_info("Removing Windows installer artifacts (PortableGit, Node, gateway-service)...")
removed_artifacts = remove_portable_tooling_windows(hermes_home)
if removed_artifacts:
for path in removed_artifacts:
log_success(f"Removed {path}")
else:
log_info("No Windows installer artifacts to remove")
# 5. Optionally remove ~/.hermes/ data directory (and named profiles)
if full_uninstall:
# 5a. Named profiles' homes live under <default>/profiles/ (swept by the rmtree below),
@@ -1022,29 +1013,3 @@ def main(argv=None) -> int:
if __name__ == "__main__":
sys.exit(main())
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def find_shell_configs() -> list:
"""Find shell configuration files that might have PATH entries."""
home = Path.home()
configs = []
candidates = [
home / ".bashrc",
home / ".bash_profile",
home / ".profile",
home / ".zshrc",
home / ".zprofile",
]
for config in candidates:
if config.exists():
configs.append(config)
return configs
# ---- END PLUGIN-COMPAT ----
+112 -56
View File
@@ -32,10 +32,11 @@ installers and boot paths read it before the full config machinery loads.
from __future__ import annotations
import hashlib
from hermes_cli.runtime_paths import install_key, installs_root
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any, Optional
@@ -74,32 +75,6 @@ def canary_tag_for_date(version: str, date_utc: str) -> str:
return f"v{major}.{minor}.{patch + 1}-canary.{date_utc}"
def _install_key_sha16(project_root: Path) -> str:
"""sha16 of the canonical install-root PATH.
NOTE: dedupe with ``boot_bootstrap._install_key`` at assembly — this
is a byte-identical inline copy (sha256 of the resolved root, first 16
hex chars). Lane w2a ports boot_bootstrap in parallel; this module must
not import from it until both land.
"""
try:
canonical = str(Path(project_root).resolve())
except OSError:
canonical = str(project_root)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
def _installs_root() -> Path:
"""The parent of every per-install state folder.
NOTE: dedupe with ``boot_bootstrap.installs_root`` at assembly —
anchored to the DEFAULT home (profiles share one folder per install).
"""
from hermes_cli.profiles import _get_default_hermes_home
return _get_default_hermes_home() / "installs"
def _default_root() -> Path:
"""This process's install root.
@@ -118,7 +93,7 @@ def install_id(project_root: Optional[Path] = None) -> str:
"""
if project_root is None:
project_root = _default_root()
return _install_key_sha16(Path(project_root))
return install_key(Path(project_root))
def _read_stamp(root: Path) -> dict:
@@ -230,38 +205,119 @@ def set_install_channel(
def _write_channel_record(sha16: str, path: str, channel: str) -> None:
"""Write ``update.installs.<sha16>`` into config.yaml, preserving the rest."""
import yaml
"""Write ``update.installs.<sha16>`` into config.yaml, preserving the rest.
from hermes_cli.config import get_config_path
Persists through the shared comment-preserving atomic writer
(:func:`utils.atomic_roundtrip_yaml_update` — the same ruamel round-trip
path ``hermes config set`` uses), fail-closed via
:func:`hermes_cli.config.require_readable_config_before_write`. Malformed
``update`` / ``update.installs`` values are refused, never replaced —
the dotted writer would otherwise turn a scalar into a mapping and
destroy whatever the user had there.
"""
from utils import atomic_roundtrip_yaml_update
from hermes_cli.config import (
get_config_path,
require_readable_config_before_write,
)
config_path = get_config_path()
try:
raw = yaml.safe_load(config_path.read_text(encoding="utf-8-sig")) or {}
except FileNotFoundError:
raw = {}
if not isinstance(raw, dict):
raise ValueError(f"config at {config_path} is not a mapping")
update_cfg = raw.setdefault("update", {})
if not isinstance(update_cfg, dict):
existing = require_readable_config_before_write(config_path)
update_cfg = existing.get("update")
if update_cfg is not None and not isinstance(update_cfg, dict):
raise ValueError("config key 'update' is not a mapping")
installs = update_cfg.setdefault("installs", {})
if not isinstance(installs, dict):
installs = update_cfg.get("installs") if isinstance(update_cfg, dict) else None
if installs is not None and not isinstance(installs, dict):
raise ValueError("config key 'update.installs' is not a mapping")
record = installs.setdefault(sha16, {})
if not isinstance(record, dict):
record = {}
installs[sha16] = record
record["path"] = path # DATA, for humans + doctor GC
record["channel"] = channel
record = installs.get(sha16) if isinstance(installs, dict) else None
new_record = dict(record) if isinstance(record, dict) else {}
new_record["path"] = path # DATA, for humans + doctor GC
new_record["channel"] = channel
atomic_roundtrip_yaml_update(config_path, f"update.installs.{sha16}", new_record)
config_path.parent.mkdir(parents=True, exist_ok=True)
tmp = config_path.with_suffix(config_path.suffix + ".tmp")
tmp.write_text(
yaml.safe_dump(raw, sort_keys=False, allow_unicode=True), encoding="utf-8"
)
tmp.replace(config_path)
def _stable_wait_target(canary_version: str) -> str:
"""The stable release a canary install waits for: the canary's own base
version (canary is current-stable patch+1; ``v0.28.0`` from
``v0.28.0-canary.20260818171926``)."""
base = re.sub(r"-canary\.\d+$", "", canary_version.strip())
return base if base.startswith("v") else f"v{base}"
def _stamp_channel_hint(stamp: dict) -> Optional[str]:
"""The channel the RUNNING artifact implies, for stamps without a clean
``tag`` (desktop About-page stamps carry ``displayVersion`` like
``0.28.0-canary.20260818`` — the ``v`` prefix ``is_canary_tag`` requires
is restored before the shape check, so validation stays with the single
canary authority). Switch text only; resolution stays with
:func:`resolve_update_channel`."""
if stamp.get("updateMechanism") != "electron-updater":
return None
version = str(stamp.get("tag") or stamp.get("displayVersion") or "")
if not version:
return None
candidate = version if version.startswith("v") else f"v{version}"
return CHANNEL_CANARY if is_canary_tag(candidate) else None
def _set_channel_from_cli(channel: str) -> None:
"""Persist ``--set-channel`` and print the switch text. Never updates.
Runs before the update lock, git, network, backups, or process pause:
this is a configuration action, not an update. The reported previous
channel prefers the stored per-install record over what the running
artifact implies.
"""
from hermes_cli.config import read_raw_config
root = _default_root()
stamp = _read_stamp(root)
stored = channel_record(read_raw_config(), root).get("channel")
if isinstance(stored, str) and stored.strip().lower() in VALID_CHANNELS:
previous = stored.strip().lower()
else:
previous = _stamp_channel_hint(stamp) or resolve_update_channel(None, root)
try:
sha16 = set_install_channel(channel, root)
except ValueError as exc:
print(f"error: {exc}")
sys.exit(1)
print(f"Channel set to '{channel}' (was '{previous}') for install {sha16}.")
version = str(stamp.get("tag") or stamp.get("displayVersion") or "")
if previous == CHANNEL_CANARY and channel == CHANNEL_STABLE:
print(f" You are on canary build {version}.")
print(
f" Stable updates begin at { _stable_wait_target(version) } —"
" canary outversions it until that release ships."
)
print(" Not patient? Switch back: hermes update --set-channel canary")
print(" Docs: https://hermes-agent.nousresearch.com")
elif channel == CHANNEL_CANARY:
print(
" Canary builds are forward-incompatible: a canary install"
" only updates to artifacts that ship after it. Downgrading"
" means reinstalling."
)
sys.exit(0)
def handle_channel_flags(args) -> None:
"""Preflight for the informational update flags.
``--install-id`` prints this install's id and path; ``--set-channel``
atomically persists a valid channel record. Both terminate the command
here — the caller never reaches the update lock, git, network, backup,
or process-pause paths.
"""
if getattr(args, "install_id", False):
root = _default_root()
print(f"{install_id(root)} {root}")
sys.exit(0)
channel = getattr(args, "set_channel", None)
if channel:
_set_channel_from_cli(channel)
def stale_channel_records(config: Optional[dict]) -> list[tuple[str, dict, str]]:
@@ -290,7 +346,7 @@ def stale_channel_records(config: Optional[dict]) -> list[tuple[str, dict, str]]
if not path.exists():
stale.append((sha16, record, "missing"))
continue
if _install_key_sha16(path) != sha16:
if install_key(path) != sha16:
stale.append((sha16, record, "replaced"))
continue
@@ -298,7 +354,7 @@ def stale_channel_records(config: Optional[dict]) -> list[tuple[str, dict, str]]
# record whose sha16 has no installs/<sha16>/install.json was
# either hand-written or its install never booted post-record.
try:
if not (_installs_root() / sha16 / "install.json").is_file():
if not (installs_root() / sha16 / "install.json").is_file():
stale.append((sha16, record, "unclaimed"))
except Exception as exc: # noqa: BLE001 — doctor sweep must not raise
logger.debug("installs root unavailable: %s", exc)
+958 -10309
View File
File diff suppressed because it is too large. Load diff
+105 -413
View File
@@ -146,36 +146,11 @@ def _web_toolchain_roots(web_dir: Path) -> tuple[Path, ...]:
return (web_dir, web_dir.parent)
def _ensure_venv_pip(pip_cmd: list, python_exe: str) -> None:
"""Bootstrap pip back into the venv via ensurepip when ``pip --version`` fails
(some environments lose it); call before the editable install."""
from hermes_cli.update_cmd import _m
try:
subprocess.run(pip_cmd + ["--version"], cwd=_m().PROJECT_ROOT, check=True, capture_output=True)
except subprocess.CalledProcessError:
subprocess.run(
[python_exe, "-m", "ensurepip", "--upgrade", "--default-pip"], cwd=_m().PROJECT_ROOT, check=True)
def _upgrade_pip_before_lazy_refresh(
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None:
"""Upgrade pip before lazy refreshes: older pip can fail setuptools source builds and
leave a partially-written venv. Never raises.
See #57828.
"""
from hermes_cli.update_cmd import _m
try:
_m()._run_package_only_install(install_cmd_prefix + ["install", "--upgrade", "pip"], env=env)
except subprocess.CalledProcessError as exc:
logger.debug("pip upgrade before lazy refresh failed: %s", exc)
def _capture_active_lazy_features() -> list[str]:
"""Snapshot active lazy backends before a managed runtime is replaced."""
try:
from tools import lazy_deps
return lazy_deps.active_features()
from pm.ensure import enabled_extras
return enabled_extras()
except Exception as exc:
logger.debug("Could not snapshot active lazy features: %s", exc)
return []
@@ -191,152 +166,25 @@ def _capture_active_tool_dependencies() -> list[str]:
return []
def _module_importable_in(target_python, module_name: str, env) -> bool:
"""Probe ``find_spec(module_name)`` under *target_python*; an indeterminate probe reads as
missing (safer to repair than to assume it survived)."""
try:
probe = subprocess.run(
[str(target_python), "-c",
"import importlib.util,sys; raise SystemExit(0 if importlib.util.find_spec(sys.argv[1]) else 1)",
module_name],
capture_output=True, env=env, check=False)
return probe.returncode == 0
except (subprocess.SubprocessError, OSError):
return False
def _restore_active_tool_dependencies(
dependencies: list[str], install_cmd_prefix: list[str], *, env: dict[str, str] | None = None
) -> None:
"""Restore allowlisted ``hermes tools`` dependencies (from a pre-rebuild probe) into a rebuilt
venv. Never raises: a failed optional tool must not block the update, but must be reported."""
from hermes_cli.update_cmd import _m
if not dependencies:
return
try:
from hermes_cli import tools_config
except Exception as exc:
logger.debug("Hermes Tools dependency restore skipped (import failed): %s", exc)
return
def _refresh_active_lazy_features(features: list[str] | None = None) -> bool:
"""Re-sync the venv's enabled extras against the (possibly new) uv.lock.
target_python = _m()._resolve_install_target_python(install_cmd_prefix, env)
missing: list[tuple[str, tuple[str, ...]]] = []
for name in dependencies:
spec = tools_config.restorable_python_tool_dependency(name)
if spec is None:
continue
module_name, install_args = spec
if target_python is not None and _module_importable_in(target_python, module_name, env):
continue
missing.append((name, install_args))
if not missing:
return
print()
print(f"→ Restoring {len(missing)} Hermes Tools dependency set(s)...")
restored: list[str] = []
failed: list[tuple[str, str]] = []
for name, install_args in missing:
try:
_m()._run_package_only_install(
install_cmd_prefix + ["install", *install_args, "--quiet"], env=env)
restored.append(name)
except Exception as exc:
# Best-effort: surface failures without aborting the update.
failed.append((name, str(exc)))
if restored:
print(f" ✓ {len(restored)} restored: {', '.join(restored)}")
for name, reason in failed:
print(f" ⚠ {name} failed to restore: {_clip(reason)}")
def _clip(reason: str, limit: int = 200) -> str:
"""Bound a failure reason for the one-line report."""
return reason if len(reason) <= limit else reason[:limit] + "..."
def _refresh_active_lazy_features(
install_cmd_prefix: list[str] | None = None, *, env: dict[str, str] | None = None,
features: list[str] | None = None) -> bool:
"""Refresh previously-activated lazy backends (cold ones untouched): the core install never
touches them, so a bumped :data:`LAZY_DEPS` pin would leave them stale forever. Returns True
when the venv is safe (refreshed / nothing active / import repair succeeded), False when a
failed lazy install left broken core imports repair couldn't fix. Never raises.
See #57828.
Extras live in the installed-state file and uv.lock owns every pin, so
a post-update refresh is one sync_venv() call: it re-installs exactly
the locked versions of everything enabled. Never raises.
"""
from hermes_cli.update_cmd import _m
try:
from tools import lazy_deps
from pm.ensure import sync_venv
sync_venv(features, explicit=True)
return True
except Exception as exc:
logger.debug("Lazy refresh skipped (import failed): %s", exc)
return True
active = features
if active is None:
try:
active = lazy_deps.active_features()
except Exception as exc:
logger.debug("Lazy refresh skipped (active_features failed): %s", exc)
return True
if not active:
return True
print()
print(f"→ Refreshing {len(active)} active lazy backend(s)...")
unexpected_failure = False
try:
results = (
lazy_deps.refresh_active_features(prompt=False) if features is None
else lazy_deps.restore_features(active))
except Exception as exc:
# refresh_active_features is never-raise by contract; defend anyway.
print(f" ⚠ Lazy refresh failed unexpectedly: {exc}")
results = {}
unexpected_failure = True
refreshed = [f for f, s in results.items() if s in {"refreshed", "restored"}]
current = [f for f, s in results.items() if s == "current"]
failed = [(f, s) for f, s in results.items() if s.startswith("failed:")]
skipped = [(f, s) for f, s in results.items() if s.startswith("skipped:")]
if refreshed:
print(f" ↑ {len(refreshed)} refreshed: {', '.join(refreshed)}")
if current:
print(f" ✓ {len(current)} already current")
if skipped:
# Usually security.allow_lazy_installs=false; informational, not an error.
names = ", ".join(f for f, _ in skipped)
reason = skipped[0][1].split(": ", 1)[-1]
print(f" · {len(skipped)} skipped ({reason}): {names}")
if not failed and not unexpected_failure:
return True
for feature, status in failed:
print(f" ⚠ {feature} failed to refresh: {_clip(status.split(': ', 1)[-1])}")
if install_cmd_prefix is None:
print(" ⚠ Lazy refresh failed; rerun `hermes update` once resolved.")
print(f" ⚠ Extra re-sync failed: {exc}")
print(" Rerun `hermes update` (or `hermes pm install`) once resolved.")
return False
# Import-based recovery: metadata-only verifiers miss dist-info intact but import files
# wiped. Unavailable probes are indeterminate, not healthy — keep the lazy marker.
# See #57828.
status = _m()._repair_venv_via_import_probes(install_cmd_prefix, env=env)
if status == "repaired":
print(" Lazy backend(s) keep their previous version until refresh succeeds.")
return True
if status == "healthy":
print(" Lazy backend(s) keep their previous version; probed packages look intact.")
print(" Rerun `hermes update` once the upstream issue is resolved.")
return True
if status == "indeterminate":
print(" ⚠ Leaving `.lazy-refresh-incomplete` until import probes can confirm health.")
return False
def _refresh_active_memory_provider_dependencies() -> None:
"""Refresh pip deps for the configured external memory provider: its bridge packages live in
@@ -380,52 +228,6 @@ def _refresh_active_memory_provider_dependencies() -> None:
print(f" ⚠ {provider} dependencies failed to refresh: {exc}")
def _is_android_python() -> bool:
from hermes_cli.update_cmd import _m
return _m().sys.platform == "android"
def _install_psutil_android_compat(
install_cmd_prefix: list[str], *, env: dict[str, str] | None = None) -> None:
"""Install psutil on Android by patching its platform detection: setup gates Linux sources on
``sys.platform.startswith('linux')`` but Termux reports ``'android'`` though the Linux path
compiles fine. Only this attempt's build tree is patched (stopgap until psutil ships a fix)."""
from hermes_cli.update_cmd import _m
import tempfile
import urllib.request
from hermes_cli.psutil_android import PSUTIL_URL, prepare_patched_psutil_sdist
with tempfile.TemporaryDirectory() as tmp:
archive = Path(tmp) / "psutil.tar.gz"
urllib.request.urlretrieve(PSUTIL_URL, archive)
src_root = prepare_patched_psutil_sdist(archive, Path(tmp))
_m()._run_install_with_heartbeat(
install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)], env=env)
def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None:
"""Best-effort uv bootstrap on Termux (official installer may fail: glibc vs bionic). Prefer a
PATH uv; else wheel-only ``pip install uv`` so the Rust crate is never source-built."""
from hermes_cli.update_cmd import _m
from hermes_cli.managed_uv import resolve_uv
existing = resolve_uv()
if existing:
return existing
if not _m()._is_termux_env():
return None
# Termux-packaged uv is on PATH but not the managed bin dir, so resolve_uv() misses it;
# prefer it over pip, which has no Android wheel and would source-build on a small device.
system_uv = shutil.which("uv")
if system_uv:
return system_uv
with suppress(Exception):
print(" → Termux detected: trying to install uv for faster dependency updates...")
result = subprocess.run(
pip_cmd + ["install", "uv", "--only-binary", ":all:"], cwd=_m().PROJECT_ROOT, check=False)
if result.returncode != 0:
return None
return resolve_uv() or shutil.which("uv")
def _npm_manifest_paths() -> tuple[Path, ...]:
"""Manifests whose changes must defeat the update-skip. The lockfile alone isn't enough (a
package.json can be edited without running npm); workspaces come from the root ``workspaces``
@@ -627,11 +429,29 @@ def _update_node_dependencies() -> list[str]:
def _venv_core_imports_healthy() -> tuple[bool, str]:
"""Probe the venv (in ITS interpreter — the updater may run under another Python) for core
imports, catching a half-updated venv that "Already up to date!" would otherwise never re-sync.
"""Probe the SELECTED dependency environment (in ITS interpreter — the updater may run under
another Python) for core imports, catching a half-updated environment that "Already up to
date!" would otherwise never re-sync.
Selection goes through ``hermes_cli.runtime_paths`` — the stdlib-only selection module — not
through the pm manager: the probe must run before any dependency of that environment has been
imported. The legacy ``<repo>/venv`` target is superseded: the PM model stages a fresh
generation under ``installs/<key>/environments/<gen>`` and commits the selection in
``facts.json`` (``pm.packages.VenvPackage.apply``), so the environment the update must judge
(and the one the repair below rebuilds) is the SELECTED one, never a hardcoded repo-relative
``venv`` directory.
Returns ``(healthy, detail)``; never raises, unknown states report healthy."""
from hermes_cli.update_cmd import _m
venv_dir = _m().PROJECT_ROOT / "venv"
from hermes_cli import runtime_paths
try:
venv_dir = runtime_paths.selected_venv(_m().PROJECT_ROOT)
except FileNotFoundError:
venv_dir = runtime_paths.base_venv(_m().PROJECT_ROOT)
except RuntimeError as exc:
# An unreadable/invalid committed selection is a KNOWN-unhealthy state (the repair
# flow below re-syncs and re-commits); never raise.
return False, str(exc)
venv_python = venv_python_path(venv_dir, windows=_m()._is_windows())
if not venv_python.exists():
# No venv: normal for a dev checkout (healthy), but on a MANAGED install (bootstrap
@@ -669,138 +489,6 @@ def _venv_core_imports_healthy() -> tuple[bool, str]:
return True, ""
# Native extensions that pin venv files once imported: if the updater holds one, Windows blocks
# REPLACE on the mapped ``.pyd`` and the sync dies with ``os error 5``. PyYAML's ``_yaml`` is in
# every CLI process, so the guard must be HONEST: fire only when the sync would actually REWRITE
# the dist, and only AFTER the code swap so a deferral leaves new code with just the install
# pending. Keys are ``sys.modules`` prefixes; values are ``(display name, PyPI dist)``.
# If the updater process itself has any of these loaded, the dependency sync below cannot rewrite the
# backing ``.pyd``/``.dll`` — Windows blocks REPLACE on a mapped image — and the update dies with ``os error
# 5`` between uninstall and reinstall, stranding the venv half-updated (#83569). ``cryptography`` is the
# canonical case: ``hermes_cli.main`` used to import it at startup while resolving external secret sources;
# ``PyYAML``'s ``_yaml`` C extension is loaded by every CLI process (config parsing). Keep this guard as
# defence-in-depth against future eager imports (new secret sources, plugins absorbed into core, refactors
# of the startup order) — but the guard must be HONEST (#86735/#86780/#86781: a preflight that fired on
# every run, before the fetch, re-bricked the exact flow it was meant to protect). Two honesty gates: 1. It
# only fires when the dependency sync would actually REWRITE the loaded distribution
# (``_dependency_sync_would_rewrite``): if the installed version already satisfies the on-disk pyproject
# pins, uv/pip will not touch the mapped ``.pyd``, so there is no lock to trip. 2. It runs AFTER the code
# swap (git pull / ZIP commit), immediately before the venv rewrite — so the on-disk pyproject is the NEW
# one (gate 1 compares against the right target) and a deferral no longer strands the user on the old
# checkout: the next launch's marker recovery completes the dependency install against the already-updated
# pyproject.
_SELF_LOCKING_NATIVE_MODULES: dict[str, tuple[str, str]] = {
"cryptography.hazmat.bindings._rust": ("cryptography (_rust.pyd)", "cryptography"),
"yaml._yaml": ("PyYAML (_yaml.pyd)", "pyyaml")}
def _dependency_sync_would_rewrite(dist_name: str) -> bool | None:
"""Whether the ``.[all]`` install would replace *dist_name*'s files, judged against every
applicable pin in on-disk ``pyproject.toml`` (base + extras). False: all pins satisfied;
True: pin unsatisfied or dist missing; None: undeterminable. Never raises. Callers treat
None as fail-OPEN — PyYAML is in every process, so deferring on uncertainty always fires.
See #86735.
"""
from hermes_cli.update_cmd import _m
try:
from importlib import metadata as _ilmd
installed = _ilmd.version(dist_name)
except Exception:
return True # not installed → the sync will definitely install it
try:
import tomllib
from packaging.requirements import Requirement
from packaging.utils import canonicalize_name
from packaging.version import Version
pyproject = _m().PROJECT_ROOT / "pyproject.toml"
data = tomllib.loads(pyproject.read_text(encoding="utf-8-sig"))
project = data.get("project") or {}
req_strings: list[str] = list(project.get("dependencies") or [])
for extra_reqs in (project.get("optional-dependencies") or {}).values():
req_strings.extend(extra_reqs or [])
target = canonicalize_name(dist_name)
installed_v = Version(installed)
saw_pin = False
for req_str in req_strings:
try:
req = Requirement(req_str)
except Exception:
continue
if canonicalize_name(req.name) != target:
continue
if req.marker is not None and not req.marker.evaluate():
continue
saw_pin = True
if installed_v not in req.specifier:
return True
# Not pinned in pyproject: the resolver may still move it as a transitive — unknown.
return False if saw_pin else None
except Exception:
return None
def _detect_self_loaded_native_modules() -> list[str]:
"""Display names of native venv extensions loaded into THIS process that the sync would rewrite.
Empty off Windows (POSIX keeps an unlinked inode usable). Modules whose installed version
already satisfies the pins are NOT reported — no swap at risk. Never raises.
Returns display names (empty off Windows — POSIX lets a running process keep using an unlinked inode, so
self-locking is a Windows-only hazard). A loaded module whose installed version already satisfies the
on-disk pyproject pins is NOT reported: the dependency sync will not touch its files, so there is no
swap at risk (#86735 — the always-firing variant of this preflight bricked every Windows update).
"""
from hermes_cli.update_cmd import _m
if not _m()._is_windows():
return []
# Defer ONLY on a CONFIRMED rewrite; unknown fails OPEN (PyYAML is in every process, so
# unknown-as-at-risk always fires). A missed deferral only yields the mid-sync os error 5
# that marker recovery already handles — far less harmful than an update that never runs.
return sorted({
display for prefix, (display, dist) in _SELF_LOCKING_NATIVE_MODULES.items()
if prefix in sys.modules and _m()._dependency_sync_would_rewrite(dist) is True})
def _abort_dependency_sync_if_self_locked(gateway_resume=None) -> None:
"""Defer the venv rewrite when THIS process holds something it must replace (runs after the
code swap, so a deferral leaves NEW code with only the install pending). Two hazards:
a mapped ``.pyd`` -> exit 2, next launch's marker recovery finishes; the ``hermes.exe`` shim
we run from -> every launch is the shim so the marker would defer forever: hand the
install to a child under the venv interpreter and exit 0.
See #88838, #89599.
"""
from hermes_cli.update_cmd import _m
locked = _m()._detect_self_loaded_native_modules()
if locked:
_m()._defer_update_for_self_lock(locked)
exit_code = 2
elif _m()._reexec_dependency_sync_off_windows_shim():
exit_code = 0
else:
return
if gateway_resume is not None:
_m()._resume_windows_gateways_after_update(gateway_resume)
sys.exit(exit_code)
def _defer_update_for_self_lock(loaded: list[str]) -> None:
"""Explain + drop the update-incomplete marker (next fresh launch completes the install) when
the updater holds a lock the sync must replace; the caller exits 2."""
from hermes_cli.update_cmd import _m
print("✗ This updater process has already loaded native venv modules that")
print(" the dependency sync must replace:")
for name in loaded:
print(f" {name}")
print()
print(" On Windows a mapped extension cannot be replaced by the process")
print(" holding it. The code update has been applied; only the dependency")
print(" sync has been deferred: the next `hermes` launch will complete it")
print(" in a fresh process before anything imports these modules.")
_m()._write_update_incomplete_marker()
def _desktop_app_present(desktop_dir: Path) -> bool:
"""Return whether a packaged or source Desktop build exists."""
from hermes_cli.update_cmd import _m
@@ -960,93 +648,97 @@ def _refuse_update_if_venv_foreign_owned(project_root) -> None:
def _sync_python_dependencies_after_pull(
git_cmd, branch, pre_pull_sha, *, active_lazy_features, active_tool_dependencies,
_windows_gateway_resume):
"""Reinstall Python deps for the pulled checkout. Order matters: ownership preflight ->
self-lock deferral -> core marker -> ``.[all]`` -> bytecode sweep -> lazy/tool refresh (own
marker) -> memory-provider deps -> critical-import probe (warn only; stale bytecode self-heals)."""
_windows_gateway_resume, desktop_dir, had_desktop_app_before_update):
"""Reinstall Python deps for the pulled checkout (PM flow). Order matters: ownership preflight ->
core marker -> ``pm.sync_venv(["all"], explicit=True)`` (stages + commits a fresh generation
environment; the live env of any running process is never mutated) -> module reload -> lazy/tool
refresh (own marker) -> memory-provider deps -> critical-import probe (warn only) -> node deps +
web + desktop rebuild. Returns ``(node_failures, desktop_build_ok)``."""
from hermes_cli.update_cmd import (
_m, _pip_install_prefix, _sweep_bytecode_after_update, _validate_critical_modules_import,
_write_lazy_refresh_incomplete_marker, _write_update_incomplete_marker)
# Reinstall Python dependencies. Prefer .[all], but if one optional extra breaks on this machine, keep
# base deps and reinstall the remaining extras individually so update does not silently strip working
# capabilities. Ownership preflight (#83529): refuse before the first venv mutation if the venv contains
# foreign-owned files (sudo-pip residue) — the install below would die mid-mutation and brick the CLI.
_m, _write_lazy_refresh_incomplete_marker, _write_update_incomplete_marker)
# Reinstall Python dependencies. Prefer .[all], but if one optional extra
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
#
# Ownership preflight (#83529): refuse before the first venv mutation
# if the venv contains foreign-owned files (sudo-pip residue) — the
# install below would die mid-mutation and brick the CLI.
_refuse_update_if_venv_foreign_owned(_m().PROJECT_ROOT)
# Self-lock deferral: if THIS process holds a native extension the sync must rewrite, defer
# NOW (after the code swap) so only the install is pending for the next launch's marker.
_m()._abort_dependency_sync_if_self_locked(_windows_gateway_resume)
# Drop the core-install breadcrumb BEFORE touching the venv so a killed install is finished
# by the next launch (``_recover_from_interrupted_install``). Lazy refresh uses its own marker.
_write_update_incomplete_marker()
deps_current = _editable_install_is_current(git_cmd, _m().PROJECT_ROOT, pre_pull_sha)
print(
"→ Python dependencies unchanged — skipping reinstall" if deps_current
else "→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
# `uv self update` if we already have a managed uv.
update_managed_uv()
uv_bin = ensure_uv()
pip_cmd = [sys.executable, "-m", "pip"]
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
if not uv_bin:
_ensure_venv_pip(pip_cmd, sys.executable)
install_prefix, lazy_env = _pip_install_prefix(uv_bin)
install_group = "all"
is_termux = _m()._is_termux_env(lazy_env)
if is_termux:
if lazy_env is not None:
lazy_env.pop("PYTHONPATH", None)
lazy_env.pop("PYTHONHOME", None)
install_group = "termux-all"
uv_note = "uv + " if uv_bin else ""
print(f" → Termux detected: using {uv_note}curated termux-all optional profile...")
if deps_current:
# Verification normally runs inside the skipped install; run it here so a wrong skip
# self-heals (both verifiers reinstall what they find missing).
_m()._verify_core_dependencies_installed(install_prefix, env=lazy_env, group=install_group)
_m()._verify_console_scripts_installed(install_prefix, env=lazy_env)
else:
if is_termux and _is_android_python():
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
_install_psutil_android_compat(install_prefix, env=lazy_env)
_m()._install_python_dependencies_with_optional_fallback(
install_prefix, env=lazy_env, group=install_group)
print("→ Syncing Python dependencies...")
import pm
# Clear the core breadcrumb before lazy refresh, which uses its own marker so a lazy
# failure can't be "healed" by a narrow core import probe.
try:
pm.sync_venv(["all"], explicit=True)
deps_synced = True
except pm.InstallError as _sync_err:
print(f" ✗ {_sync_err}")
print(" Re-run `hermes update` (or `hermes pm install`) once resolved.")
raise
# Core ``.[all]`` install finished. Clear the generic core breadcrumb
# before the lazy-refresh phase — that phase uses its own marker so a
# later lazy failure cannot be "healed" by clearing the core marker
# based on a narrow 7-package import probe (#58004 review).
_m()._clear_update_incomplete_marker()
# Still the old interpreter process: refresh caches/modules before lazy refresh imports
# newly-pulled modules. The install may have regenerated bytecode from build-cache
# copies — this second sweep catches those stragglers.
_sweep_bytecode_after_update(branch)
# The update process is still the old Python interpreter process. Run
# one final cache/module refresh immediately before lazy backend
# refresh, which imports newly-pulled modules that may depend on fresh
# symbols in hermes_constants or pm. The dependency install
# above may also have regenerated bytecode from build-cache copies —
# this second sweep catches those stragglers (#60242, #65240).
removed = _m()._clear_bytecode_cache(_m().PROJECT_ROOT)
if removed:
print(
f" ✓ Cleared {removed} stale __pycache__ director{'y' if removed == 1 else 'ies'}"
)
_m()._record_bytecode_fingerprint()
_m()._refresh_bootstrap_cache_scripts(branch)
_m()._reload_updated_runtime_modules()
# Stale pip can fail source builds and leave partially-written packages.
# See #57828.
_write_lazy_refresh_incomplete_marker()
_m()._upgrade_pip_before_lazy_refresh(install_prefix, env=lazy_env)
# Clear the lazy marker only when refresh/repair is confirmed healthy.
if _m()._refresh_active_lazy_features(install_prefix, env=lazy_env, features=active_lazy_features):
lazy_ok = _m()._refresh_active_lazy_features(active_lazy_features)
if lazy_ok:
_m()._clear_lazy_refresh_incomplete_marker()
else:
print(
" ⚠ Lazy-refresh recovery incomplete — run `hermes` again "
"to finish import-based venv repair.")
"to finish import-based venv repair."
)
_m()._restore_active_tool_dependencies(active_tool_dependencies, install_prefix, env=lazy_env)
# Heal memory-provider bridge packages last — the steps above may have stripped them.
# Heal the active memory provider's bridge packages last — the core
# reinstall + lazy refresh above may have stripped or downgraded
# plugin.yaml-declared deps that aren't in extras (#53272, #70636).
_m()._refresh_active_memory_provider_dependencies()
# Remaining import failures are real breakage. Warn only — never roll back: `cannot import
# name X` is also the stale-bytecode signature, which self-heals next launch.
import_ok, failing_module, import_error = _validate_critical_modules_import(_m().PROJECT_ROOT)
# Everything that can legitimately produce a transient ImportError has
# now run (bytecode sweep, dependency reinstall, lazy refresh), so a
# module that still won't import is real breakage. Warn only — never
# roll back here: `cannot import name X` is also the signature of the
# stale-bytecode class (#6207, #60242), and the launch-time sweep in
# _sweep_stale_bytecode_if_checkout_changed() self-heals that on the
# next run. A destructive reset would undo a good update over a state
# that fixes itself.
import_ok, failing_module, import_error = _validate_critical_modules_import(
_m().PROJECT_ROOT
)
if not import_ok:
print()
print(f" ⚠ {failing_module} still fails to import after updating:")
print(f" {import_error}")
print(" Run `hermes update` again — if it persists, reinstall:")
print(" https://hermes-agent.nousresearch.com")
node_failures = _update_node_dependencies()
_m()._build_web_ui(_m().PROJECT_ROOT / "web")
desktop_build_ok = _rebuild_desktop_after_update(
desktop_dir,
had_desktop_app_before_update=had_desktop_app_before_update,
)
print()
return node_failures, desktop_build_ok
+1 -1
View File
@@ -70,7 +70,7 @@ def _current_checkout_sha() -> str | None:
"""Current on-disk checkout HEAD, or None if it cannot be resolved."""
from hermes_cli.update_cmd import _capture_head_sha, _m
try:
from hermes_cli.build_info import get_code_identity
from hermes_cli.version_info import get_code_identity
sha = (get_code_identity(refresh=True) or {}).get("sha")
return str(sha) if sha else None
except Exception:
+23 -7
View File
@@ -28,9 +28,24 @@ def _git_ok(git_cmd, args, cwd, **kw) -> bool:
return _git_stdout(git_cmd, args, cwd, **kw) is not None
def _git_run(git_cmd, args, cwd=None, *, check=False):
"""Run ``git_cmd + args`` and return the CompletedProcess.
The updater's git runner: capture all output and decode as UTF-8 regardless of the
Windows ANSI code page (#52649). ``check=True`` raises on non-zero exit.
"""
return subprocess.run(
git_cmd + list(args),
cwd=cwd,
capture_output=True,
text=True, encoding="utf-8", errors="replace",
check=check,
)
def _git_stdout(git_cmd, args, cwd, **kw) -> Optional[str]:
"""Stripped stdout of a successful ``_git_run``; ``None`` on non-zero exit or any exception."""
from hermes_cli.update_cmd import _git_run
from hermes_cli.update_cmd_git import _git_run
with suppress(Exception):
result = _git_run(git_cmd, args, cwd, **kw)
if result.returncode == 0:
@@ -52,7 +67,7 @@ def _prune_orphan_rescue_refs(
those objects include a full working-tree snapshot (the autostash orphan commit), which can be multi-GB
when the tree holds large stray files. See #87694.
"""
from hermes_cli.update_cmd import _git_run
from hermes_cli.update_cmd_git import _git_run
with suppress(OSError):
prefix = f"refs/hermes-update-backups/orphan-{branch}-"
list_result = _git_run(git_cmd, ["for-each-ref", "--format=%(refname)", "--sort=refname", f"{prefix}*"], cwd)
@@ -107,7 +122,7 @@ def _assess_parked_branch_switch(git_cmd: list[str], cwd: Path, current_branch:
- (False, "disabled"|"dirty"|"unverifiable") — caller must NOT touch the branch. Dirty is the
genuinely unsafe case: uncommitted work riding an autostash across branches.
A config read failure must not disable the safety checks: fall through with the default."""
from hermes_cli.update_cmd import _git_run
from hermes_cli.update_cmd_git import _git_run
try:
from hermes_cli.config import load_config
_update_cfg = (load_config() or {}).get("updates", {})
@@ -400,8 +415,9 @@ def _locate_real_git() -> Optional[Path]:
invoked directly (#87876).
"""
candidates = [
Path(r"C:\Program Files\Git\mingw64\libexec\git-core\git.exe"),
Path(r"C:\Program Files (x86)\Git\mingw64\libexec\git-core\git.exe"),
Path(base) / "Git" / arch / "libexec" / "git-core" / "git.exe"
for base in (r"C:\Program Files", r"C:\Program Files (x86)")
for arch in ("clangarm64", "mingw64", "mingw32")
] + _portable_git_candidates()
return next((c for c in candidates if c.exists() and _probe_fork_bomb([str(c)]) is False), None)
@@ -424,7 +440,7 @@ def _ensure_non_trampoline_git(git_cmd: list) -> list:
def _discard_lockfile_churn(git_cmd, repo_root):
"""Restore ``package-lock.json`` files npm rewrote non-deterministically, so the update sees a clean tree
instead of autostashing every run. Only touches lockfiles whose package.json is NOT also dirty. Best-effort."""
from hermes_cli.update_cmd import _git_run
from hermes_cli.update_cmd_git import _git_run
with suppress(Exception):
diff = _git_run(git_cmd, ["diff", "--name-only"], repo_root)
if diff.returncode != 0:
@@ -452,7 +468,7 @@ def _normalize_managed_eol(git_cmd, repo_root):
reuses its build-pinned ``install.ps1`` forever — so ``hermes update``, which ships with the checkout
itself, is the only path left that can fix them. See #67730.
"""
from hermes_cli.update_cmd import _git_run
from hermes_cli.update_cmd_git import _git_run
# -c, not config: evaluate the tree as it WOULD look pinned, persisting nothing.
probe = git_cmd + ["-c", "core.autocrlf=false"]
+3 -2
View File
@@ -23,7 +23,7 @@ from hermes_cli.update_cmd_common import _best_effort
logger = logging.getLogger("hermes_cli.update_cmd")
_UPDATE_RUNTIME_RELOAD_MODULES = "hermes_constants", "tools.environments.local", "tools.lazy_deps"
_UPDATE_RUNTIME_RELOAD_MODULES = "hermes_constants", "tools.environments.local", "pm.extras"
#: Package prefixes whose cached modules go stale when the checkout changes under this
#: process; purged (not reloaded) so any LATER import chain resolves against fresh source.
@@ -472,7 +472,8 @@ def _restore_state_db_from_snapshot(state_path: Path, snap_state: Path) -> bool:
clobbers pages. Holder scan ``None`` proceeds (gateways drained; refusing on unknown would
disable auto-restore on non-Linux). Raises OSError if the copy fails.
"""
from hermes_cli.backup import _foreign_db_holder_pids, verify_sqlite_integrity
from hermes_cli.backup import verify_sqlite_integrity
from hermes_cli.backup_restore import _foreign_db_holder_pids
from hermes_cli.sqlite_safe_read import LiveConnectionError, offline_file_access
holders = _foreign_db_holder_pids(state_path)
if holders:
+7 -9
View File
@@ -57,7 +57,7 @@ def _print_first_line(text: str) -> None:
def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[str]:
from hermes_cli.update_cmd import _git_run
from hermes_cli.update_cmd_git import _git_run
status = _git_run(git_cmd, ["status", "--porcelain"], cwd, check=True)
if not status.stdout.strip():
return None
@@ -94,7 +94,7 @@ def _stash_local_changes_if_needed(git_cmd: list[str], cwd: Path) -> Optional[st
def _resolve_stash_selector(git_cmd: list[str], cwd: Path, stash_ref: str) -> Optional[str]:
from hermes_cli.update_cmd import _git_run
from hermes_cli.update_cmd_git import _git_run
stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %H"], cwd, check=True)
for line in stash_list.stdout.splitlines():
selector, _, commit = line.partition(" ")
@@ -114,7 +114,7 @@ def _warn_orphaned_update_autostashes(git_cmd: list[str], cwd: Path) -> int:
``git stash`` invisibly for weeks (#63717 problem 6). This prints a short notice naming the stale
entries with recovery/cleanup guidance.
"""
from hermes_cli.update_cmd import _git_run
from hermes_cli.update_cmd_git import _git_run
try:
stash_list = _git_run(git_cmd, ["stash", "list", "--format=%gd %s"], cwd)
if stash_list.returncode != 0:
@@ -270,7 +270,7 @@ def _confirm_restore(stash_ref: str, input_fn) -> bool:
def _apply_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> bool:
"""``git stash apply``; False (tree reset, stash kept) on conflicts or any failure other than the
undeletable-untracked class."""
from hermes_cli.update_cmd import _git_run
from hermes_cli.update_cmd_git import _git_run
print("→ Restoring local changes...")
restore = _git_run(git_cmd, ["stash", "apply", stash_ref], cwd)
unmerged = _git_run(git_cmd, ["diff", "--name-only", "--diff-filter=U"], cwd) # conflicts can exist even on rc 0
@@ -298,7 +298,7 @@ def _apply_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> bool:
def _drop_restored_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> None:
from hermes_cli.update_cmd import _git_run
from hermes_cli.update_cmd_git import _git_run
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
if stash_selector is None:
print("⚠ Local changes were restored, but Hermes couldn't find the stash entry to drop.")
@@ -317,9 +317,7 @@ def _drop_restored_stash(git_cmd: list[str], cwd: Path, stash_ref: str) -> None:
def _restore_stashed_changes(
git_cmd: list[str], cwd: Path, stash_ref: str, prompt_user: bool = False, input_fn=None,
) -> bool:
from hermes_cli.update_cmd import (
_critical_module_import_failures, _git_untracked_paths, _restored_python_paths, _validate_python_files_syntax,
)
from hermes_cli.update_cmd import _critical_module_import_failures, _git_untracked_paths, _restored_python_paths, _validate_python_files_syntax
if prompt_user and not _confirm_restore(stash_ref, input_fn):
return False
preexisting_untracked = _git_untracked_paths(git_cmd, cwd)
@@ -356,7 +354,7 @@ def _discard_stashed_changes(git_cmd: list[str], cwd: Path, stash_ref: str) -> b
Unlike reset --hard + clean -fd this touches only what was stashed; ignored paths are never affected.
Returns True if dropped, False on git failure (stash left in place).
"""
from hermes_cli.update_cmd import _git_run
from hermes_cli.update_cmd_git import _git_run
stash_selector = _resolve_stash_selector(git_cmd, cwd, stash_ref)
if stash_selector is None:
print(
+4 -2
View File
@@ -402,7 +402,8 @@ def _relaunch_stopped_serves(token: dict) -> None:
"""Idempotent atexit relaunch of manual serves stopped by the venv guard.
`pending` flips False on first invocation so explicit call + atexit registration cannot double-spawn."""
from hermes_cli.update_cmd import _m, _record_update_step
from hermes_cli.update_cmd import _m
from hermes_cli.update_receipt import record_step as _record_update_step
if not token.get("pending"):
return
token["pending"] = False
@@ -1206,7 +1207,8 @@ def _clear_windows_venv_holders_or_exit(args, gateway_mode: bool, _windows_gatew
Rungs in order: leftover pausable gateways -> ledger orphaned backends -> orphaned Desktop backends ->
ledger manual serve (relaunched at exit on the same bind) -> GUI hand-off leaks. Remaining holders are
refused (the sync would corrupt against a locked .pyd)."""
from hermes_cli.update_cmd import _m, _record_update_step, _refuse_gateway_ancestor_tree_kill
from hermes_cli.update_cmd import _m, _refuse_gateway_ancestor_tree_kill
from hermes_cli.update_receipt import record_step as _record_update_step
def _resume_and_exit():
_m()._resume_windows_gateways_after_update(_windows_gateway_resume)
+34 -43
View File
@@ -317,41 +317,30 @@ def _download_and_swap_zip(branch: str, zip_url: str) -> None:
def _reinstall_python_deps_after_zip(active_tool_dependencies) -> None:
"""Reinstall Python deps (uv preferred, pip fallback) and re-arm active tool deps."""
from hermes_cli.update_cmd import (
_ensure_uv_for_termux, _ensure_venv_pip, _m, _refuse_update_for_contended_shims, _shim_quarantine_error_type,
)
"""Reinstall Python deps via the PM sync authority (pm.sync_venv, no pip fallback).
The PM sync stages a fresh generation environment and commits the selection — the live
environment of the running process is never mutated, so no self-lock deferral guards this
and no pip/uv restore re-arms tool deps into the superseded pre-swap environment
(pm-clean-audit-49945b1402 final-gates item 9)."""
from hermes_cli.update_cmd import _m
import pm
# Same raising-uv realize the current-checkout repair path uses: the update command
# must SEE realization failures rather than silently fall back.
try:
pm.ensure("uv")
except pm.InstallError as e:
print(f"⚠ Managed uv unavailable: {e}")
try:
pm.sync_venv(["all"], explicit=True)
except pm.InstallError as _sync_err:
print(f" ✗ {_sync_err}")
print(" Re-run `hermes update` (or `hermes pm install`) once resolved.")
raise
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
update_managed_uv() # keep managed uv current — runs `uv self update` if we already have one
uv_bin = ensure_uv()
pip_cmd = [_m().sys.executable, "-m", "pip"]
if not uv_bin:
uv_bin = _ensure_uv_for_termux(pip_cmd)
if uv_bin:
# Same UV-env isolation as the main update path: a user-level UV_PYTHON_INSTALL_DIR / UV_PYTHON
# from unrelated software must not steer which interpreter uv resolves here.
from hermes_cli.managed_uv import managed_python_env
uv_env = managed_python_env()
uv_env["VIRTUAL_ENV"] = str(_m().PROJECT_ROOT / "venv")
if _m()._is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
try:
_m()._install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env)
except _shim_quarantine_error_type() as _sqe:
# Runs inside the ZIP-fallback error handler, so cmd_update's boundary except cannot catch
# it — refuse here with the same defer-via-marker contract.
# See #87331.
_refuse_update_for_contended_shims(_sqe)
install_prefix, install_env = [uv_bin, "pip"], uv_env
else:
# sys.executable -m pip avoids PEP 668 'externally-managed-environment' errors.
_ensure_venv_pip(pip_cmd, _m().sys.executable)
_m()._install_python_dependencies_with_optional_fallback(pip_cmd)
install_prefix, install_env = pip_cmd, None
_m()._restore_active_tool_dependencies(active_tool_dependencies, install_prefix, env=install_env)
# Parity with git-pull path: heal the active memory provider's bridge packages after the reinstall.
_m()._refresh_active_memory_provider_dependencies()
@@ -359,11 +348,19 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> boo
"""Update via ZIP archive; used on Windows when git file I/O is broken (antivirus / NTFS filter
drivers causing 'Invalid argument'). Returns ``False`` when a Desktop rebuild ran and failed."""
from hermes_cli.update_cmd import (
_finish_dashboard_update_cleanup, _m, _print_bundled_skills_sync_report, _print_curator_first_run_notice,
_print_curator_recent_run_notice, _print_update_summary, _read_project_version, _rebuild_desktop_after_update,
_sweep_bytecode_after_update, _update_node_dependencies, _validate_critical_modules_import,
_finish_dashboard_update_cleanup,
_m,
_print_curator_first_run_notice,
_print_curator_recent_run_notice,
_print_update_summary,
_read_project_version,
_rebuild_desktop_after_update,
_update_node_dependencies,
_validate_critical_modules_import,
_verify_and_restore_state_dbs_post_update,
)
from hermes_cli.update_cmd_maint import _print_bundled_skills_sync_report
from hermes_cli.update_cmd_maint import _sweep_bytecode_after_update
active_tool_dependencies = _m()._capture_active_tool_dependencies()
pre_update_version = _read_project_version() # snapshot before files are replaced, for the completion line
# The static archive would silently ignore --branch — the exact silent-divergence bug it exists to
@@ -381,12 +378,6 @@ def _update_via_zip(args, *, had_desktop_app_before_update: bool = False) -> boo
_abort_zip_update_if_dirty_tree()
_download_and_swap_zip(branch, f"https://github.com/NousResearch/hermes-agent/archive/refs/heads/{branch}.zip")
_sweep_bytecode_after_update(branch)
# Self-lock deferral: the code swap is committed; defer only the dependency sync when this process
# holds a native extension the sync must rewrite.
# Reinstall Python dependencies. Prefer .[all], but if one optional extra breaks on this machine, keep
# base deps and reinstall the remaining extras individually so update does not silently strip working
# capabilities. See #86735.
_m()._abort_dependency_sync_if_self_locked()
print("→ Updating Python dependencies...")
_reinstall_python_deps_after_zip(active_tool_dependencies)
# Verify the tree imports (catches the parse-OK-but-skewed tree an interrupted copy leaves). Runs
+1 -1
View File
@@ -237,7 +237,7 @@ def collect_runtime_inventory() -> UpdatePlan:
plan = UpdatePlan()
_collect_install_shape(plan)
with _probe("Code-identity probe"):
from hermes_cli.build_info import get_code_identity
from hermes_cli.version_info import get_code_identity
identity = get_code_identity(refresh=True)
plan.expected_sha = identity.get("sha")
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff. Show more