Skip to content
Merged
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
19 changes: 16 additions & 3 deletions api/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,14 @@ def _remote_terminal_cwd() -> str | None:


def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
"""Return a non-stat'ed target-side Path when it is under terminal.cwd."""
"""Return a non-stat'ed target-side Path when it is under terminal.cwd.

Remote workspace paths live on the target host (e.g., remote SSH/Docker
backend). For valid target-side POSIX paths under ``terminal.cwd``, the
normalized target path is preserved as a ``Path`` object without invoking
local host-filesystem resolution (avoiding host-specific firmlink rewriting
such as macOS synthetic ``/home`` -> ``/System/Volumes/Data/home``).
"""
cwd = _remote_terminal_cwd()
if not cwd:
return None
Expand All @@ -183,7 +190,7 @@ def _remote_terminal_workspace_candidate(path: str | Path) -> Path | None:
if _is_blocked_workspace_path(Path(normalized_raw), normalized_raw) or _is_blocked_workspace_path(Path(normalized_cwd), normalized_cwd):
return None
if posix_candidate == posix_base or _posix_is_within(posix_candidate, posix_base):
return _resolve_path(normalized_raw)
return Path(normalized_raw)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
return None
candidate = _resolve_path(raw)
base = _resolve_path(cwd)
Expand Down Expand Up @@ -246,6 +253,8 @@ def _profile_default_workspace() -> str:

def _clean_workspace_list(workspaces: list) -> list:
"""Sanitize a workspace list:
- Preserve target-side remote terminal workspace paths (SSH/Docker) without
resolving them against the local WebUI host filesystem.
- Preserve saved paths even when they are currently missing or inaccessible;
picker state must not be destroyed by a transient stat/permission failure.
- Remove entries whose paths live inside another profile's directory
Expand All @@ -261,7 +270,11 @@ def _clean_workspace_list(workspaces: list) -> list:
name = w.get('name', '')
if not path:
continue
p = _safe_resolve(_expanduser_path(path))
remote_cand = _remote_terminal_workspace_candidate(path)
if remote_cand is not None:
p = remote_cand
else:
p = _safe_resolve(_expanduser_path(path))
# Skip paths inside a DIFFERENT profile's directory (cross-profile leak).
# Allow paths inside the CURRENT profile's own directory (e.g. test workspaces
# created under ~/.hermes/profiles/webui/webui-mvp-test/).
Expand Down
27 changes: 27 additions & 0 deletions tests/test_remote_terminal_workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,30 @@ def test_remote_terminal_workspace_rejects_embedded_nullbyte_in_cwd(monkeypatch)
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"}
]

Loading