From 19f4ef0de4432227da9b291b24ef3e53a7933c13 Mon Sep 17 00:00:00 2001 From: LeonSGP43 Date: Sun, 3 May 2026 21:08:22 +0800 Subject: [PATCH] fix(cli): prefer launch cwd for local sessions --- cli.py | 31 +++++++------ gateway/run.py | 5 ++ tests/cli/test_cwd_env_respect.py | 76 +++++++++++++++++++++++-------- 3 files changed, 81 insertions(+), 31 deletions(-) diff --git a/cli.py b/cli.py index da917ae19065d..d95592f6a7fde 100644 --- a/cli.py +++ b/cli.py @@ -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"] @@ -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) diff --git a/gateway/run.py b/gateway/run.py index 86076bf0bfc2d..7b4ba296e2c6a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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.+):(?P/[^:]+?)(?::(?P[^:]+))?$") _DOCKER_MEDIA_OUTPUT_CONTAINER_PATHS = {"/output", "/outputs"} diff --git a/tests/cli/test_cwd_env_respect.py b/tests/cli/test_cwd_env_respect.py index e9f3341d2aecc..242eee77eb892 100644 --- a/tests/cli/test_cwd_env_respect.py +++ b/tests/cli/test_cwd_env_respect.py @@ -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"] @@ -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", "") @@ -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): @@ -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().""" @@ -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 = {}