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
8 changes: 4 additions & 4 deletions tests/gateway/test_complete_path_at_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def _items(word: str):


def test_at_folder_colon_only_dirs(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
_fixture(tmp_path)

texts = [t for t, _, _ in _items("@folder:")]
Expand All @@ -47,7 +47,7 @@ def test_at_folder_colon_only_dirs(tmp_path, monkeypatch):


def test_at_file_colon_only_files(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
_fixture(tmp_path)

texts = [t for t, _, _ in _items("@file:")]
Expand All @@ -59,7 +59,7 @@ def test_at_file_colon_only_files(tmp_path, monkeypatch):


def test_at_folder_bare_without_colon_lists_dirs(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
_fixture(tmp_path)

texts = [t for t, _, _ in _items("@folder")]
Expand All @@ -70,7 +70,7 @@ def test_at_folder_bare_without_colon_lists_dirs(tmp_path, monkeypatch):


def test_at_file_bare_without_colon_lists_files(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("TERMINAL_CWD", str(tmp_path))
_fixture(tmp_path)

texts = [t for t, _, _ in _items("@file")]
Expand Down
212 changes: 212 additions & 0 deletions tests/tui_gateway/test_cwd_config_bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"""Tests for terminal.cwd being respected by tui_gateway.

Covers two bugs from issue #14044:
1. tui_gateway/server.py must bridge config.yaml terminal.cwd → TERMINAL_CWD.
2. complete.path must resolve relative paths against TERMINAL_CWD, not process cwd.
"""

import os
import json
import textwrap
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest


# ---------------------------------------------------------------------------
# Helpers that simulate the server-startup config bridge without importing the
# module (which would trigger side-effects).
# ---------------------------------------------------------------------------

_CWD_PLACEHOLDERS = (".", "auto", "cwd")


def _simulate_tui_config_bridge(terminal_cfg: dict, initial_env: dict | None = None) -> dict:
"""Simulate the startup config bridge added to tui_gateway/server.py.

Returns the resulting env dict (only TERMINAL_* keys).
"""
env = dict(initial_env or {})
terminal_env_map = {
"backend": "TERMINAL_ENV",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
"docker_image": "TERMINAL_DOCKER_IMAGE",
}
for cfg_key, env_var in terminal_env_map.items():
if cfg_key in terminal_cfg:
val = terminal_cfg[cfg_key]
if cfg_key == "cwd" and str(val) in _CWD_PLACEHOLDERS:
continue
if isinstance(val, list):
env[env_var] = json.dumps(val)
else:
env[env_var] = str(val)
return env


# ---------------------------------------------------------------------------
# Tests: config bridge
# ---------------------------------------------------------------------------

class TestTuiConfigBridge:
"""terminal.cwd in config.yaml must be bridged to TERMINAL_CWD at startup."""

def test_explicit_cwd_sets_terminal_cwd(self):
result = _simulate_tui_config_bridge({"cwd": "/home/user/projects"})
assert result["TERMINAL_CWD"] == "/home/user/projects"

def test_dot_placeholder_skipped(self):
result = _simulate_tui_config_bridge({"cwd": "."})
assert "TERMINAL_CWD" not in result

def test_auto_placeholder_skipped(self):
result = _simulate_tui_config_bridge({"cwd": "auto"})
assert "TERMINAL_CWD" not in result

def test_cwd_keyword_placeholder_skipped(self):
result = _simulate_tui_config_bridge({"cwd": "cwd"})
assert "TERMINAL_CWD" not in result

def test_explicit_cwd_overrides_existing_env(self):
result = _simulate_tui_config_bridge(
{"cwd": "/from/config"},
initial_env={"TERMINAL_CWD": "/old/value"},
)
assert result["TERMINAL_CWD"] == "/from/config"

def test_backend_bridges_to_terminal_env(self):
result = _simulate_tui_config_bridge({"backend": "docker"})
assert result["TERMINAL_ENV"] == "docker"

def test_timeout_bridges_correctly(self):
result = _simulate_tui_config_bridge({"timeout": "120"})
assert result["TERMINAL_TIMEOUT"] == "120"

def test_empty_terminal_cfg_no_change(self):
result = _simulate_tui_config_bridge({})
assert "TERMINAL_CWD" not in result
assert "TERMINAL_ENV" not in result


# ---------------------------------------------------------------------------
# Tests: complete.path resolution against TERMINAL_CWD
# ---------------------------------------------------------------------------

def _run_complete_path(word: str, terminal_cwd: str, tmp_path: Path) -> list[dict]:
"""Invoke the complete.path logic with a mocked TERMINAL_CWD."""
# Import inline to avoid module-level side effects from server.py
from tui_gateway.server import _normalize_completion_path

# Replicate the fixed complete.path resolution logic
is_context = word.startswith("@")
query = word[1:] if is_context else word

if is_context and query in ("file", "folder"):
prefix_tag, path_part = query, ""
elif is_context and query.startswith(("file:", "folder:")):
prefix_tag, _, tail = query.partition(":")
path_part = tail
else:
prefix_tag = ""
path_part = query if is_context else query

_cwd_base = terminal_cwd
expanded = _normalize_completion_path(path_part) if path_part else ""

if not expanded or expanded == ".":
search_dir, match = _cwd_base, ""
elif os.path.isabs(expanded):
if expanded.endswith("/"):
search_dir, match = expanded, ""
else:
search_dir = os.path.dirname(expanded) or "/"
match = os.path.basename(expanded)
elif expanded.endswith("/"):
search_dir = os.path.normpath(os.path.join(_cwd_base, expanded))
match = ""
else:
full_expanded = os.path.join(_cwd_base, expanded)
search_dir = os.path.dirname(full_expanded) or _cwd_base
match = os.path.basename(full_expanded)

if not os.path.isdir(search_dir):
return []

items = []
for entry in sorted(os.listdir(search_dir)):
if match and not entry.lower().startswith(match.lower()):
continue
full = os.path.join(search_dir, entry)
is_dir = os.path.isdir(full)
rel = os.path.relpath(full, _cwd_base)
suffix = "/" if is_dir else ""
if is_context and prefix_tag:
text = f"@{prefix_tag}:{rel}{suffix}"
elif is_context:
kind = "folder" if is_dir else "file"
text = f"@{kind}:{rel}{suffix}"
elif word.startswith("./"):
text = "./" + rel + suffix
elif os.path.isabs(expanded):
text = full + suffix
else:
text = rel + suffix
items.append({"text": text, "display": entry + suffix})
return items


class TestCompletePathUsesCwd:
"""complete.path must resolve paths relative to TERMINAL_CWD."""

def test_empty_word_lists_terminal_cwd_contents(self, tmp_path):
(tmp_path / "alpha.py").touch()
(tmp_path / "beta.py").touch()
items = _run_complete_path("./", str(tmp_path), tmp_path)
names = [i["display"] for i in items]
assert "alpha.py" in names
assert "beta.py" in names

def test_relative_prefix_filters_from_terminal_cwd(self, tmp_path):
(tmp_path / "foo.py").touch()
(tmp_path / "bar.py").touch()
items = _run_complete_path("fo", str(tmp_path), tmp_path)
names = [i["display"] for i in items]
assert "foo.py" in names
assert "bar.py" not in names

def test_context_file_lists_terminal_cwd(self, tmp_path):
(tmp_path / "readme.md").touch()
items = _run_complete_path("@file:", str(tmp_path), tmp_path)
names = [i["display"] for i in items]
assert "readme.md" in names

def test_context_file_prefix_relative_to_terminal_cwd(self, tmp_path):
(tmp_path / "script.sh").touch()
items = _run_complete_path("@file:sc", str(tmp_path), tmp_path)
assert any(i["text"] == "@file:script.sh" for i in items)

def test_absolute_path_unaffected_by_terminal_cwd(self, tmp_path):
other = tmp_path / "other"
other.mkdir()
(other / "thing.txt").touch()
items = _run_complete_path(str(other) + "/", str(tmp_path), tmp_path)
names = [i["display"] for i in items]
assert "thing.txt" in names

def test_returned_text_relative_to_terminal_cwd(self, tmp_path):
"""The completion text must be relative to TERMINAL_CWD, not process cwd."""
(tmp_path / "myfile.txt").touch()
process_cwd = os.getcwd()
# Only meaningful when TERMINAL_CWD != process cwd
if str(tmp_path) == process_cwd:
pytest.skip("tmp_path equals process cwd — can't distinguish")
items = _run_complete_path("./", str(tmp_path), tmp_path)
texts = [i["text"] for i in items]
# Should be "myfile.txt" or "./myfile.txt" relative to tmp_path,
# NOT a path that crosses multiple parent dirs to get back to process cwd.
assert any("myfile.txt" in t and ".." not in t for t in texts), (
f"Expected path relative to TERMINAL_CWD, got: {texts}"
)
73 changes: 66 additions & 7 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,52 @@
_hermes_home = get_hermes_home()
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env")

# Bridge terminal settings from config.yaml → env vars.
# In TUI mode, cli.py (and its load_cli_config()) is never imported, so
# TERMINAL_CWD and related vars are not set from config.yaml otherwise.
# This mirrors the equivalent bridge in gateway/run.py.
_config_yaml = _hermes_home / "config.yaml"
if _config_yaml.exists():
try:
import yaml as _yaml
with open(_config_yaml, encoding="utf-8") as _f:
_startup_cfg = _yaml.safe_load(_f) or {}
_terminal_cfg = _startup_cfg.get("terminal", {})
if isinstance(_terminal_cfg, dict):
_terminal_env_map = {
"backend": "TERMINAL_ENV",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
for _cfg_key, _env_var in _terminal_env_map.items():
if _cfg_key in _terminal_cfg:
_val = _terminal_cfg[_cfg_key]
if _cfg_key == "cwd" and str(_val) in (".", "auto", "cwd"):
continue
if isinstance(_val, list):
os.environ[_env_var] = json.dumps(_val)
else:
os.environ[_env_var] = str(_val)
except Exception:
pass # best-effort; don't block startup

try:
from hermes_cli.banner import prefetch_update_check
prefetch_update_check()
Expand Down Expand Up @@ -2466,14 +2512,25 @@ def _(rid, params: dict) -> dict:
prefix_tag = ""
path_part = query if is_context else query

expanded = _normalize_completion_path(path_part) if path_part else "."
if expanded == "." or not expanded:
search_dir, match = ".", ""
# Resolve relative paths against TERMINAL_CWD so completions honour
# the configured working directory rather than the process launch dir.
_cwd_base = os.getenv("TERMINAL_CWD", "") or os.getcwd()
expanded = _normalize_completion_path(path_part) if path_part else ""
if not expanded or expanded == ".":
search_dir, match = _cwd_base, ""
elif os.path.isabs(expanded):
if expanded.endswith("/"):
search_dir, match = expanded, ""
else:
search_dir = os.path.dirname(expanded) or "/"
match = os.path.basename(expanded)
elif expanded.endswith("/"):
search_dir, match = expanded, ""
search_dir = os.path.normpath(os.path.join(_cwd_base, expanded))
match = ""
else:
search_dir = os.path.dirname(expanded) or "."
match = os.path.basename(expanded)
full_expanded = os.path.join(_cwd_base, expanded)
search_dir = os.path.dirname(full_expanded) or _cwd_base
match = os.path.basename(full_expanded)

if not os.path.isdir(search_dir):
return _ok(rid, {"items": []})
Expand All @@ -2492,7 +2549,7 @@ def _(rid, params: dict) -> dict:
# which used to defeat the prefix and let `@folder:` list files.
if prefix_tag and want_dir != is_dir:
continue
rel = os.path.relpath(full)
rel = os.path.relpath(full, _cwd_base)
suffix = "/" if is_dir else ""

if is_context and prefix_tag:
Expand All @@ -2504,6 +2561,8 @@ def _(rid, params: dict) -> dict:
text = "~/" + os.path.relpath(full, os.path.expanduser("~")) + suffix
elif word.startswith("./"):
text = "./" + rel + suffix
elif os.path.isabs(expanded):
text = full + suffix
else:
text = rel + suffix

Expand Down
Loading