"""External-tool checks for hermes doctor: terminal backends, git/rg, Node + agent-browser, npm audit, tool availability. Split out of ``hermes_cli/doctor.py``, which re-exports every name so ``hermes_cli.doctor.`` keeps resolving (and monkeypatching). """ from __future__ import annotations import importlib.util import os import shutil import subprocess import sys from hermes_cli.doctor_platform import _system_package_install_cmd from hermes_cli.doctor_report import Finding, _fail_and_issue, check_info, check_ok, check_warn from hermes_cli.vercel_auth import describe_vercel_auth from hermes_constants import agent_browser_runnable, is_termux as _is_termux def _safe_which(cmd: str) -> str | None: """shutil.which wrapper resilient to platform monkeypatching in tests.""" try: return shutil.which(cmd) except Exception: return None def _run_ok(cmd: list[str], timeout: int, **kw) -> bool: """True when *cmd* exits 0 within *timeout*; a timeout counts as failure.""" try: return subprocess.run(cmd, capture_output=True, timeout=timeout, **kw).returncode == 0 except subprocess.TimeoutExpired: return False def _termux_browser_setup_steps(node_installed: bool) -> list[str]: steps = [] if node_installed else ["1) pkg install nodejs"] n = len(steps) + 1 return steps + [f"{n}) npm install -g agent-browser", f"{n + 1}) agent-browser install"] def _termux_install_all_fallback_notes() -> list[str]: return [ "Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).", "Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).", "Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).", "STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).", ] 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" or 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 ``(status, label, detail)`` rows (status ``ok``/``warn``) for web search/extract readiness. Uses the same active-provider resolvers as the tools but reports ``is_available()`` readiness, 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 is a fresh process: bundled web providers only register during plugin # discovery, which nothing has triggered yet (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.""" from hermes_cli.doctor import _honcho_is_configured_for_doctor updated_available = list(available) updated_unavailable = [] for item in unavailable: if _is_kanban_worker_env_gate(item): gated = "kanban" elif item.get("name") == "honcho" and _honcho_is_configured_for_doctor(): gated = "honcho" else: updated_unavailable.append(item) continue if gated not in updated_available: updated_available.append(gated) return updated_available, updated_unavailable def _enabled_cli_toolsets_for_doctor() -> set[str] | None: """Return toolsets enabled for the CLI, or None if config resolution fails.""" try: from hermes_cli.config import load_config from hermes_cli.tools_config import _get_platform_tools return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, "cli")} except Exception: return None def _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]: """Filter unavailable API-key toolsets to those enabled for the CLI.""" from hermes_cli.doctor import _enabled_cli_toolsets_for_doctor api_key_unavailable = [item for item in unavailable if item.get("missing_vars") or item.get("env_vars")] enabled_toolsets = _enabled_cli_toolsets_for_doctor() if enabled_toolsets is None: return api_key_unavailable return [item for item in api_key_unavailable if str(item.get("name") or "") in enabled_toolsets] def _check_git_and_rg(should_fix: bool) -> Finding: f = Finding() if _safe_which("git"): check_ok("git") else: check_warn("git not found", "(optional)") if _safe_which("rg"): check_ok("ripgrep (rg)", "(faster file search)") else: check_warn("ripgrep (rg) not found", "(file search uses grep fallback)") check_info(f"Install for faster search: {_system_package_install_cmd('ripgrep')}") return f _BUILTIN_TERMINAL_BACKENDS = {"local", "docker", "singularity", "modal", "managed_modal", "daytona", "vercel_sandbox", "ssh"} def _check_docker_backend(terminal_env: str, running_in_container: bool, issues: list[str]) -> None: if terminal_env == "docker": if not _safe_which("docker"): _fail_and_issue("docker not found", "(required for TERMINAL_ENV=docker)", "Install Docker or change TERMINAL_ENV", issues) elif _run_ok(["docker", "info"], timeout=10): check_ok("docker", "(daemon running)") else: _fail_and_issue("docker daemon not running", "", "Start Docker daemon", issues) elif _safe_which("docker"): check_ok("docker", "(optional)") elif _is_termux(): check_info("Docker backend is not available inside Termux (expected on Android)") elif not running_in_container: # in-container case already explained by the caller check_warn("docker not found", "(optional)") def _check_ssh_backend(issues: list[str]) -> None: ssh_host = os.getenv("TERMINAL_SSH_HOST") if not ssh_host: _fail_and_issue("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)", "Set TERMINAL_SSH_HOST in .env", issues) return ssh_user, ssh_port, ssh_key = (os.getenv(f"TERMINAL_SSH_{k}") for k in ("USER", "PORT", "KEY")) cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"] if ssh_port: cmd += ["-p", ssh_port] if ssh_key: cmd += ["-i", os.path.expanduser(ssh_key)] cmd += [f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host, "echo ok"] if _run_ok(cmd, timeout=15, text=True, encoding='utf-8', errors='replace'): check_ok(f"SSH connection to {ssh_host}") else: _fail_and_issue(f"SSH connection to {ssh_host}", "", f"Check SSH configuration for {ssh_host}", issues) def _check_daytona_backend(issues: list[str]) -> None: if os.getenv("DAYTONA_API_KEY"): check_ok("Daytona API key", "(configured)") else: _fail_and_issue("DAYTONA_API_KEY not set", "(required for TERMINAL_ENV=daytona)", "Set DAYTONA_API_KEY environment variable", issues) try: from daytona import Daytona # noqa: F401 — SDK presence check check_ok("daytona SDK", "(installed)") except ImportError: _fail_and_issue("daytona SDK not installed", "(pip install daytona)", "Install daytona SDK: pip install daytona", issues) def _check_vercel_backend(issues: list[str]) -> None: from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24" if runtime in _SUPPORTED_VERCEL_RUNTIMES: check_ok("Vercel runtime", f"({runtime})") else: supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES) _fail_and_issue("Vercel runtime unsupported", f"({runtime}; use {supported})", f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}", issues) if os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip() in {"", "0", "51200"}: check_ok("Vercel disk setting", "(uses platform default)") else: _fail_and_issue("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)", "Vercel Sandbox does not support custom container_disk; use the shared default 51200", issues) if importlib.util.find_spec("vercel") is not None: check_ok("vercel SDK", "(installed)") else: _fail_and_issue("vercel SDK not installed", "(pip install 'hermes-agent[vercel]')", "Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'", issues) auth_status = describe_vercel_auth() if auth_status.ok: check_ok("Vercel auth", f"({auth_status.label})") elif auth_status.label.startswith("partial"): _fail_and_issue("Vercel auth incomplete", f"({auth_status.label})", "Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together", issues) else: _fail_and_issue("Vercel auth not configured", f"({auth_status.label})", "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", issues) for line in auth_status.detail_lines: check_info(f"Vercel auth {line}") if os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"}: check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation") else: check_info("Vercel persistence: ephemeral filesystem") def _check_plugin_backend(terminal_env: str, issues: list[str]) -> None: try: from hermes_cli.plugins import discover_plugins discover_plugins() from agent.terminal_env_registry import get_provider provider = get_provider(terminal_env) except Exception: provider = None if provider is None: _fail_and_issue(f"Unknown terminal backend '{terminal_env}'", "(no built-in or plugin backend by that name)", "Fix terminal.backend in config.yaml, or install/enable the plugin that provides it", issues) return for ok, label, detail in provider.doctor_checks(): if ok: check_ok(label, detail) else: _fail_and_issue(label, detail, detail.strip("()"), issues) def _check_terminal_backend(should_fix: bool) -> Finding: """Docker/SSH/Daytona/Vercel/plugin terminal backends, gated on TERMINAL_ENV.""" f = Finding() terminal_env = os.getenv("TERMINAL_ENV", "local") try: from hermes_constants import is_container as _is_container running_in_container = _is_container() except Exception: running_in_container = False # Inside our container docker-in-docker isn't set up, so the local backend is the # intended one: skip the noisy "docker not found" warning. An explicit # TERMINAL_ENV=docker (user likely mounted docker.sock) still gets the normal check. if running_in_container and terminal_env != "docker": check_info("Running inside a container — using local terminal backend (docker-in-docker is not configured by default)") terminal_env = "local" _check_docker_backend(terminal_env, running_in_container, f.issues) if terminal_env == "ssh": _check_ssh_backend(f.issues) elif terminal_env == "daytona": _check_daytona_backend(f.issues) elif terminal_env == "vercel_sandbox": _check_vercel_backend(f.issues) elif terminal_env not in _BUILTIN_TERMINAL_BACKENDS: _check_plugin_backend(terminal_env, f.issues) return f def _check_agent_browser(should_fix: bool) -> bool: """agent-browser resolution; returns True when browser tools will find a usable install. Mirrors ``tools.browser_tool._find_agent_browser``'s own cascade (it resolves lazily via npx or a global/Hermes-managed install) so doctor can't diverge from what the tools find; validate=False keeps this a cheap existence check with no subprocess or install side effects. """ try: from tools.browser_tool import _find_agent_browser, _is_npx_agent_browser_sentinel resolved = _find_agent_browser(validate=False) except Exception: resolved = None if resolved and _is_npx_agent_browser_sentinel(resolved): check_ok("agent-browser", "(resolves via npx on first use)") if should_fix: # Can't tell from here whether npx's cache is warm — fire the same warm-up # `hermes update` does so the first browser call doesn't pay the registry fetch. from tools.browser_tool import warm_agent_browser_npx_cache if warm_agent_browser_npx_cache(): check_info(" Warmed npx cache for agent-browser") else: check_info(" Could not warm npx cache (offline or npx unavailable)") return True if resolved and agent_browser_runnable(resolved): check_ok("agent-browser", "(browser automation)") return True if resolved: # Almost always a dangling global symlink left by agent-browser's npm # postinstall after `hermes update` wiped node_modules. check_warn("agent-browser found but not runnable", f"(broken symlink at {resolved}? run: npx agent-browser --version)") elif _is_termux(): check_info("agent-browser is not installed (expected in the tested Termux path)") check_info("Install it manually later with: npm install -g agent-browser && agent-browser install") check_info("Termux browser setup:") for step in _termux_browser_setup_steps(node_installed=True): check_info(step) else: check_warn("agent-browser not installed", "(requires npm/npx on PATH)") return False def _check_chromium() -> None: """Playwright Chromium presence, using the exact predicate browser_tool uses to hide browser_* tools. Lazy import: browser_tool is a ~150KB module; if it can't import that is a separate bug surfaced elsewhere. Camofox, a CDP override, a cloud provider, or Lightpanda all bypass the local Chromium requirement, so no warning is emitted for them. """ from hermes_cli.doctor import PROJECT_ROOT try: from tools.browser_tool import (_chromium_installed, _is_camofox_mode, _get_cloud_provider, _get_cdp_override_raw, _using_lightpanda_engine) except Exception: return if _is_camofox_mode() or bool(_get_cdp_override_raw()) or _get_cloud_provider() is not None or _using_lightpanda_engine(): return if _chromium_installed(): check_ok("Playwright Chromium", "(browser engine)") return check_warn("Playwright Chromium not installed", "(browser_* tools will be hidden from the agent)") with_deps = "" if sys.platform == "win32" else "--with-deps " check_info(f"Install with: cd {PROJECT_ROOT} && npx playwright install {with_deps}chromium") def _check_lightpanda() -> None: """Lightpanda engine (browser.engine / AGENT_BROWSER_ENGINE); independent of Node since Browser Use mode spawns ``lightpanda serve`` itself.""" try: from tools.browser_tool import _using_lightpanda_engine, lightpanda_engine_status from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary except Exception: return # _using_lightpanda_engine() is a cached config read — a failure there is exceptional, not hidden. if not _using_lightpanda_engine(): return try: used, reason = lightpanda_engine_status() except Exception as e: used, reason = False, f"status check failed: {e}" if not used: check_warn("browser.engine=lightpanda is shadowed", f"({reason})") check_info("Fix: pick Lightpanda in `hermes tools` → Browser Automation, or set browser.engine: auto") elif find_lightpanda_binary(): check_ok("Lightpanda", f"({reason})") else: check_warn("Lightpanda selected but binary not found", "(browser tools will fail until it is installed)") check_info(LIGHTPANDA_INSTALL_HINT) def _check_node_and_browser(should_fix: bool) -> Finding: """Node.js, agent-browser resolution, Playwright Chromium, Lightpanda engine.""" f = Finding() if _safe_which("node"): check_ok("Node.js") if _check_agent_browser(should_fix) and not _is_termux(): # Chromium check is not a tested Termux path _check_chromium() elif _is_termux(): check_info("Node.js not found (browser tools are optional in the tested Termux path)") check_info("Install Node.js on Termux with: pkg install nodejs") check_info("Termux browser setup:") for step in _termux_browser_setup_steps(node_installed=False): check_info(step) else: check_warn("Node.js not found", "(optional, needed for browser tools)") _check_lightpanda() return f def _plural(n: int) -> str: return "vulnerability" if n == 1 else "vulnerabilities" def _audit_one(npm_bin: str, npm_dir, label: str, audit_extra: list[str], issues: list[str]) -> None: """Run one `npm audit --json` and report; any failure is silently skipped. Workspace-scoped (`--workspace `) advisories are build-time tooling (esbuild/vite), not runtime code. `npm audit fix --workspace` crashes on current npm (arborist "edgesOut"), and the root-level fix can crash on the same tree ("isDescendantOf"), so no manual fix command is offered for those — they clear via a lockfile bump. """ import json try: # Resolved absolute path so Windows can execute npm.cmd (CreateProcessW can't run bare .cmd names). audit_result = subprocess.run([npm_bin, "audit", "--json", *audit_extra], cwd=str(npm_dir), capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30) audit_data = json.loads(audit_result.stdout) if audit_result.stdout.strip() else {} counts = audit_data.get("metadata", {}).get("vulnerabilities", {}) critical, high, moderate = (counts.get(k, 0) for k in ("critical", "high", "moderate")) total = critical + high + moderate workspace_scoped = bool(audit_extra) and audit_extra[0] == "--workspace" if total == 0: check_ok(f"{label} deps", "(no known vulnerabilities)") elif critical > 0 or high > 0: if workspace_scoped: remedy = "build-tool advisory; clears via lockfile bump" else: flag = " --workspaces=false" if audit_extra == ["--workspaces=false"] else "" remedy = f"run: cd {npm_dir} && npm audit fix{flag}" check_warn(f"{label} deps", f"({critical} critical, {high} high, {moderate} moderate — {remedy})") if workspace_scoped: check_info(" ^ build-time tooling (not runtime); if manual npm remediation " "errors with an arborist crash it's a known npm bug — clears via a lockfile bump") issues.append(f"{label} has {total} npm {_plural(total)}") else: check_ok(f"{label} deps", f"({moderate} moderate {_plural(moderate)})") except Exception: pass def _check_npm_audit(should_fix: bool) -> Finding: """npm audit per Node package tree (root, web/ui-tui workspaces, WhatsApp bridge). PROJECT_ROOT is audited with --workspaces=false so the apps/* glob (Electron, node-pty, ...) is never resolved for a routine security check; web and ui-tui are audited via --workspace. The WhatsApp bridge may live under a writable HERMES_HOME mirror rather than the (possibly read-only) install tree in Docker, so it is resolved through the shared helper. """ from hermes_cli.doctor import PROJECT_ROOT f = Finding() npm_bin = _safe_which("npm") if npm_bin: try: from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir whatsapp_bridge_dir = resolve_whatsapp_bridge_dir() except Exception: whatsapp_bridge_dir = PROJECT_ROOT / "scripts" / "whatsapp-bridge" for npm_dir, label, audit_extra in ( (PROJECT_ROOT, "Browser tools (agent-browser)", ["--workspaces=false"]), (PROJECT_ROOT, "web workspace", ["--workspace", "web"]), (PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]), (whatsapp_bridge_dir, "WhatsApp bridge", []), ): # Workspace-scoped audits run from PROJECT_ROOT check the root node_modules; # standalone dirs (whatsapp-bridge) check their own. check_dir = PROJECT_ROOT if audit_extra else npm_dir if (check_dir / "node_modules").exists(): _audit_one(npm_bin, npm_dir, label, audit_extra, f.issues) if _is_termux(): check_info("Termux compatibility fallbacks:") for note in _termux_install_all_fallback_notes(): check_info(note) return f def _check_tool_availability(should_fix: bool) -> Finding: from hermes_cli.doctor import PROJECT_ROOT, _doctor_web_capability_rows f = Finding() try: sys.path.insert(0, str(PROJECT_ROOT)) from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS available, unavailable = check_tool_availability() available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable) # Web is split into search/extract readiness rows so an explicitly # selected but unconfigured backend cannot look healthy. web_rows = [] if "web" in available or any(item.get("name") == "web" for item in unavailable): web_rows = _doctor_web_capability_rows() if web_rows: available = [tid for tid in available if tid != "web"] unavailable = [item for item in unavailable if item.get("name") != "web"] for tid in available: check_ok(TOOLSET_REQUIREMENTS.get(tid, {}).get("name", tid), _doctor_tool_availability_detail(tid)) for status, label, detail in web_rows: (check_ok if status == "ok" else check_warn)(label, detail) for item in unavailable: env_vars = item.get("missing_vars") or item.get("env_vars") or [] check_warn(item["name"], f"(missing {', '.join(env_vars)})" if env_vars else "(system dependency not met)") # Only toolsets enabled for the CLI count toward the summary; default-off or # disabled toolsets may warn above but must not pollute it. api_disabled = _missing_api_key_toolsets_for_summary(unavailable) if api_disabled or any(status != "ok" for status, _, _ in web_rows): f.issues.append("Run 'hermes setup' to configure missing API keys for full tool access") except Exception as e: check_warn("Could not check tool availability", f"({e})") return f