diff --git a/misc/hermes-agent/Makefile b/misc/hermes-agent/Makefile index e7bc2d8babed..98d426f16197 100644 --- a/misc/hermes-agent/Makefile +++ b/misc/hermes-agent/Makefile @@ -1,167 +1,191 @@ PORTNAME= hermes-agent -PORTVERSION= 0.17.0 +PORTVERSION= 0.18.0 CATEGORIES= misc python MASTER_SITES+= LOCAL/olivier:webdist DISTFILES+= ${PORTNAME}-web-dist-${PORTVERSION}${EXTRACT_SUFX}:webdist MAINTAINER= olivier@FreeBSD.org COMMENT= AI agent with built-in learning loop WWW= https://github.com/NousResearch/hermes-agent LICENSE= MIT LICENSE_FILE= ${WRKSRC}/LICENSE +# Upstream splits deps into a small `dependencies` core plus lazy-installed +# extras (tools/lazy_deps.py). The port ships the provider-specific extras +# (anthropic, edge-tts, exa-py, fal-client, firecrawl-py, parallel-web) as +# hard RUN_DEPENDS because patch-tools_lazy__deps.py turns lazy installs off +# on FreeBSD -- pkg owns site-packages, so there is no runtime pip fallback. +# +# firecrawl-anydoc (upstream core since v2026.8.31) is deliberately NOT +# listed: no port exists, and tools/read_extract.py guards the import and +# emits a teaching error naming the pip fallback, so the legacy-Office / +# ODF / EPUB read_file paths degrade instead of breaking the install. RUN_DEPENDS= ${PYTHON_PKGNAMEPREFIX}anthropic>=0.86.0:misc/py-anthropic@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}certifi>=2026.5.20:security/py-certifi@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}croniter>=6.0.0:sysutils/py-croniter@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}cryptography>=50.0.0:security/py-cryptography@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}edge-tts>=7.2.7:audio/py-edge-tts@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}exa-py>=2.10.2:www/py-exa-py@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}fal-client>=0.13.1:misc/py-fal-client@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}fastapi>=0.133.1:www/py-fastapi@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}fire>=0.7.0:devel/py-fire@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}firecrawl-py>=4.17.0:www/py-firecrawl-py@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}httpx>=0.28.1:www/py-httpx@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}Jinja2>=3.1.6:devel/py-Jinja2@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}markdown>=3.10.2:textproc/py-markdown@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}openai>=2.24.0:misc/py-openai@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}packaging>=26.0:devel/py-packaging@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}parallel-web>=0.4.2:www/py-parallel-web@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}pathspec>=1.1.1:devel/py-pathspec@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}pillow>=11.3.0:graphics/py-pillow@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}prompt-toolkit>=3.0.52:devel/py-prompt-toolkit@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}psutil>=7.2.2:sysutils/py-psutil@${PY_FLAVOR} \ - ${PYTHON_PKGNAMEPREFIX}pydantic2>=2.12.5:devel/py-pydantic2@${PY_FLAVOR} \ - ${PYTHON_PKGNAMEPREFIX}pyjwt>=2.12.1:www/py-pyjwt@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}pydantic2>=2.13.4:devel/py-pydantic2@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}pyjwt>=2.13.0:www/py-pyjwt@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}pysocks>0:net/py-pysocks@${PY_FLAVOR} \ - ${PYTHON_PKGNAMEPREFIX}python-dotenv>=1.2.1:www/py-python-dotenv@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}python-dotenv>=1.2.2:www/py-python-dotenv@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}python-multipart>=0.0.9:www/py-python-multipart@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}pyyaml>=6.0.3:devel/py-pyyaml@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}requests>=2.33.0:www/py-requests@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}rich>=14.3.3:textproc/py-rich@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}ruamel.yaml>=0.18.17:devel/py-ruamel.yaml@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}snowballstemmer>=3.1.1:textproc/py-snowballstemmer@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}socksio>0:net/py-socksio@${PY_FLAVOR} \ ${PYTHON_PKGNAMEPREFIX}tenacity>=9.1.4:devel/py-tenacity@${PY_FLAVOR} \ - ${PYTHON_PKGNAMEPREFIX}uvicorn>=0.41.0:www/py-uvicorn@${PY_FLAVOR} + ${PYTHON_PKGNAMEPREFIX}urllib3>=2.7.0:net/py-urllib3@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}uvicorn>=0.41.0:www/py-uvicorn@${PY_FLAVOR} \ + ${PYTHON_PKGNAMEPREFIX}websockets>=15.0.1:devel/py-websockets@${PY_FLAVOR} USES= python:3.11+,run shebangfix USE_GITHUB= yes GH_ACCOUNT= NousResearch GH_PROJECT= hermes-agent -GH_TAGNAME= v2026.8.18 +GH_TAGNAME= v2026.8.31 USE_RC_SUBR= hermes_dashboard hermes_gateway SUB_FILES= pkg-message NO_ARCH= yes NO_BUILD= yes # Hermes is an application, not a Python library. Upstream's Dockerfile, # Nix flake, and Homebrew formula all install it into a private directory # (/opt/hermes, the Nix store, libexec/ respectively) rather than into # site-packages, because the project ships top-level packages with generic # names (tools, agent, gateway, plugins, ...) and bare modules (cli.py, # utils.py, ...) that would collide with other Python packages. We follow # the same convention: install the source tree under HERMES_LIBDIR and # create thin wrapper scripts in ${PREFIX}/bin that inject HERMES_LIBDIR # into sys.path before calling each entry point. HERMES_LIBDIR= ${PREFIX}/lib/${PORTNAME} PLIST_SUB+= HERMES_LIBDIR=${HERMES_LIBDIR:S,^${PREFIX}/,,} # Web dashboard SPA (Vite/React) — upstream's release tarball does NOT ship # a prebuilt web_dist/, only the source under web/. Building it requires # native node modules (lightningcss, @tailwindcss/oxide) that upstream only # publishes for freebsd-x64 — no freebsd-arm64 binaries exist on npm — # making the build impossible on non-amd64. The bundle itself is static # HTML/CSS/JS (NO_ARCH-safe), so we prebuild it once on amd64, ship it as # a second distfile (LOCAL/:webdist), and drop it into place # during extract. web_server.py serves it from # ${HERMES_LIBDIR}/hermes_cli/web_dist at runtime. # # How to (re)generate ${PORTNAME}-web-dist-${PORTVERSION}.tar.gz on every # PORTVERSION bump (run on an amd64 host with npm 10+ and node 20+): # # 1. Extract the upstream source tarball: # tar xzf ${DISTDIR}/NousResearch-${PORTNAME}-${PORTVERSION}-${GH_TAGNAME}_GH0.tar.gz # cd ${PORTNAME}-*/web # 2. Install deps and build the SPA: -# npm ci --no-audit --no-fund +# npm install --no-audit --no-fund # npm run build # This writes the bundle to ../hermes_cli/web_dist/. +# (v2026.8.31 dropped web/package-lock.json, so `npm install` is the +# only option -- `npm ci` requires a lockfile. Earlier releases also +# needed --engine-strict=false for a too-narrow npm range in +# package.json; the engines gate is gone as of this release.) # 3. Repackage with a top-level dir whose name matches the distfile: # cp -a ../hermes_cli/web_dist /tmp/${PORTNAME}-web-dist-${PORTVERSION} # cd /tmp && tar --no-acls --no-xattrs --no-fflags --uid=0 --gid=0 \ # -czf ${PORTNAME}-web-dist-${PORTVERSION}.tar.gz \ # ${PORTNAME}-web-dist-${PORTVERSION} # 4. Upload to LOCAL/'s distcache directory and drop a copy # into ${DISTDIR} so `make makesum` picks it up locally. # 5. cd ${.CURDIR} && make makesum # Python packages that constitute the runtime app. The bare top-level # modules (cli.py, run_agent.py, registration_lifecycle.py, ...) are not # listed: do-install globs every top-level *.py except setup.py, because # upstream adds new leaf modules on most releases and a hardcoded list # silently drops them (ModuleNotFoundError at runtime). HERMES_PKGS= acp_adapter agent cron gateway hermes_cli plugins providers \ tools tui_gateway SHEBANG_FILES= *.py PORTDOCS= README.md SECURITY.md CONTRIBUTING.md AGENTS.md OPTIONS_DEFINE= DOCS PLIST_FILES= "@(,,0755) bin/hermes" \ "@(,,0755) bin/hermes-agent" \ "@(,,0755) bin/hermes-acp" # Move the prebuilt web bundle (extracted to ${WRKDIR}/${PORTNAME}-web-dist- # ${PORTVERSION}/ by bsd.port.mk) into hermes_cli/web_dist so do-install # picks it up alongside the rest of the hermes_cli package. post-extract: ${MV} ${WRKDIR}/${PORTNAME}-web-dist-${PORTVERSION} \ ${WRKSRC}/hermes_cli/web_dist do-install: ${MKDIR} ${STAGEDIR}${HERMES_LIBDIR} .for d in ${HERMES_PKGS} cd ${WRKSRC} && ${COPYTREE_SHARE} ${d} ${STAGEDIR}${HERMES_LIBDIR} \ "! -name __pycache__ ! -name *.pyc" .endfor cd ${WRKSRC} && for f in *.py; do \ [ "$$f" = setup.py ] && continue; \ ${INSTALL_DATA} $$f ${STAGEDIR}${HERMES_LIBDIR}; \ done ${MKDIR} ${STAGEDIR}${PREFIX}/bin ${SED} -e 's|%%HERMES_LIBDIR%%|${HERMES_LIBDIR}|g' \ -e 's|%%DATADIR%%|${DATADIR}|g' \ -e 's|%%PYTHON_CMD%%|${PYTHON_CMD}|g' \ -e 's|%%ENTRY_MODULE%%|hermes_cli.main|g' \ -e 's|%%ENTRY_FUNC%%|main|g' \ ${FILESDIR}/wrapper.in > ${STAGEDIR}${PREFIX}/bin/hermes ${SED} -e 's|%%HERMES_LIBDIR%%|${HERMES_LIBDIR}|g' \ -e 's|%%DATADIR%%|${DATADIR}|g' \ -e 's|%%PYTHON_CMD%%|${PYTHON_CMD}|g' \ -e 's|%%ENTRY_MODULE%%|run_agent|g' \ -e 's|%%ENTRY_FUNC%%|main|g' \ ${FILESDIR}/wrapper.in > ${STAGEDIR}${PREFIX}/bin/hermes-agent ${SED} -e 's|%%HERMES_LIBDIR%%|${HERMES_LIBDIR}|g' \ -e 's|%%DATADIR%%|${DATADIR}|g' \ -e 's|%%PYTHON_CMD%%|${PYTHON_CMD}|g' \ -e 's|%%ENTRY_MODULE%%|acp_adapter.entry|g' \ -e 's|%%ENTRY_FUNC%%|main|g' \ ${FILESDIR}/wrapper.in > ${STAGEDIR}${PREFIX}/bin/hermes-acp ${MKDIR} ${STAGEDIR}${DATADIR} cd ${WRKSRC} && ${COPYTREE_SHARE} skills ${STAGEDIR}${DATADIR} cd ${WRKSRC} && ${COPYTREE_SHARE} optional-skills ${STAGEDIR}${DATADIR} post-install: @cd ${STAGEDIR}${PREFIX} && \ ${FIND} ${HERMES_LIBDIR:S,^${PREFIX}/,,} ${DATADIR:S,^${PREFIX}/,,} \ -type f >> ${TMPPLIST} @cd ${STAGEDIR}${PREFIX} && \ ${FIND} ${HERMES_LIBDIR:S,^${PREFIX}/,,} ${DATADIR:S,^${PREFIX}/,,} \ -type d -mindepth 1 | ${SORT} -r | \ ${SED} 's|^|@dir |' >> ${TMPPLIST} post-install-DOCS-on: ${MKDIR} ${STAGEDIR}${DOCSDIR} .for f in ${PORTDOCS} ${INSTALL_DATA} ${WRKSRC}/${f} ${STAGEDIR}${DOCSDIR} .endfor .include diff --git a/misc/hermes-agent/distinfo b/misc/hermes-agent/distinfo index 942fbbb7ed70..45fd1b39b858 100644 --- a/misc/hermes-agent/distinfo +++ b/misc/hermes-agent/distinfo @@ -1,5 +1,5 @@ -TIMESTAMP = 1787258479 -SHA256 (hermes-agent-web-dist-0.17.0.tar.gz) = 8130e8eb8b7529cd4712e549829c500cbbed699c28c29740af3cb3d4efab98a2 -SIZE (hermes-agent-web-dist-0.17.0.tar.gz) = 1490329 -SHA256 (NousResearch-hermes-agent-0.17.0-v2026.8.18_GH0.tar.gz) = 1e3d39d3638ec15fa9d31af262568a953e9272090deb1c50c44cd401175f5b80 -SIZE (NousResearch-hermes-agent-0.17.0-v2026.8.18_GH0.tar.gz) = 66313931 +TIMESTAMP = 1788600397 +SHA256 (hermes-agent-web-dist-0.18.0.tar.gz) = 09b8f000eb69f303869e7e62494f6fad19f9026a1c73dcdc8b94c4425450fc9f +SIZE (hermes-agent-web-dist-0.18.0.tar.gz) = 1523028 +SHA256 (NousResearch-hermes-agent-0.18.0-v2026.8.31_GH0.tar.gz) = 78fb3ff707ec1d17044b875ecac8bef28aa39d44242824f6871ca40afe7bf217 +SIZE (NousResearch-hermes-agent-0.18.0-v2026.8.31_GH0.tar.gz) = 69421757 diff --git a/misc/hermes-agent/files/patch-agent_anthropic__adapter.py b/misc/hermes-agent/files/patch-agent_anthropic__adapter.py new file mode 100644 index 000000000000..9b8bb394d9a4 --- /dev/null +++ b/misc/hermes-agent/files/patch-agent_anthropic__adapter.py @@ -0,0 +1,75 @@ +--- agent/anthropic_adapter.py.orig 2026-09-05 11:43:11 UTC ++++ agent/anthropic_adapter.py +@@ -560,6 +560,34 @@ def _common_betas_for_base_url( + return betas + + ++_SDK_HTTPX_MOD = None ++ ++ ++def _sdk_httpx(): ++ """Return the httpx module the installed anthropic SDK is built against. ++ ++ anthropic 1.x moved its HTTP stack to ``httpx2``; objects crossing the SDK ++ boundary (``Timeout``, ``http_client``) must come from the same module or ++ the SDK rejects them at request time. Same split as ++ ``tools/mcp_tool.py:sdk_httpx()`` handles for mcp 1.x/2.x. ++ """ ++ global _SDK_HTTPX_MOD ++ if _SDK_HTTPX_MOD is not None: ++ return _SDK_HTTPX_MOD ++ try: ++ from anthropic import _base_client as _bc ++ _SDK_HTTPX_MOD = getattr(_bc, "httpx2", None) or getattr(_bc, "httpx", None) ++ except ImportError: ++ _SDK_HTTPX_MOD = None ++ if _SDK_HTTPX_MOD is None: ++ try: ++ import httpx2 as _fallback ++ except ImportError: ++ import httpx as _fallback # type: ignore[no-redef] ++ _SDK_HTTPX_MOD = _fallback ++ return _SDK_HTTPX_MOD ++ ++ + def _build_anthropic_client_with_bearer_hook( + token_provider, + base_url: str = None, +@@ -592,7 +620,7 @@ def _build_anthropic_client_with_bearer_hook( + + normalize_proxy_env_vars() + +- from httpx import Timeout ++ Timeout = _sdk_httpx().Timeout + from agent.azure_identity_adapter import build_bearer_http_client + + _read_timeout = timeout if (isinstance(timeout, (int, float)) and timeout > 0) else 900.0 +@@ -604,7 +632,9 @@ def _build_anthropic_client_with_bearer_hook( + import re as _re + normalized_base_url = _re.sub(r"/v1/?$", "", normalized_base_url.rstrip("/")) + +- http_client = build_bearer_http_client(token_provider, timeout=timeout_obj) ++ http_client = build_bearer_http_client( ++ token_provider, httpx_mod=_sdk_httpx(), timeout=timeout_obj ++ ) + + kwargs = { + "timeout": timeout_obj, +@@ -691,7 +721,7 @@ def build_anthropic_client( + + normalize_proxy_env_vars() + +- from httpx import Timeout ++ Timeout = _sdk_httpx().Timeout + + normalized_base_url = _normalize_base_url_text(base_url) + if normalized_base_url: +@@ -823,7 +853,7 @@ def build_anthropic_bedrock_client(region: str): + "anthropic.AnthropicBedrock not available. " + "Upgrade with: pip install 'anthropic>=0.39.0'" + ) +- from httpx import Timeout ++ Timeout = _sdk_httpx().Timeout + + return _anthropic_sdk.AnthropicBedrock( + aws_region=region, diff --git a/misc/hermes-agent/files/patch-agent_azure__identity__adapter.py b/misc/hermes-agent/files/patch-agent_azure__identity__adapter.py new file mode 100644 index 000000000000..68b5a695b4f1 --- /dev/null +++ b/misc/hermes-agent/files/patch-agent_azure__identity__adapter.py @@ -0,0 +1,44 @@ +--- agent/azure_identity_adapter.py.orig 2026-09-05 11:44:04 UTC ++++ agent/azure_identity_adapter.py +@@ -475,7 +475,12 @@ def materialize_bearer_for_http(value: Any) -> str: + raise ValueError("no usable api_key / token provider") + + +-def build_bearer_http_client(token_provider: Callable[[], str], **httpx_kwargs: Any) -> Any: ++def build_bearer_http_client( ++ token_provider: Callable[[], str], ++ *, ++ httpx_mod: Any = None, ++ **httpx_kwargs: Any, ++) -> Any: + """Return an ``httpx.Client`` that mints a fresh Entra bearer JWT + per outbound request. + +@@ -511,14 +516,19 @@ def build_bearer_http_client(token_provider: Callable[ + "token provider" + ) + +- try: +- import httpx +- except ImportError as exc: # pragma: no cover — httpx ships with openai/anthropic +- raise ImportError( +- "httpx is required for Entra ID bearer auth on Microsoft Foundry " +- "Anthropic-style endpoints. It is normally a transitive " +- "dependency of the openai/anthropic SDKs." +- ) from exc ++ # ``httpx_mod`` lets the caller pin the flavour its SDK expects: anthropic ++ # 1.x is on httpx2 while openai 2.x is still on httpx. Defaults to httpx. ++ if httpx_mod is not None: ++ httpx = httpx_mod ++ else: ++ try: ++ import httpx ++ except ImportError as exc: # pragma: no cover — httpx ships with openai/anthropic ++ raise ImportError( ++ "httpx is required for Entra ID bearer auth on Microsoft Foundry " ++ "Anthropic-style endpoints. It is normally a transitive " ++ "dependency of the openai/anthropic SDKs." ++ ) from exc + + def _inject_bearer(request: "httpx.Request") -> None: + try: diff --git a/misc/hermes-agent/files/patch-agent_skill__utils.py b/misc/hermes-agent/files/patch-agent_skill__utils.py index be1fc31615b3..5dec1122077c 100644 --- a/misc/hermes-agent/files/patch-agent_skill__utils.py +++ b/misc/hermes-agent/files/patch-agent_skill__utils.py @@ -1,12 +1,12 @@ ---- agent/skill_utils.py.orig 2026-08-03 16:57:23 UTC +--- agent/skill_utils.py.orig 2026-08-31 19:29:27 UTC +++ agent/skill_utils.py -@@ -236,6 +236,9 @@ def skill_matches_platform_list(platforms: Any) -> boo +@@ -237,6 +237,9 @@ def skill_matches_platform_list(platforms: Any) -> boo mapped = PLATFORM_MAP.get(normalized, normalized) if current.startswith(mapped): return True + # FreeBSD is POSIX-compatible; accept linux-tagged skills. + if current.startswith("freebsd") and mapped == "linux": + return True # Termux runs a Linux userland on Android. Accept linux-tagged # skills regardless of whether sys.platform is "linux" (pre-3.13 # Termux) or "android" (Python 3.13+ Termux, and any other diff --git a/misc/hermes-agent/files/patch-hermes__cli_config__defaults.py b/misc/hermes-agent/files/patch-hermes__cli_config__defaults.py index 504b7aadcd14..24b97794ef8e 100644 --- a/misc/hermes-agent/files/patch-hermes__cli_config__defaults.py +++ b/misc/hermes-agent/files/patch-hermes__cli_config__defaults.py @@ -1,24 +1,24 @@ ---- hermes_cli/config_defaults.py.orig 2026-08-04 14:52:16 UTC +--- hermes_cli/config_defaults.py.orig 2026-08-31 19:29:27 UTC +++ hermes_cli/config_defaults.py @@ -4,6 +4,8 @@ verbatim from hermes_cli/config.py. Must not import fr verbatim from hermes_cli/config.py. Must not import from hermes_cli.config. """ +import sys + DEFAULT_CONFIG = { "model": "", "providers": {}, -@@ -2153,8 +2155,10 @@ DEFAULT_CONFIG = { +@@ -2700,8 +2702,10 @@ DEFAULT_CONFIG = { # their TTS provider). Set to false to require explicit # ``pip install`` for everything beyond the base set — appropriate # for restricted networks, audited environments, or air-gapped - # systems where any runtime install is unacceptable. - "allow_lazy_installs": True, + # systems where any runtime install is unacceptable. Defaults to + # False on FreeBSD: the package manager owns the site-packages tree, + # and runtime pip writes there create pkg-invisible files. + "allow_lazy_installs": not sys.platform.startswith("freebsd"), }, "cron": { diff --git a/misc/hermes-agent/files/patch-hermes__cli_gateway.py b/misc/hermes-agent/files/patch-hermes__cli_gateway.py index 2a8f1e6db94e..b4443397d7a3 100644 --- a/misc/hermes-agent/files/patch-hermes__cli_gateway.py +++ b/misc/hermes-agent/files/patch-hermes__cli_gateway.py @@ -1,308 +1,308 @@ ---- hermes_cli/gateway.py.orig 2026-08-20 21:23:13 UTC +--- hermes_cli/gateway.py.orig 2026-08-31 19:29:27 UTC +++ hermes_cli/gateway.py -@@ -1945,6 +1945,45 @@ def supports_systemd_services() -> bool: +@@ -2608,6 +2608,45 @@ def supports_systemd_services() -> bool: return True +FREEBSD_RC_SCRIPT_NAME = "hermes_gateway" +FREEBSD_RC_SCRIPT_PATH = Path("/usr/local/etc/rc.d") / FREEBSD_RC_SCRIPT_NAME +FREEBSD_RC_VAR = "hermes_gateway_enable" + + +def is_freebsd() -> bool: + return sys.platform.startswith("freebsd") + + +# Privilege escalators, in preference order. sudo(8) is preferred so behavior +# matches the Linux path used elsewhere in the codebase; doas(1) is the +# common lightweight alternative on FreeBSD. +_FREEBSD_PRIV_ESCALATORS = ("sudo", "doas") + + +def _freebsd_privilege_escalator() -> str | None: + """Return the first available escalator command name, or None.""" + for name in _FREEBSD_PRIV_ESCALATORS: + if shutil.which(name) is not None: + return name + return None + + +def supports_freebsd_rc() -> bool: + """Return True only when running on FreeBSD, the port-installed rc.d + script exists, AND the caller has a viable path to root (already root, or + a privilege escalator like sudo/doas is on PATH). Without one, callers + cannot drive service(8) or sysrc(8), so the dispatcher falls through to + the generic "not supported" branch and the user can still run + `hermes gateway run` in the foreground.""" + if not is_freebsd(): + return False + if shutil.which("service") is None: + return False + if not FREEBSD_RC_SCRIPT_PATH.exists(): + return False + return _freebsd_is_root() or _freebsd_privilege_escalator() is not None + + def is_macos() -> bool: return sys.platform == "darwin" -@@ -2829,7 +2868,8 @@ def ensure_gateway_service(context: str = "setup") -> +@@ -3492,7 +3531,8 @@ def ensure_gateway_service(context: str = "setup") -> return False supports_systemd = supports_systemd_services() - if not (supports_systemd or is_macos() or is_windows()): + supports_rc = supports_freebsd_rc() + if not (supports_systemd or supports_rc or is_macos() or is_windows()): print_info(" No supported service manager found on this host.") print_info(" Run the gateway in the foreground with: hermes gateway") return False -@@ -2847,6 +2887,8 @@ def ensure_gateway_service(context: str = "setup") -> +@@ -3510,6 +3550,8 @@ def ensure_gateway_service(context: str = "setup") -> print_info(" Installing the gateway background service ...") if supports_systemd: systemd_install(force=False, non_interactive=True) + elif supports_rc: + freebsd_rc_install(force=False, non_interactive=True) elif is_macos(): launchd_install(force=False) else: -@@ -2859,6 +2901,8 @@ def ensure_gateway_service(context: str = "setup") -> +@@ -3522,6 +3564,8 @@ def ensure_gateway_service(context: str = "setup") -> if supports_systemd: systemd_start() + elif supports_rc: + freebsd_rc_start() elif is_macos(): launchd_start() else: -@@ -5287,6 +5331,129 @@ def launchd_status(deep: bool = False): +@@ -6145,6 +6189,129 @@ def launchd_status(deep: bool = False): # ============================================================================= +# FreeBSD rc.d service (port-installed hermes_gateway script) +# ============================================================================= +# +# The rc.d script itself is shipped by misc/hermes-agent; the CLI only flips +# rcvar via sysrc(8) and drives lifecycle via service(8). ``system=`` is +# accepted for API parity with systemd_* helpers but is a no-op — rc.d is +# inherently system-scoped. + + +def _freebsd_is_root() -> bool: + try: + return os.geteuid() == 0 + except AttributeError: + return False + + +def _freebsd_run_or_print(cmd: list[str], *, action: str) -> bool: + """Run *cmd* directly when root; otherwise prepend the first available + privilege escalator (sudo, then doas). When none is available, print the + command for the user to run manually and return False. Returns True on + success.""" + if _freebsd_is_root(): + try: + subprocess.run(cmd, check=True) + return True + except subprocess.CalledProcessError as e: + print(f"✗ Failed to {action} {FREEBSD_RC_SCRIPT_NAME}: exit {e.returncode}") + return False + + escalator = _freebsd_privilege_escalator() + if escalator is None: + print(f" Run as root: {' '.join(cmd)}") + return False + + try: + subprocess.run([escalator] + cmd, check=True) + return True + except subprocess.CalledProcessError as e: + print(f"✗ Failed to {action} {FREEBSD_RC_SCRIPT_NAME}: exit {e.returncode}") + return False + + +def freebsd_rc_install( + force: bool = False, + system: bool = False, + run_as_user: str | None = None, + enable_on_startup: bool = True, + non_interactive: bool = False, +): + """Enable hermes_gateway in /etc/rc.conf. Does NOT start — dispatcher + starts via freebsd_rc_start when the user opts in.""" + del force, system, enable_on_startup, non_interactive # dispatcher parity + + import getpass + target_user = run_as_user or getpass.getuser() + + print(f"Enabling {FREEBSD_RC_VAR}=YES in /etc/rc.conf...") + _freebsd_run_or_print( + ["sysrc", f"{FREEBSD_RC_VAR}=YES", f"hermes_gateway_user={target_user}"], + action="enable", + ) + + +def freebsd_rc_uninstall(system: bool = False): + """Stop the gateway and remove its rcvar. Leaves the rc.d script in place + (owned by pkg).""" + del system + print(f"Stopping {FREEBSD_RC_SCRIPT_NAME}...") + _freebsd_run_or_print( + ["service", FREEBSD_RC_SCRIPT_NAME, "stop"], + action="stop", + ) + print(f"Removing {FREEBSD_RC_VAR} from /etc/rc.conf...") + _freebsd_run_or_print( + ["sysrc", "-x", FREEBSD_RC_VAR], + action="disable", + ) + print(f" (The rc.d script {FREEBSD_RC_SCRIPT_PATH} is owned by the package") + print(" manager — use 'pkg delete hermes-agent' to remove it.)") + + +def freebsd_rc_start(system: bool = False): + del system + _freebsd_run_or_print( + ["service", FREEBSD_RC_SCRIPT_NAME, "start"], + action="start", + ) + + +def freebsd_rc_stop(system: bool = False): + del system + _freebsd_run_or_print( + ["service", FREEBSD_RC_SCRIPT_NAME, "stop"], + action="stop", + ) + + +def freebsd_rc_restart(system: bool = False): + del system + _freebsd_run_or_print( + ["service", FREEBSD_RC_SCRIPT_NAME, "restart"], + action="restart", + ) + + +def freebsd_rc_status(deep: bool = False, system: bool = False, full: bool = False): + del deep, system, full + result = subprocess.run( + ["service", FREEBSD_RC_SCRIPT_NAME, "status"], + check=False, + ) + if result.returncode != 0: + print() + print("To start the gateway:") + if _freebsd_is_root(): + print(" hermes gateway start") + else: + escalator = _freebsd_privilege_escalator() or "sudo" + print(f" {escalator} service {FREEBSD_RC_SCRIPT_NAME} start") + print(f" {escalator} sysrc {FREEBSD_RC_VAR}=YES # start at boot") + + +# ============================================================================= # Gateway Runner # ============================================================================= -@@ -7560,6 +7727,18 @@ def _gateway_command_inner(args): +@@ -8449,6 +8616,18 @@ def _gateway_command_inner(args): ) if start_now: systemd_start(system=system) + elif supports_freebsd_rc(): + non_interactive = not (hasattr(sys.stdin, "isatty") and sys.stdin.isatty()) + _sn = getattr(args, "start_now", None) + if _sn is not None: + start_now = _sn + elif not non_interactive: + start_now = prompt_yes_no("Start the gateway now after installing the service?", True) + else: + start_now = True + freebsd_rc_install(force=force, run_as_user=run_as_user) + if start_now: + freebsd_rc_start() elif is_macos(): launchd_install(force) elif is_windows(): -@@ -7634,6 +7813,8 @@ def _gateway_command_inner(args): +@@ -8537,6 +8716,8 @@ def _gateway_command_inner(args): sys.exit(1) if supports_systemd_services(): systemd_uninstall(system=system) + elif supports_freebsd_rc(): + freebsd_rc_uninstall() elif is_macos(): launchd_uninstall() elif is_windows(): -@@ -7687,6 +7868,8 @@ def _gateway_command_inner(args): +@@ -8590,6 +8771,8 @@ def _gateway_command_inner(args): sys.exit(1) if supports_systemd_services(): systemd_start(system=system) + elif supports_freebsd_rc(): + freebsd_rc_start() elif is_macos(): launchd_start() elif is_windows(): -@@ -7764,6 +7947,18 @@ def _gateway_command_inner(args): +@@ -8673,6 +8856,18 @@ def _gateway_command_inner(args): service_available = True except subprocess.CalledProcessError: pass + elif supports_freebsd_rc(): + try: + freebsd_rc_stop() + service_available = True + except subprocess.CalledProcessError: + pass + elif supports_freebsd_rc(): + try: + freebsd_rc_stop() + service_available = True + except subprocess.CalledProcessError: + pass elif is_macos() and get_launchd_plist_path().exists(): try: launchd_stop() -@@ -7861,6 +8056,12 @@ def _gateway_command_inner(args): +@@ -8776,6 +8971,12 @@ def _gateway_command_inner(args): service_stopped = True except subprocess.CalledProcessError: pass + elif supports_freebsd_rc(): + try: + freebsd_rc_stop() + service_stopped = True + except subprocess.CalledProcessError: + pass elif is_macos() and get_launchd_plist_path().exists(): try: launchd_stop() -@@ -7889,6 +8090,8 @@ def _gateway_command_inner(args): +@@ -8804,6 +9005,8 @@ def _gateway_command_inner(args): or get_systemd_unit_path(system=True).exists() ): systemd_start(system=system) + elif supports_freebsd_rc(): + freebsd_rc_start() elif is_macos() and get_launchd_plist_path().exists(): launchd_start() elif is_windows(): -@@ -7915,6 +8118,13 @@ def _gateway_command_inner(args): +@@ -8830,6 +9033,13 @@ def _gateway_command_inner(args): service_available = True except subprocess.CalledProcessError: pass + elif supports_freebsd_rc(): + service_configured = True + try: + freebsd_rc_restart() + service_available = True + except subprocess.CalledProcessError: + pass elif is_macos() and get_launchd_plist_path().exists(): service_configured = True try: -@@ -7997,6 +8207,9 @@ def _gateway_command_inner(args): +@@ -8912,6 +9122,9 @@ def _gateway_command_inner(args): or get_systemd_unit_path(system=True).exists() ): systemd_status(deep, system=system, full=full) + _print_gateway_process_mismatch(snapshot) + elif supports_freebsd_rc(): + freebsd_rc_status(deep, system=system, full=full) _print_gateway_process_mismatch(snapshot) elif is_macos() and get_launchd_plist_path().exists(): launchd_status(deep) diff --git a/misc/hermes-agent/files/patch-hermes__cli_main.py b/misc/hermes-agent/files/patch-hermes__cli_main.py index 2cb46e485db8..ed64dd386f8f 100644 --- a/misc/hermes-agent/files/patch-hermes__cli_main.py +++ b/misc/hermes-agent/files/patch-hermes__cli_main.py @@ -1,19 +1,19 @@ ---- hermes_cli/main.py.orig 2026-08-03 16:57:23 UTC +--- hermes_cli/main.py.orig 2026-08-31 19:29:27 UTC +++ hermes_cli/main.py -@@ -11112,6 +11112,16 @@ def cmd_skills(args): +@@ -13019,6 +13019,16 @@ def cmd_skills(args): def cmd_skills(args): + # Seed ~/.hermes/skills/ from the bundled library before any skills + # subcommand runs. Without this, a fresh install's `hermes skills list` + # reports zero skills — the sync only fires from cmd_chat / cmd_gateway / + # cmd_dashboard, so a user who runs `hermes skills` first sees an empty + # catalog and (reasonably) assumes something is broken. On FreeBSD in + # particular the port ships bundled skills under DATADIR and exposes the + # path via HERMES_BUNDLED_SKILLS; without this sync call, the catalog stays + # empty forever until the user happens to run one of the other entrypoints. + _sync_bundled_skills_quietly() + # Route 'config' action to skills_config module if getattr(args, "skills_action", None) == "config": _require_tty("skills config") diff --git a/misc/hermes-agent/files/patch-hermes__cli_setup.py b/misc/hermes-agent/files/patch-hermes__cli_setup.py index 4d6298ef9bdf..f68bf3cb36fd 100644 --- a/misc/hermes-agent/files/patch-hermes__cli_setup.py +++ b/misc/hermes-agent/files/patch-hermes__cli_setup.py @@ -1,31 +1,31 @@ ---- hermes_cli/setup.py.orig 2026-08-20 21:23:13 UTC +--- hermes_cli/setup.py.orig 2026-08-31 19:29:27 UTC +++ hermes_cli/setup.py -@@ -2240,9 +2240,11 @@ def setup_gateway(config: dict): +@@ -2350,9 +2350,11 @@ def setup_gateway(config: dict): from hermes_cli.gateway import ( _is_service_running, supports_systemd_services, + supports_freebsd_rc, ensure_gateway_service, systemd_restart, launchd_restart, + freebsd_rc_restart, UserSystemdUnavailableError, SystemScopeRequiresRootError, _system_scope_wizard_would_need_root, -@@ -2253,6 +2255,7 @@ def setup_gateway(config: dict): +@@ -2363,6 +2365,7 @@ def setup_gateway(config: dict): _is_macos = _platform.system() == "Darwin" _is_windows = _platform.system() == "Windows" supports_systemd = supports_systemd_services() + supports_rc = supports_freebsd_rc() print() if _is_service_running(): -@@ -2267,6 +2270,8 @@ def setup_gateway(config: dict): +@@ -2377,6 +2380,8 @@ def setup_gateway(config: dict): try: if supports_systemd: systemd_restart() + elif supports_rc: + freebsd_rc_restart() elif _is_macos: launchd_restart() elif _is_windows: diff --git a/misc/hermes-agent/files/patch-hermes__cli_uninstall.py b/misc/hermes-agent/files/patch-hermes__cli_uninstall.py index 5c7564199600..8490337642ea 100644 --- a/misc/hermes-agent/files/patch-hermes__cli_uninstall.py +++ b/misc/hermes-agent/files/patch-hermes__cli_uninstall.py @@ -1,26 +1,26 @@ ---- hermes_cli/uninstall.py.orig 2026-08-03 16:57:23 UTC +--- hermes_cli/uninstall.py.orig 2026-08-31 19:29:27 UTC +++ hermes_cli/uninstall.py -@@ -265,7 +265,22 @@ def uninstall_gateway_service(): +@@ -278,7 +278,22 @@ def uninstall_gateway_service(): except Exception as e: log_warn(f"Could not remove launchd gateway service: {e}") - # 4. Windows: uninstall Scheduled Task + Startup-folder entry. The + # 4. FreeBSD: rcvar disable via freebsd_rc_uninstall (delegates to + # sysrc/service). The rc.d script itself is pkg-owned and is removed + # by `pkg delete hermes-agent`, not here. + elif system == "FreeBSD": + try: + from hermes_cli.gateway import ( + supports_freebsd_rc, + freebsd_rc_uninstall, + ) + if supports_freebsd_rc(): + freebsd_rc_uninstall() + stopped_something = True + except Exception as e: + log_warn(f"Could not remove FreeBSD gateway service: {e}") + + # 5. Windows: uninstall Scheduled Task + Startup-folder entry. The # gateway_windows module already knows how to locate and remove both # code paths (schtasks /Delete + .cmd unlink) and how to stop any # running detached pythonw gateway process. We call into it so the diff --git a/misc/hermes-agent/files/patch-tools_lazy__deps.py b/misc/hermes-agent/files/patch-tools_lazy__deps.py index 806ce5257141..cbe9417fcd6e 100644 --- a/misc/hermes-agent/files/patch-tools_lazy__deps.py +++ b/misc/hermes-agent/files/patch-tools_lazy__deps.py @@ -1,13 +1,13 @@ ---- tools/lazy_deps.py.orig 2026-08-03 16:57:23 UTC +--- tools/lazy_deps.py.orig 2026-08-31 19:29:27 UTC +++ tools/lazy_deps.py -@@ -514,7 +514,9 @@ def _allow_lazy_installs() -> bool: +@@ -524,7 +524,9 @@ def _allow_lazy_installs() -> bool: cfg = None if cfg is not None: sec = cfg.get("security") or {} - if not bool(sec.get("allow_lazy_installs", True)): + # FreeBSD packages own their deps; users can still opt in. + default = not sys.platform.startswith("freebsd") + if not bool(sec.get("allow_lazy_installs", default)): return False # (2) Sealed-venv env var: blocks ONLY when there is no safe durable