From 621975de859763d0a87a5b001c0c04aaa6e15460 Mon Sep 17 00:00:00 2001 From: Sora-bluesky Date: Thu, 23 Jul 2026 06:51:20 +0900 Subject: [PATCH] fix(dashboard): anchor the SSH token dir to $HOME/.hermes, not the active profile The Desktop client writes the SSH session token under $HOME/.hermes/desktop-ssh (a literal ~/.hermes/desktop-ssh in apps/desktop/electron/remote-lifecycle.ts, expanded against the account's $HOME), independent of HERMES_HOME and the active profile. But _read_ssh_session_token_file validated it against get_hermes_home()/desktop-ssh, which a non-default sticky profile re-homes to /profiles//desktop-ssh (and any custom HERMES_HOME points elsewhere). relative_to() then rejects every token as "not under the desktop-ssh directory", so SSH remote mode is broken under any non-default profile. Anchor to Path.home()/.hermes/desktop-ssh so the validator matches the exact directory the client writes to, across default, profile, and Docker layouts. Adds profile / custom-root acceptance tests and a profile-local rejection test. Fixes #69551. Co-Authored-By: Claude Opus 4.8 --- hermes_cli/main.py | 10 ++- .../test_ssh_session_token_parser.py | 84 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0f85fd5d34..5aa979f17f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -10047,13 +10047,19 @@ def _read_ssh_session_token_file(path: str) -> str: import stat as _stat from pathlib import Path as _Path - from hermes_constants import get_hermes_home as _get_hermes_home if not os.path.isabs(path): raise SystemExit("--ssh-session-token-file must be absolute") token_path = _Path(path) - token_root = _get_hermes_home() / "desktop-ssh" + # The Desktop client writes the token under $HOME/.hermes/desktop-ssh: a + # literal "~/.hermes/desktop-ssh" in apps/desktop/electron/remote-lifecycle.ts + # expanded against the account's $HOME, independent of HERMES_HOME and the + # active profile. Anchor validation to that same OS-home path, NOT to + # get_hermes_home(): a non-default sticky profile (or any HERMES_HOME pointing + # elsewhere, e.g. a Docker /opt/data root) re-homes get_hermes_home() and + # would otherwise reject every token the client legitimately wrote (#69551). + token_root = _Path.home() / ".hermes" / "desktop-ssh" try: relative = token_path.relative_to(token_root) except ValueError as exc: diff --git a/tests/hermes_cli/test_ssh_session_token_parser.py b/tests/hermes_cli/test_ssh_session_token_parser.py index 7e7499f78d..de7ea87b70 100644 --- a/tests/hermes_cli/test_ssh_session_token_parser.py +++ b/tests/hermes_cli/test_ssh_session_token_parser.py @@ -32,11 +32,80 @@ def test_serve_help_advertises_secure_ssh_bootstrap_flags(capsys): +@pytest.mark.skipif( + os.name == "nt", + reason="POSIX fixture uses mode bits; Windows read_token requires protected DACLs", +) +def test_token_file_is_read_and_unlinked_through_private_directory(tmp_path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + hermes_home = home / ".hermes" + token_dir = hermes_home / "desktop-ssh" / ("a" * 32) + token_dir.mkdir(parents=True, mode=0o700) + token_path = token_dir / "0123456789abcdef.token" + token_path.write_text("b" * 64) + token_path.chmod(0o600) + override = set_hermes_home_override(hermes_home) + try: + assert _read_ssh_session_token_file(str(token_path)) == "b" * 64 + assert not token_path.exists() + finally: + reset_hermes_home_override(override) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX desktop-ssh token path") +def test_token_anchor_is_os_home_not_active_profile(tmp_path, monkeypatch): + """Regression for #69551: the Desktop client always writes the token under + ``$HOME/.hermes/desktop-ssh`` (a literal ``~/.hermes/desktop-ssh`` in + apps/desktop/electron/remote-lifecycle.ts, expanded against the account's + $HOME). A non-default sticky profile re-homes ``get_hermes_home()`` to + ``/profiles/``, and a Docker-style ``HERMES_HOME`` can point + elsewhere entirely — neither must move the validator off + ``$HOME/.hermes/desktop-ssh``, or the token is wrongly rejected.""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + token_dir = home / ".hermes" / "desktop-ssh" / ("a" * 32) + token_dir.mkdir(parents=True, mode=0o700) + token_path = token_dir / "0123456789abcdef.token" + + # A sticky profile and a custom (Docker) root both point get_hermes_home() + # away from $HOME/.hermes; the anchor must ignore both. + for elsewhere in (home / ".hermes" / "profiles" / "coder", tmp_path / "opt" / "data"): + token_path.write_text("b" * 64) + token_path.chmod(0o600) + override = set_hermes_home_override(elsewhere) + try: + assert _read_ssh_session_token_file(str(token_path)) == "b" * 64 + assert not token_path.exists() + finally: + reset_hermes_home_override(override) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX desktop-ssh token path") +def test_token_under_profile_desktop_ssh_is_rejected(tmp_path, monkeypatch): + """The client never writes under a profile-scoped desktop-ssh dir, so a token + placed there must be rejected even while that profile is active — proving the + anchor is the OS home, not the active profile (#69551).""" + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + profile_home = home / ".hermes" / "profiles" / "coder" + token_dir = profile_home / "desktop-ssh" / ("a" * 32) + token_dir.mkdir(parents=True, mode=0o700) + token_path = token_dir / "0123456789abcdef.token" + token_path.write_text("b" * 64) + token_path.chmod(0o600) + override = set_hermes_home_override(profile_home) + try: + with pytest.raises(SystemExit, match="desktop-ssh directory"): + _read_ssh_session_token_file(str(token_path)) + finally: + reset_hermes_home_override(override) @pytest.mark.skipif(os.name == "nt", reason="POSIX symlink contract") def test_token_file_rejects_symlink(tmp_path, monkeypatch): home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) token_dir = home / ".hermes" / "desktop-ssh" / ("a" * 32) token_dir.mkdir(parents=True, mode=0o700) target = tmp_path / "token" @@ -54,3 +123,18 @@ def test_token_file_rejects_symlink(tmp_path, monkeypatch): reset_hermes_home_override(override) +def test_token_file_rejects_parent_escape(tmp_path, monkeypatch): + home = tmp_path / "home" + monkeypatch.setenv("HOME", str(home)) + token_root = home / ".hermes" / "desktop-ssh" + token_root.mkdir(parents=True, mode=0o700) + escaped = token_root.parent / "0123456789abcdef.token" + escaped.write_text("b" * 64) + escaped.chmod(0o600) + override = set_hermes_home_override(home / ".hermes") + try: + with pytest.raises(SystemExit, match="invalid runtime path"): + _read_ssh_session_token_file(str(token_root / ".." / escaped.name)) + assert escaped.exists() + finally: + reset_hermes_home_override(override)