diff --git a/api/workspace.py b/api/workspace.py index 5a10868745b..12a4635646f 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -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 @@ -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) return None candidate = _resolve_path(raw) base = _resolve_path(cwd) @@ -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 @@ -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/). diff --git a/tests/test_remote_terminal_workspace.py b/tests/test_remote_terminal_workspace.py index e5c1e7ace5a..2b896ce5a39 100644 --- a/tests/test_remote_terminal_workspace.py +++ b/tests/test_remote_terminal_workspace.py @@ -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/ paths must not resolve to /System/Volumes/Data/home/ 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"} + ] +