mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
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:
1 parent
49945b1402
commit
92686159d1
362 files changed
+12928
-22396
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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("*"))
|
||||
|
||||
@@ -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', () =>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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: 'جار التحضير...',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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: '準備中…',
|
||||
|
||||
@@ -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: 'Готовимся…',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '準備中…',
|
||||
|
||||
@@ -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: '准备中…',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)")
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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])))
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+105
-413
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in new issue
Block a user