feat(update): self-heal the hermes-acp launcher + document Buzz Desktop as an ACP host

Existing installs predate the install.sh hermes-acp launcher, and hermes
update never re-runs setup_path, so ACP hosts (Zed, JetBrains, Buzz)
still resolve Hermes as unavailable until a reinstall. _ensure_acp_launcher()
writes the launcher next to an existing hermes command in ~/.local/bin or
/usr/local/bin during hermes update — delegating to the sibling launcher so
it is correct for every install layout. Never follows symlinks (#21454),
skips unwritable dirs, no-op on Windows (venv Scripts is already on PATH).

Docs: add a Buzz Desktop section to the ACP page (en + zh-Hans).
This commit is contained in:
Teknium 2026-07-28 10:15:13 -07:00
parent a7d5147cf1
commit 38cd253abd
4 changed files with 211 additions and 0 deletions

View file

@ -10986,6 +10986,54 @@ def _ensure_fhs_path_guard() -> None:
print(" (reload your shell or run 'source ~/.bashrc' to pick it up)")
def _ensure_acp_launcher() -> None:
"""Self-heal: install a ``hermes-acp`` launcher next to the ``hermes`` one.
Mirrors the launcher block in ``scripts/install.sh`` so existing installs
gain the ACP command on ``hermes update`` without a reinstall. ACP hosts
(Zed, JetBrains, Buzz Desktop) spawn the agent by resolving the
``hermes-acp`` command name against the login-shell PATH; the console
script of that name lives inside the install's venv, which is not on that
PATH, so those hosts report Hermes as not installed even when it is.
The shim simply delegates to the sibling ``hermes`` launcher with the
``acp`` subcommand, which makes it correct for every install layout
(venv wrapper, FHS symlink, pipx/pip console script) without having to
reconstruct interpreter/entrypoint paths.
No-op on Windows (install.ps1 puts ``venv\\Scripts`` on the user PATH, so
``hermes-acp.exe`` already resolves) and wherever a ``hermes-acp`` is
already present next to the ``hermes`` command. Unwritable directories
(e.g. ``/usr/local/bin`` as non-root) are skipped silently. Idempotent.
"""
if sys.platform == "win32":
return
for bin_dir in (Path.home() / ".local" / "bin", Path("/usr/local/bin")):
hermes_cmd = bin_dir / "hermes"
acp_cmd = bin_dir / "hermes-acp"
try:
if not (hermes_cmd.is_file() or hermes_cmd.is_symlink()):
continue
# Already present — a console script (pip/pipx install), an
# earlier shim, or a symlink. is_symlink() catches broken
# symlinks that exists() would miss; never follow-and-overwrite
# (the #21454 failure mode).
if acp_cmd.exists() or acp_cmd.is_symlink():
continue
shim = (
"#!/usr/bin/env bash\n"
"# Hermes Agent — ACP launcher (written by `hermes update`).\n"
"# ACP hosts (Zed, JetBrains, Buzz) resolve the agent by this\n"
"# command name on the login-shell PATH.\n"
f'exec "{hermes_cmd}" acp "$@"\n'
)
acp_cmd.write_text(shim, encoding="utf-8")
acp_cmd.chmod(acp_cmd.stat().st_mode | 0o755)
except OSError:
continue
print(f" ✓ Installed hermes-acp launcher → {acp_cmd}")
def _size_delta_label(saved_mb: float) -> str:
"""Human label for a before/after database size delta, in MB.
@ -12916,6 +12964,14 @@ def _cmd_update_impl(args, gateway_mode: bool):
except Exception as e:
logger.debug("FHS PATH guard check failed: %s", e)
# Self-heal the hermes-acp launcher for installs that predate it, so
# ACP hosts (Zed, JetBrains, Buzz) can resolve Hermes on PATH without
# a reinstall. No-op on Windows and when already present.
try:
_ensure_acp_launcher()
except Exception as e:
logger.debug("hermes-acp launcher self-heal failed: %s", e)
# Refresh the cua-driver binary used by the Computer Use toolset.
# The upstream installer is gated on supported platforms and on the
# binary already being on PATH, so this is a no-op for users who

View file

@ -0,0 +1,121 @@
"""`hermes update` must self-heal the ``hermes-acp`` launcher.
ACP hosts (Zed, JetBrains, Buzz Desktop) resolve the agent by the
``hermes-acp`` command name on the login-shell PATH. Fresh installs get the
launcher from ``scripts/install.sh``; existing installs get it from
``_ensure_acp_launcher()`` during ``hermes update``.
"""
import os
import stat
from pathlib import Path
from unittest.mock import patch
import pytest
from hermes_cli.main import _ensure_acp_launcher
@pytest.fixture
def fake_home(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
bin_dir = tmp_path / ".local" / "bin"
bin_dir.mkdir(parents=True)
return bin_dir
def test_writes_launcher_next_to_hermes(fake_home):
hermes = fake_home / "hermes"
hermes.write_text("#!/usr/bin/env bash\nexec true\n", encoding="utf-8")
hermes.chmod(0o755)
_ensure_acp_launcher()
acp = fake_home / "hermes-acp"
assert acp.is_file()
assert acp.stat().st_mode & stat.S_IXUSR
text = acp.read_text(encoding="utf-8")
# Delegates to the sibling `hermes` launcher with the acp subcommand.
assert f'exec "{hermes}" acp "$@"' in text
def test_noop_without_hermes_launcher(fake_home):
_ensure_acp_launcher()
assert not (fake_home / "hermes-acp").exists()
def test_does_not_overwrite_existing_command(fake_home):
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
existing = fake_home / "hermes-acp"
marker = "#!/usr/bin/env python\n# real console script\n"
existing.write_text(marker, encoding="utf-8")
_ensure_acp_launcher()
assert existing.read_text(encoding="utf-8") == marker
def test_does_not_follow_symlink_into_venv(fake_home, tmp_path):
"""#21454 failure mode: never write through a symlinked hermes-acp."""
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
console_script = tmp_path / "venv" / "bin" / "hermes-acp"
console_script.parent.mkdir(parents=True)
marker = "#!/usr/bin/env python\n# real console script\n"
console_script.write_text(marker, encoding="utf-8")
(fake_home / "hermes-acp").symlink_to(console_script)
_ensure_acp_launcher()
assert console_script.read_text(encoding="utf-8") == marker
assert (fake_home / "hermes-acp").is_symlink()
def test_skips_broken_symlink(fake_home, tmp_path):
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
dangling = fake_home / "hermes-acp"
dangling.symlink_to(tmp_path / "gone")
_ensure_acp_launcher()
assert dangling.is_symlink()
assert not dangling.exists()
def test_symlinked_hermes_counts_as_present(fake_home, tmp_path):
"""FHS-style installs symlink ~/.local/bin/hermes — still eligible."""
real = tmp_path / "real-hermes"
real.write_text("#!/bin/sh\n", encoding="utf-8")
(fake_home / "hermes").symlink_to(real)
_ensure_acp_launcher()
assert (fake_home / "hermes-acp").is_file()
def test_idempotent(fake_home):
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
_ensure_acp_launcher()
first = (fake_home / "hermes-acp").read_text(encoding="utf-8")
_ensure_acp_launcher()
assert (fake_home / "hermes-acp").read_text(encoding="utf-8") == first
def test_noop_on_windows(fake_home):
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
with patch("hermes_cli.main.sys") as fake_sys:
fake_sys.platform = "win32"
_ensure_acp_launcher()
assert not (fake_home / "hermes-acp").exists()
def test_unwritable_bin_dir_is_skipped(fake_home):
(fake_home / "hermes").write_text("#!/bin/sh\n", encoding="utf-8")
if os.geteuid() == 0:
pytest.skip("root ignores directory write permissions")
fake_home.chmod(0o555)
try:
_ensure_acp_launcher() # must not raise
assert not (fake_home / "hermes-acp").exists()
finally:
fake_home.chmod(0o755)

View file

@ -145,6 +145,24 @@ Prerequisites:
Use an ACP-compatible plugin and point it at `hermes acp` or `hermes-acp`.
### Buzz Desktop
[Buzz](https://github.com/block/buzz) ships Hermes Agent as a preset runtime.
With Hermes installed the normal way, Buzz discovers it automatically —
open **Settings → Runtimes** and Hermes appears under your runtimes.
If discovery fails (older installs), make sure the ACP launcher resolves on a
login-shell PATH:
```bash
command -v hermes-acp || command -v hermes
```
Recent installs write both `hermes` and `hermes-acp` launchers into
`~/.local/bin`; running `hermes update` adds the `hermes-acp` launcher to
older installs. As a manual fallback, configure Buzz's agent command as
`hermes` with args `["acp"]`.
## Configuration and credentials
ACP mode uses the same Hermes configuration as the CLI:

View file

@ -143,6 +143,22 @@ hermes acp --setup-browser --yes # 非交互式接受下载
使用兼容 ACP 的插件并将其指向 `hermes acp``hermes-acp`
### Buzz Desktop
[Buzz](https://github.com/block/buzz) 将 Hermes Agent 作为预设运行时提供。
按常规方式安装 Hermes 后Buzz 会自动发现它 —— 打开 **Settings → Runtimes**
Hermes 就会出现在你的运行时列表中。
如果发现失败(较旧的安装),请确认 ACP 启动器可以在登录 shell 的 PATH 上解析:
```bash
command -v hermes-acp || command -v hermes
```
较新的安装会将 `hermes``hermes-acp` 两个启动器写入 `~/.local/bin`
运行 `hermes update` 会为较旧的安装补上 `hermes-acp` 启动器。作为手动兜底方案,
可以将 Buzz 的 agent 命令配置为 `hermes`,参数为 `["acp"]`
## 配置与凭据
ACP 模式使用与 CLI 相同的 Hermes 配置: