* fix(workspace): preserve remote POSIX paths without macOS synthetic resolution (#7131) When hermes-webui runs on macOS with a remote terminal profile pointing to a Linux host (e.g. terminal.cwd='/home/<user>'), path resolution previously invoked Path.resolve(), expanding '/home' via macOS synthetic firmlinks to '/System/Volumes/Data/home/<user>' and failing validation. - Return Path(normalized_raw) directly from _remote_terminal_workspace_candidate - Check _remote_terminal_workspace_candidate in _clean_workspace_list before local _safe_resolve - Add unit test covering Linux /home/<user> remote paths without macOS expansion * ci: trigger required checks (workflow did not run on fork head) --------- Co-authored-by: n <a@n>
175 lines
6.7 KiB
Python
175 lines
6.7 KiB
Python
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from api import config as api_config
|
|
from api import workspace
|
|
|
|
|
|
REMOTE_CWD = "/Users/joeyshiue"
|
|
|
|
|
|
def _remote_config(**overrides):
|
|
cfg = {"terminal": {"backend": "ssh", "cwd": REMOTE_CWD}}
|
|
cfg.update(overrides)
|
|
return cfg
|
|
|
|
|
|
def test_remote_terminal_cwd_is_profile_default_without_local_stat(monkeypatch, tmp_path):
|
|
fallback = tmp_path / "fallback"
|
|
fallback.mkdir()
|
|
|
|
monkeypatch.setattr(api_config, "DEFAULT_WORKSPACE", fallback)
|
|
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
|
|
|
|
assert workspace._profile_default_workspace() == REMOTE_CWD
|
|
|
|
|
|
def test_remote_terminal_last_workspace_ignores_stale_local_path(monkeypatch, tmp_path):
|
|
stale_local = tmp_path / "stale-local"
|
|
stale_local.mkdir()
|
|
last_workspace = tmp_path / "last_workspace.txt"
|
|
last_workspace.write_text(str(stale_local), encoding="utf-8")
|
|
|
|
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
|
|
monkeypatch.setattr(workspace, "_last_workspace_file", lambda: last_workspace)
|
|
monkeypatch.setattr(workspace, "_GLOBAL_LW_FILE", tmp_path / "missing-global-last-workspace.txt")
|
|
|
|
assert workspace.get_last_workspace() == REMOTE_CWD
|
|
|
|
|
|
def test_remote_terminal_workspace_paths_under_cwd_do_not_require_local_existence(monkeypatch):
|
|
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
|
|
|
|
target_side_project = f"{REMOTE_CWD}/projects/demo"
|
|
|
|
assert workspace.validate_workspace_to_add(target_side_project) == Path(target_side_project).resolve()
|
|
assert workspace.resolve_trusted_workspace(target_side_project) == Path(target_side_project).resolve()
|
|
|
|
|
|
def test_remote_terminal_workspace_paths_outside_cwd_still_reject(monkeypatch):
|
|
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
|
|
|
|
with pytest.raises(ValueError, match="Path does not exist"):
|
|
workspace.validate_workspace_to_add("/Users/other/projects/demo")
|
|
|
|
with pytest.raises(ValueError, match="Path does not exist"):
|
|
workspace.resolve_trusted_workspace("/Users/other/projects/demo")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"terminal_cfg",
|
|
[
|
|
pytest.param({"backend": "ssh", "cwd": REMOTE_CWD}, id="cwd-absolute"),
|
|
pytest.param({"backend": "ssh"}, id="cwd-omitted"),
|
|
pytest.param({"backend": "ssh", "cwd": ""}, id="cwd-empty"),
|
|
pytest.param({"backend": "ssh", "cwd": "."}, id="cwd-dot"),
|
|
],
|
|
)
|
|
def test_remote_terminal_implicit_recovery_does_not_use_local_missing_path(
|
|
monkeypatch, tmp_path, terminal_cfg
|
|
):
|
|
fallback_path = tmp_path / "fallback"
|
|
fallback_path.mkdir()
|
|
monkeypatch.setattr(
|
|
api_config,
|
|
"get_config",
|
|
lambda: _remote_config(terminal=terminal_cfg),
|
|
)
|
|
monkeypatch.setattr(workspace, "_home_path", lambda: tmp_path)
|
|
fallback_calls = {"count": 0}
|
|
|
|
def fallback():
|
|
fallback_calls["count"] += 1
|
|
return fallback_path
|
|
|
|
with pytest.raises(ValueError, match="Path does not exist"):
|
|
workspace.resolve_implicit_workspace_with_recovery(
|
|
"/Users/other/projects/demo",
|
|
fallback,
|
|
)
|
|
|
|
assert fallback_calls["count"] == 0
|
|
|
|
|
|
def test_remote_terminal_workspace_paths_with_parent_escape_still_reject(monkeypatch):
|
|
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
|
|
|
|
escaped = f"{REMOTE_CWD}/../other/projects/demo"
|
|
|
|
with pytest.raises(ValueError, match="Path does not exist"):
|
|
workspace.validate_workspace_to_add(escaped)
|
|
|
|
with pytest.raises(ValueError, match="Path does not exist"):
|
|
workspace.resolve_trusted_workspace(escaped)
|
|
|
|
|
|
@pytest.mark.parametrize("workspace_path", ["/etc", "/etc/ssh"])
|
|
def test_remote_terminal_workspace_system_roots_still_reject(monkeypatch, workspace_path):
|
|
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config(terminal={"backend": "ssh", "cwd": "/etc"}))
|
|
|
|
with pytest.raises(ValueError, match="Path points to a system directory"):
|
|
workspace.validate_workspace_to_add(workspace_path)
|
|
|
|
with pytest.raises(ValueError, match="Path points to a system directory"):
|
|
workspace.resolve_trusted_workspace(workspace_path)
|
|
|
|
|
|
@pytest.mark.parametrize("validator", [workspace.resolve_trusted_workspace, workspace.validate_workspace_to_add])
|
|
def test_var_home_workspaces_stay_allowed_before_system_root_blocklist(monkeypatch, validator):
|
|
home = Path("/var/home/joeyshiue")
|
|
candidate = home / "projects/demo"
|
|
|
|
monkeypatch.setattr(workspace, "_resolve_path", lambda raw: candidate if str(raw) == str(candidate) else Path(raw))
|
|
monkeypatch.setattr(workspace, "_home_path", lambda: home)
|
|
monkeypatch.setattr(workspace, "_workspace_access_error", lambda _candidate: None)
|
|
|
|
assert validator(str(candidate)) == candidate
|
|
|
|
|
|
def test_remote_terminal_workspace_rejects_embedded_nullbyte_in_raw_path(monkeypatch):
|
|
"""Embedded null bytes in remote workspace path should be rejected."""
|
|
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config())
|
|
|
|
# Path with embedded null byte
|
|
nullbyte_path = f"{REMOTE_CWD}/projects\x00/demo"
|
|
|
|
assert workspace._remote_terminal_workspace_candidate(nullbyte_path) is None
|
|
|
|
|
|
def test_remote_terminal_workspace_rejects_embedded_nullbyte_in_cwd(monkeypatch):
|
|
"""Embedded null bytes in remote terminal cwd should be rejected."""
|
|
monkeypatch.setattr(api_config, "get_config", lambda: _remote_config(terminal={"backend": "ssh", "cwd": f"{REMOTE_CWD}\x00/malicious"}))
|
|
|
|
# Normal path, but remote cwd contains null byte
|
|
normal_path = f"{REMOTE_CWD}/projects/demo"
|
|
|
|
assert workspace._remote_terminal_workspace_candidate(normal_path) is None
|
|
|
|
|
|
def test_remote_terminal_linux_home_preserves_path_without_macos_synthetic_resolution(monkeypatch):
|
|
"""Remote Linux /home/<user> paths must not resolve to /System/Volumes/Data/home/<user> on macOS."""
|
|
monkeypatch.setattr(
|
|
api_config,
|
|
"get_config",
|
|
lambda: _remote_config(terminal={"backend": "ssh", "cwd": "/home/developer"}),
|
|
)
|
|
|
|
real_resolve = workspace._resolve_path
|
|
|
|
def fake_resolve(p):
|
|
p_str = str(p)
|
|
if p_str == "/home/developer" or p_str.startswith("/home/developer/"):
|
|
return Path(f"/System/Volumes/Data{p_str}")
|
|
return real_resolve(p)
|
|
|
|
monkeypatch.setattr(workspace, "_resolve_path", fake_resolve)
|
|
|
|
assert workspace.validate_workspace_to_add("/home/developer") == Path("/home/developer")
|
|
assert workspace.resolve_trusted_workspace("/home/developer") == Path("/home/developer")
|
|
assert workspace.get_profile_default_workspace() == "/home/developer"
|
|
assert workspace._clean_workspace_list([{"path": "/home/developer", "name": "Dev"}]) == [
|
|
{"path": "/home/developer", "name": "Dev"}
|
|
]
|
|
|