diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6a89779feb..8c1e770271 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -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 diff --git a/.github/workflows/lazy-deps-guard.yml b/.github/workflows/lazy-deps-guard.yml new file mode 100644 index 0000000000..8d4adf490a --- /dev/null +++ b/.github/workflows/lazy-deps-guard.yml @@ -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 diff --git a/acp_adapter/content.py b/acp_adapter/content.py index d2137ffd38..17d07ed3bb 100644 --- a/acp_adapter/content.py +++ b/acp_adapter/content.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: diff --git a/acp_adapter/edit_approval.py b/acp_adapter/edit_approval.py index f60c55ddce..f081f77b99 100644 --- a/acp_adapter/edit_approval.py +++ b/acp_adapter/edit_approval.py @@ -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 diff --git a/activate b/activate index 5e8dd5b691..eb7e48da22 100644 --- a/activate +++ b/activate @@ -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--- 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 diff --git a/activate.ps1 b/activate.ps1 index 82bfdfb300..0b979a92f9 100644 --- a/activate.ps1 +++ b/activate.ps1 @@ -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 diff --git a/agent/agent_init.py b/agent/agent_init.py index 3421e26df8..5ca050ff46 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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}") diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index c332d8479f..ee492ef6fe 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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 diff --git a/agent/image_routing.py b/agent/image_routing.py index 475653d594..8b531bcc7f 100644 --- a/agent/image_routing.py +++ b/agent/image_routing.py @@ -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 diff --git a/agent/learning_mutations.py b/agent/learning_mutations.py index f1962639f8..2c5016e667 100644 --- a/agent/learning_mutations.py +++ b/agent/learning_mutations.py @@ -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 ───────────────────────────────────────────────────────────────── diff --git a/agent/monitoring/gateway_health_export.py b/agent/monitoring/gateway_health_export.py index ecf1a5a335..45c63e4099 100644 --- a/agent/monitoring/gateway_health_export.py +++ b/agent/monitoring/gateway_health_export.py @@ -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") diff --git a/agent/provider_registry.py b/agent/provider_registry.py index 0c563e5c24..fa2a90c19a 100644 --- a/agent/provider_registry.py +++ b/agent/provider_registry.py @@ -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: diff --git a/agent/proxy_sources/iron_proxy.py b/agent/proxy_sources/iron_proxy.py index a6df4deb8f..4d19919af8 100644 --- a/agent/proxy_sources/iron_proxy.py +++ b/agent/proxy_sources/iron_proxy.py @@ -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 ``/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"") diff --git a/agent/secret_sources/registry.py b/agent/secret_sources/registry.py index b3003d5aec..3dc3d29b6d 100644 --- a/agent/secret_sources/registry.py +++ b/agent/secret_sources/registry.py @@ -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 diff --git a/agent/skill_commands.py b/agent/skill_commands.py index aaf05d0c38..ea7a2ee6e5 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -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("*")) diff --git a/agent/turn_recovery.py b/agent/turn_recovery.py index 4dd2f1b5a1..6fd3fbb25b 100644 --- a/agent/turn_recovery.py +++ b/agent/turn_recovery.py @@ -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.", ) diff --git a/apps/desktop/electron/appinstaller-checker.test.ts b/apps/desktop/electron/appinstaller-checker.test.ts new file mode 100644 index 0000000000..aa6fa38333 --- /dev/null +++ b/apps/desktop/electron/appinstaller-checker.test.ts @@ -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[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' }) + }) +}) diff --git a/apps/desktop/electron/appinstaller-checker.ts b/apps/desktop/electron/appinstaller-checker.ts new file mode 100644 index 0000000000..70e093dda6 --- /dev/null +++ b/apps/desktop/electron/appinstaller-checker.ts @@ -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