Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2749,10 +2749,12 @@ def _termux_example_image_path(filename: str = "cat.png") -> str:
"/storage/emulated/0",
"/storage/self/primary",
]
# Termux/Android roots are POSIX paths — join with literal forward
# slashes so the hint stays correct even when this renders on Windows.
for root in candidates:
if os.path.isdir(root):
return os.path.join(root, "Pictures", filename)
return os.path.join("~/storage/shared", "Pictures", filename)
return f"{root}/Pictures/{filename}"
return f"~/storage/shared/Pictures/{filename}"


def _split_path_input(raw: str) -> tuple[str, str]:
Expand Down Expand Up @@ -2823,6 +2825,16 @@ def _resolve_attachment_path(raw_path: str) -> Path | None:
expanded = unquote(parsed.path or "")
if parsed.netloc and os.name == "nt":
expanded = f"//{parsed.netloc}{expanded}"
elif (
os.name == "nt"
and len(expanded) >= 3
and expanded[0] == "/"
and expanded[1].isalpha()
and expanded[2] == ":"
):
# file:///C:/... parses to path "/C:/..." — drop the
# leading slash so it resolves as a drive-letter path.
expanded = expanded[1:]
except Exception:
expanded = token
expanded = os.path.expandvars(os.path.expanduser(expanded))
Expand Down
7 changes: 5 additions & 2 deletions gateway/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,12 @@ def _command_line_belongs_to_profile(command: str, profile_home: Path) -> bool:
explicit ``HERMES_HOME=<path>``) on its argv; the default/root gateway runs
bare with no profile flag.
"""
command_lc = command.lower()
# Normalize separators before the substring match: on Windows,
# str(Path) renders backslashes while a HERMES_HOME= value on the argv
# may carry forward slashes (Git Bash, JSON configs) — and vice versa.
command_lc = command.lower().replace("\\", "/")
profile_name = _profile_name_for_home(profile_home)
home_lc = str(profile_home).lower()
home_lc = str(profile_home).lower().replace("\\", "/")

if profile_name is not None and profile_name != "default":
profile_lc = profile_name.lower()
Expand Down
9 changes: 8 additions & 1 deletion hermes_cli/banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,14 @@ def cprint(text: str):
"""Print ANSI-colored text through prompt_toolkit's renderer."""
from prompt_toolkit import print_formatted_text as _pt_print
from prompt_toolkit.formatted_text import ANSI as _PT_ANSI

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a focused regression test that makes print_formatted_text raise and verifies this fallback emits through plain print; no banner test is changed by this PR.

_pt_print(_PT_ANSI(text))
try:
_pt_print(_PT_ANSI(text))
except Exception:
# prompt_toolkit needs a real console. On Windows, a redirected or
# absent stdout (pythonw.exe, CI, `hermes ... > file`) raises
# NoConsoleScreenBufferError from its Win32Output — display helpers
# must never crash the caller over that, so degrade to plain print.
print(text)


# =========================================================================
Expand Down
6 changes: 5 additions & 1 deletion hermes_cli/browser_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import os
import platform
import posixpath
import shlex
import shutil
import subprocess
Expand Down Expand Up @@ -90,7 +91,10 @@ def add_windows_install_paths(
for _, group in install_groups:
for base in filter(None, bases):
for parts in group:
add(os.path.join(base, *parts))
# Only called with WSL ``/mnt/c/...`` bases — those are
# POSIX paths regardless of the host OS, so join with
# posixpath (os.path.join would emit backslashes on nt).
add(posixpath.join(base, *parts))

if system == "Darwin":
for app in _DARWIN_APPS:
Expand Down
6 changes: 4 additions & 2 deletions hermes_cli/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,15 +346,17 @@ def _scan_gateway_pids(
looks_like_gateway_runtime_command_line,
)
current_home = str(get_hermes_home().resolve())
current_home_lc = current_home.lower()
# Forward slashes on both sides of the HERMES_HOME= match — see
# gateway.status._command_line_belongs_to_profile, which this mirrors.
current_home_lc = current_home.lower().replace("\\", "/")
current_profile_arg = _profile_arg(current_home)
current_profile_name = (
current_profile_arg.split()[-1] if current_profile_arg else ""
)
current_profile_name_lc = current_profile_name.lower()

def _matches_current_profile(command: str) -> bool:
command_lc = command.lower()
command_lc = command.lower().replace("\\", "/")
if current_profile_name:
return (
f"--profile {current_profile_name_lc}" in command_lc
Expand Down
30 changes: 30 additions & 0 deletions tests/cli/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Shared fixtures for tests/cli.

Several test modules here build a HermesCLI via ``importlib.reload(cli)``
with prompt_toolkit stubbed out in ``sys.modules`` (the ``_make_cli``
pattern from test_cli_init.py). ``importlib.reload()`` re-executes cli.py
into the SAME module dict, so those MagicMock bindings (``_pt_print``,
``_PT_ANSI``, ...) survive the ``patch.dict`` context and silently break
any later test that needs cli's real prompt_toolkit machinery — e.g.
``cli._cprint`` output vanishes into a MagicMock and capsys sees nothing.

The autouse fixture below restores the real bindings at each module
boundary by re-reloading cli (with the real prompt_toolkit back in
sys.modules) whenever the pollution is detected.
"""

import importlib
import sys
from unittest.mock import MagicMock

import pytest


@pytest.fixture(autouse=True, scope="module")
def _unpollute_cli_module():
yield
cli_mod = sys.modules.get("cli")
if cli_mod is not None and isinstance(
getattr(cli_mod, "_pt_print", None), MagicMock
):
importlib.reload(cli_mod)
10 changes: 10 additions & 0 deletions tests/cli/test_cli_browser_connect.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,16 @@ def test_linux_candidates_include_official_brave_and_edge_stable_paths(self):

assert candidates == [brave, edge]

def test_wsl_install_candidates_keep_posix_separators_on_nt_host(self):
expected = "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"

with patch("hermes_cli.browser_connect.shutil.which", return_value=None), \
patch("hermes_cli.browser_connect.os.path.isfile", side_effect=lambda path: path == expected):
candidates = get_chrome_debug_candidates("Linux")

assert candidates == [expected]
assert "\\" not in candidates[0]

def test_launch_tries_next_browser_when_first_candidate_fails(self):
brave = "/usr/bin/brave-browser"
chrome = "/usr/bin/google-chrome"
Expand Down
31 changes: 31 additions & 0 deletions tests/cli/test_cli_file_drop.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
"""Tests for _detect_file_drop — file path detection that prevents
dragged/pasted absolute paths from being mistaken for slash commands."""

import os

import pytest

from cli import _detect_file_drop


def _can_symlink():
"""Check if we can create symlinks (needs admin/dev-mode on Windows)."""
import tempfile
from pathlib import Path
try:
with tempfile.TemporaryDirectory() as d:
src = Path(d) / "src"
src.write_text("x")
lnk = Path(d) / "lnk"
lnk.symlink_to(src)
return True
except OSError:
return False


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -206,12 +222,26 @@ def test_file_uri_image_path(self, tmp_image_with_spaces):
assert result["path"] == tmp_image_with_spaces
assert result["is_image"] is True

@pytest.mark.skipif(os.name != "nt", reason="Windows drive-letter URI contract")
def test_windows_drive_letter_file_uri_drops_url_leading_slash(self, tmp_path):
image = tmp_path / "drive-uri.png"
image.write_bytes(b"\x89PNG\r\n\x1a\n")
uri = image.as_uri()
assert uri.startswith("file:///") and ":/" in uri

result = _detect_file_drop(uri)

assert result is not None
assert result["path"] == image

def test_tilde_prefixed_path(self, tmp_path, monkeypatch):
home = tmp_path / "home"
img = home / "storage" / "shared" / "Pictures" / "cat.png"
img.parent.mkdir(parents=True, exist_ok=True)
img.write_bytes(b"\x89PNG\r\n\x1a\n")
monkeypatch.setenv("HOME", str(home))
# ntpath.expanduser ignores HOME (Python 3.8+) — it wants USERPROFILE.
monkeypatch.setenv("USERPROFILE", str(home))

result = _detect_file_drop("~/storage/shared/Pictures/cat.png what is this?")

Expand Down Expand Up @@ -241,6 +271,7 @@ def test_path_that_looks_like_command_but_is_file(self, tmp_path):
assert result is not None
assert result["is_image"] is False

@pytest.mark.skipif(not _can_symlink(), reason="Symlinks need elevated privileges")
def test_symlink_to_file(self, tmp_image, tmp_path):
link = tmp_path / "link.png"
link.symlink_to(tmp_image)
Expand Down
2 changes: 2 additions & 0 deletions tests/cli/test_cli_image_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def test_collect_query_images_supports_tilde_paths(self, tmp_path, monkeypatch):
home = tmp_path / "home"
img = _make_image(home / "storage" / "shared" / "Pictures" / "cat.png")
monkeypatch.setenv("HOME", str(home))
# ntpath.expanduser ignores HOME (Python 3.8+) — it wants USERPROFILE.
monkeypatch.setenv("USERPROFILE", str(home))

message, images = _collect_query_images("describe this", "~/storage/shared/Pictures/cat.png")

Expand Down
11 changes: 10 additions & 1 deletion tests/cli/test_resume_quiet_stderr.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,16 @@ def test_session_not_found_goes_to_stderr_in_quiet_mode(self, capsys):
assert "Session not found" in captured.err
assert "hermes sessions list" in captured.err

def test_session_not_found_goes_to_stdout_in_full_mode(self, capsys):
def test_session_not_found_goes_to_stdout_in_full_mode(self, capsys, monkeypatch):
# The full-mode path prints through prompt_toolkit, which caches its
# output object on the AppSession at first use. If an earlier test
# already created it bound to the real console (Win32Output writes
# via console API, invisible to capsys), this test sees empty stdout.
# Reset the cache so output creation happens under capsys.
from prompt_toolkit.application.current import get_app_session

monkeypatch.setattr(get_app_session(), "_output", None)

db = MagicMock()
db.get_session.return_value = None
cli = _make_cli(quiet=False, db=db)
Expand Down
15 changes: 15 additions & 0 deletions tests/cli/test_worktree.py
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,21 @@ def test_ten_concurrent_worktrees(self, git_repo):
assert not Path(info["path"]).exists()


def _can_symlink():
"""Check if we can create symlinks (needs admin/dev-mode on Windows)."""
import tempfile
try:
with tempfile.TemporaryDirectory() as d:
src = Path(d) / "src"
src.write_text("x")
lnk = Path(d) / "lnk"
lnk.symlink_to(src)
return True
except OSError:
return False


@pytest.mark.skipif(not _can_symlink(), reason="Symlinks need elevated privileges")
class TestWorktreeDirectorySymlink:
"""Test .worktreeinclude with directories (symlinked)."""

Expand Down
16 changes: 16 additions & 0 deletions tests/cli/test_worktree_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,20 @@
import pytest


def _can_symlink():
"""Check if we can create symlinks (needs admin/dev-mode on Windows)."""
import tempfile
try:
with tempfile.TemporaryDirectory() as d:
src = Path(d) / "src"
src.write_text("x")
lnk = Path(d) / "lnk"
lnk.symlink_to(src)
return True
except OSError:
return False


@pytest.fixture
def git_repo(tmp_path):
"""Create a temporary git repo for testing real cli._setup_worktree behavior."""
Expand Down Expand Up @@ -76,6 +90,7 @@ def test_rejects_parent_directory_directory_traversal(self, git_repo):
finally:
_force_remove_worktree(info)

@pytest.mark.skipif(not _can_symlink(), reason="Symlinks need elevated privileges")
def test_rejects_symlink_that_resolves_outside_repo(self, git_repo):
import cli as cli_mod

Expand Down Expand Up @@ -110,6 +125,7 @@ def test_allows_valid_file_include(self, git_repo):
finally:
_force_remove_worktree(info)

@pytest.mark.skipif(not _can_symlink(), reason="Symlinks need elevated privileges")
def test_allows_valid_directory_include(self, git_repo):
import cli as cli_mod

Expand Down
8 changes: 8 additions & 0 deletions tests/gateway/test_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,14 @@ def test_runtime_status_running_pid_accepts_matching_profile_cmdline(self, monke
== 139
), cmdline

def test_command_line_belongs_to_profile_normalizes_separators(self):
"""A Windows argv renders HERMES_HOME with backslashes while the
profile's Path may carry forward slashes (and, on Windows, vice
versa). The separator difference must not defeat the match."""
home = Path("c:/opt/data/profiles/coder")
cmdline = r"hermes_home=c:\opt\data\profiles\coder hermes gateway run --replace"
assert status._command_line_belongs_to_profile(cmdline, home) is True

def test_runtime_status_running_pid_default_profile_rejects_named_cmdline(self, monkeypatch):
"""The default/root profile runs a bare gateway (no profile flag). A
recycled PID now hosting a *named* profile gateway must not be reported
Expand Down
10 changes: 10 additions & 0 deletions tests/hermes_cli/test_banner.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@
import tools.mcp_tool


def test_cprint_falls_back_to_plain_print_when_prompt_toolkit_has_no_console(capsys):
with patch(
"prompt_toolkit.print_formatted_text",
side_effect=RuntimeError("no console screen buffer"),
):
banner.cprint("fallback text")

assert capsys.readouterr().out == "fallback text\n"


def test_display_toolset_name_strips_legacy_suffix():
assert banner._display_toolset_name("homeassistant_tools") == "homeassistant"
assert banner._display_toolset_name("honcho_tools") == "honcho"
Expand Down
7 changes: 5 additions & 2 deletions tests/tools/test_windows_native_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ def _reset_configured(self, monkeypatch):
yield
sys.modules.pop("hermes_cli.stdio", None)

def test_no_op_on_posix(self):
def test_no_op_on_posix(self, monkeypatch):
from hermes_cli import stdio

assert stdio.is_windows() is False
monkeypatch.setattr(stdio, "is_windows", lambda: False)
result = stdio.configure_windows_stdio()
assert result is False

Expand Down Expand Up @@ -285,6 +285,9 @@ def test_getattr_fallback_works_when_sigkill_missing(self, monkeypatch):
result = getattr(fake_signal, "SIGKILL", fake_signal.SIGTERM)
assert result == 15

@pytest.mark.skipif(
sys.platform == "win32", reason="signal.SIGKILL does not exist on Windows"
)
def test_getattr_fallback_prefers_sigkill_when_present(self):
"""On POSIX the fallback is a no-op: real SIGKILL wins."""
result = getattr(signal, "SIGKILL", signal.SIGTERM)
Expand Down
Loading