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
31 changes: 18 additions & 13 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,26 +459,27 @@ def load_cli_config() -> Dict[str, Any]:
if "backend" in terminal_config:
terminal_config["env_type"] = terminal_config["backend"]

# Handle special cwd values: "." or "auto" means use current working directory.
# Only resolve to the host's CWD for the local backend where the host
# filesystem is directly accessible. For ALL remote/container backends
# (ssh, docker, modal, singularity), the host path doesn't exist on the
# target -- remove the key so terminal_tool.py uses its per-backend default.
# Interactive CLI/TUI sessions use the directory the user launched from.
# terminal.cwd is still consumed by gateway/daemon startup, where there is
# no meaningful shell cwd and a configured anchor is required.
#
# GUARD: If TERMINAL_CWD is already set to a real absolute path (by the
# gateway's config bridge earlier in the process), don't clobber it.
# This prevents a lazy import of cli.py during gateway runtime from
# rewriting TERMINAL_CWD to the service's working directory.
# See issue #10817.
# For gateway runtime, preserve the historical placeholder handling:
# if TERMINAL_CWD was already resolved by gateway/run.py, a lazy import of
# cli.py must not rewrite it to the service process cwd. See issue #10817.
_CWD_PLACEHOLDERS = (".", "auto", "cwd")
if terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
_gateway_runtime = os.environ.get("HERMES_GATEWAY_PROCESS") == "1"
effective_backend = terminal_config.get("env_type", "local")
_force_interactive_cwd = not _gateway_runtime and effective_backend == "local"
if _force_interactive_cwd:
terminal_config["cwd"] = os.getcwd()
defaults["terminal"]["cwd"] = terminal_config["cwd"]
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
_existing_cwd = os.environ.get("TERMINAL_CWD", "")
if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd):
# Gateway (or earlier startup) already resolved a real path — keep it
terminal_config["cwd"] = _existing_cwd
defaults["terminal"]["cwd"] = _existing_cwd
else:
effective_backend = terminal_config.get("env_type", "local")
if effective_backend == "local":
terminal_config["cwd"] = os.getcwd()
defaults["terminal"]["cwd"] = terminal_config["cwd"]
Expand Down Expand Up @@ -524,7 +525,11 @@ def load_cli_config() -> Dict[str, Any]:
# were already set by .env -- the user's .env is the fallback source.
for config_key, env_var in env_mappings.items():
if config_key in terminal_config:
if _file_has_terminal_config or env_var not in os.environ:
if (
(config_key == "cwd" and _force_interactive_cwd)
or _file_has_terminal_config
or env_var not in os.environ
):
val = terminal_config[config_key]
if isinstance(val, list):
os.environ[env_var] = json.dumps(val)
Expand Down
5 changes: 5 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,11 @@ def _home_target_env_var(platform_name: str) -> str:
_env_path = _hermes_home / '.env'
load_hermes_dotenv(hermes_home=_hermes_home, project_env=Path(__file__).resolve().parents[1] / '.env')

# Marks this process for modules that are shared with interactive CLI/TUI
# startup. In particular, cli.load_cli_config() must preserve the gateway's
# configured TERMINAL_CWD during lazy imports.
os.environ["HERMES_GATEWAY_PROCESS"] = "1"


_DOCKER_VOLUME_SPEC_RE = re.compile(r"^(?P<host>.+):(?P<container>/[^:]+?)(?::(?P<options>[^:]+))?$")
_DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS = {"/output", "/outputs"}
Expand Down
76 changes: 58 additions & 18 deletions tests/cli/test_cwd_env_respect.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
"""Tests that load_cli_config() guards against lazy-import TERMINAL_CWD clobbering.
"""Tests for load_cli_config() cwd resolution.

When the gateway resolves TERMINAL_CWD at startup and cli.py is later
imported lazily (via delegate_tool → CLI_CONFIG), load_cli_config() must
not overwrite the already-resolved value with os.getcwd().

config.yaml terminal.cwd is the canonical source of truth.
Interactive CLI/TUI startup should use the shell launch directory for local
terminal sessions, even if config.yaml or .env has a stale terminal cwd.

Gateway config.yaml terminal.cwd remains the canonical source of truth.
.env TERMINAL_CWD and MESSAGING_CWD are deprecated.
See issue #10817.
"""

import os
import pytest


# The sentinel values that mean "resolve at runtime"
_CWD_PLACEHOLDERS = (".", "auto", "cwd")


def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict):
def _resolve_terminal_cwd(
terminal_config: dict,
defaults: dict,
env: dict,
*,
gateway_runtime: bool = False,
):
"""Simulate the CWD resolution logic from load_cli_config().

This mirrors the code in cli.py that checks for a pre-resolved
TERMINAL_CWD before falling back to os.getcwd().
This mirrors the code in cli.py that forces interactive local sessions
to os.getcwd(), while preserving pre-resolved gateway TERMINAL_CWD.
"""
if terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
effective_backend = terminal_config.get("env_type", "local")
force_interactive_cwd = not gateway_runtime and effective_backend == "local"
if force_interactive_cwd:
terminal_config["cwd"] = "/fake/getcwd" # stand-in for os.getcwd()
defaults["terminal"]["cwd"] = terminal_config["cwd"]
elif terminal_config.get("cwd") in _CWD_PLACEHOLDERS:
_existing_cwd = env.get("TERMINAL_CWD", "")
if _existing_cwd and _existing_cwd not in _CWD_PLACEHOLDERS and os.path.isabs(_existing_cwd):
terminal_config["cwd"] = _existing_cwd
defaults["terminal"]["cwd"] = _existing_cwd
else:
effective_backend = terminal_config.get("env_type", "local")
if effective_backend == "local":
terminal_config["cwd"] = "/fake/getcwd" # stand-in for os.getcwd()
defaults["terminal"]["cwd"] = terminal_config["cwd"]
Expand All @@ -39,7 +51,7 @@ def _resolve_terminal_cwd(terminal_config: dict, defaults: dict, env: dict):
# Simulate the bridging loop: write terminal_config["cwd"] to env
_file_has_terminal = defaults.get("_file_has_terminal", False)
if "cwd" in terminal_config:
if _file_has_terminal or "TERMINAL_CWD" not in env:
if force_interactive_cwd or _file_has_terminal or "TERMINAL_CWD" not in env:
env["TERMINAL_CWD"] = str(terminal_config["cwd"])

return env.get("TERMINAL_CWD", "")
Expand All @@ -54,7 +66,9 @@ def test_gateway_resolved_cwd_survives(self):
terminal_config = {"cwd": ".", "env_type": "local"}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}

result = _resolve_terminal_cwd(terminal_config, defaults, env)
result = _resolve_terminal_cwd(
terminal_config, defaults, env, gateway_runtime=True
)
assert result == "/home/user/workspace"

def test_gateway_resolved_cwd_survives_with_file_terminal(self):
Expand All @@ -63,21 +77,32 @@ def test_gateway_resolved_cwd_survives_with_file_terminal(self):
terminal_config = {"cwd": ".", "env_type": "local"}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": True}

result = _resolve_terminal_cwd(terminal_config, defaults, env)
result = _resolve_terminal_cwd(
terminal_config, defaults, env, gateway_runtime=True
)
assert result == "/home/user/workspace"


class TestConfigCwdResolution:
"""config.yaml terminal.cwd is the canonical source of truth."""
class TestInteractiveCwdResolution:
"""Interactive CLI/TUI local sessions use the launch cwd."""

def test_explicit_config_cwd_wins(self):
"""terminal.cwd: /explicit/path always wins."""
env = {"TERMINAL_CWD": "/old/gateway/value"}
terminal_config = {"cwd": "/explicit/path"}
def test_explicit_config_cwd_does_not_pin_interactive_cli(self):
"""terminal.cwd must not override the shell cwd for local CLI/TUI."""
env = {}
terminal_config = {"cwd": "/explicit/path", "env_type": "local"}
defaults = {"terminal": {"cwd": "/explicit/path"}, "_file_has_terminal": True}

result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/explicit/path"
assert result == "/fake/getcwd"

def test_env_terminal_cwd_does_not_pin_interactive_cli(self):
"""A stale .env TERMINAL_CWD must not override local launch cwd."""
env = {"TERMINAL_CWD": "/old/gateway/value"}
terminal_config = {"cwd": ".", "env_type": "local"}
defaults = {"terminal": {"cwd": "."}, "_file_has_terminal": False}

result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/fake/getcwd"

def test_dot_cwd_resolves_to_getcwd_when_no_prior(self):
"""With no pre-set TERMINAL_CWD, "." resolves to os.getcwd()."""
Expand All @@ -88,6 +113,21 @@ def test_dot_cwd_resolves_to_getcwd_when_no_prior(self):
result = _resolve_terminal_cwd(terminal_config, defaults, env)
assert result == "/fake/getcwd"


class TestGatewayConfigCwdResolution:
"""Gateway config.yaml terminal.cwd is the canonical source of truth."""

def test_explicit_config_cwd_wins_for_gateway_runtime(self):
"""terminal.cwd: /explicit/path still anchors gateway sessions."""
env = {"TERMINAL_CWD": "/old/gateway/value"}
terminal_config = {"cwd": "/explicit/path"}
defaults = {"terminal": {"cwd": "/explicit/path"}, "_file_has_terminal": True}

result = _resolve_terminal_cwd(
terminal_config, defaults, env, gateway_runtime=True
)
assert result == "/explicit/path"

def test_remote_backend_pops_cwd(self):
"""Remote backend + placeholder cwd → popped for backend default."""
env = {}
Expand Down
Loading