diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index c02fb8cca16d..fb7548bbc827 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -100,7 +100,9 @@ def remove_wrapper_script(): """Remove the hermes wrapper script if it exists.""" wrapper_paths = [ Path.home() / ".local" / "bin" / "hermes", + Path.home() / ".local" / "bin" / "hermes-acp", Path("/usr/local/bin/hermes"), + Path("/usr/local/bin/hermes-acp"), ] removed = [] diff --git a/scripts/install.sh b/scripts/install.sh index df8739d62cd5..4449fb9151f1 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1677,6 +1677,31 @@ EOF chmod +x "$command_link_dir/hermes" log_success "Installed hermes launcher → $command_link_display_dir/hermes" + # Also expose `hermes-acp`. ACP hosts (Zed, JetBrains, Buzz) resolve the + # agent by command name on the login-shell PATH, and the `hermes-acp` + # console script lives inside the venv, which is not on that PATH. Without + # this launcher those hosts report Hermes as not installed. (#21454 applies + # here too: clear the path first so `cat >` cannot follow an old symlink + # into the venv and overwrite the console script.) + rm -f "$command_link_dir/hermes-acp" + if [ "$USE_VENV" = true ]; then + cat > "$command_link_dir/hermes-acp" < "$command_link_dir/hermes-acp" < str: + match = ACP_BLOCK.search(INSTALL_SH.read_text(encoding="utf-8")) + assert match, ( + "could not locate the hermes-acp launcher block in scripts/install.sh — " + "if it was renamed, update this test with it" + ) + return match.group(1) + + +def _run_block(tmp_path: Path, use_venv: str) -> Path: + """Execute the extracted block with the env vars setup_path() sets.""" + command_link_dir = tmp_path / "local_bin" + command_link_dir.mkdir() + hermes_bin = tmp_path / "venv" / "bin" / "python" + hermes_bin.parent.mkdir(parents=True) + hermes_bin.write_text("#!/bin/sh\n", encoding="utf-8") + entrypoint = tmp_path / "hermes" + entrypoint.write_text("#!/bin/sh\n", encoding="utf-8") + + script = ( + "set -e\n" + f"HERMES_BIN={hermes_bin}\n" + f"HERMES_ENTRYPOINT={entrypoint}\n" + f"command_link_dir={command_link_dir}\n" + f"command_link_display_dir={command_link_dir}\n" + f"USE_VENV={use_venv}\n" + "log_success(){ :; }\n" + _extract_acp_shim_block() + ) + result = subprocess.run( + ["bash", "-c", script], + capture_output=True, + text=True, + cwd=tmp_path, + ) + assert result.returncode == 0, ( + f"acp shim block failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + return command_link_dir / "hermes-acp" + + +def test_venv_install_writes_executable_acp_launcher(tmp_path): + shim = _run_block(tmp_path, "true") + assert shim.is_file() + assert shim.stat().st_mode & stat.S_IXUSR, "launcher must be user-executable" + + text = shim.read_text(encoding="utf-8") + assert "unset PYTHONPATH" in text + assert "unset PYTHONHOME" in text + # The launcher must dispatch to the ACP subcommand, otherwise the host gets + # an interactive CLI on stdio and the handshake never completes. + assert re.search(r'exec .*\bacp\b', text), text + + +def test_non_venv_install_writes_acp_launcher(tmp_path): + shim = _run_block(tmp_path, "false") + text = shim.read_text(encoding="utf-8") + assert re.search(r'exec .*\bacp\b', text), text + + +def test_acp_launcher_does_not_follow_a_symlink_into_the_venv(tmp_path): + """Guards the #21454 failure mode for the new launcher. + + An older install could leave `hermes-acp` as a symlink to the venv console + script. Without the `rm -f`, `cat >` follows it and overwrites that script + with a shim that then execs itself. + """ + command_link_dir = tmp_path / "local_bin" + command_link_dir.mkdir() + 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") + + shim_path = command_link_dir / "hermes-acp" + shim_path.symlink_to(console_script) + assert shim_path.is_symlink() + + hermes_bin = tmp_path / "venv" / "bin" / "python" + hermes_bin.write_text("#!/bin/sh\n", encoding="utf-8") + entrypoint = tmp_path / "hermes" + entrypoint.write_text("#!/bin/sh\n", encoding="utf-8") + + script = ( + "set -e\n" + f"HERMES_BIN={hermes_bin}\n" + f"HERMES_ENTRYPOINT={entrypoint}\n" + f"command_link_dir={command_link_dir}\n" + f"command_link_display_dir={command_link_dir}\n" + "USE_VENV=true\n" + "log_success(){ :; }\n" + _extract_acp_shim_block() + ) + result = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True, cwd=tmp_path + ) + assert result.returncode == 0, result.stderr + + assert console_script.read_text(encoding="utf-8") == marker, ( + "venv/bin/hermes-acp was overwritten — symlink-stomp regression (#21454)" + ) + assert not shim_path.is_symlink(), ( + "command_link_dir/hermes-acp must be replaced with a regular file" + )