diff --git a/cli.py b/cli.py index eedff7a07..d85fc4003 100755 --- a/cli.py +++ b/cli.py @@ -328,6 +328,8 @@ def load_cli_config() -> Dict[str, Any]: "container_persistent": "TERMINAL_CONTAINER_PERSISTENT", "docker_volumes": "TERMINAL_DOCKER_VOLUMES", "sandbox_dir": "TERMINAL_SANDBOX_DIR", + # Persistent shell (non-local backends) + "persistent_shell": "TERMINAL_PERSISTENT_SHELL", # Sudo support (works with all backends) "sudo_password": "SUDO_PASSWORD", } diff --git a/gateway/run.py b/gateway/run.py index e7bfb6257..8e9fa5578 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -77,6 +77,7 @@ if _config_path.exists(): "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: diff --git a/hermes_cli/config.py b/hermes_cli/config.py index d385cd8ba..1fdfbad77 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -118,6 +118,11 @@ DEFAULT_CONFIG = { # Each entry is "host_path:container_path" (standard Docker -v syntax). # Example: ["/home/user/projects:/workspace/projects", "/data:/data"] "docker_volumes": [], + # Persistent shell — keep a long-lived bash shell across execute() calls + # so cwd/env vars/shell variables survive between commands. + # Enabled by default for non-local backends (SSH); local is always opt-in + # via TERMINAL_LOCAL_PERSISTENT env var. + "persistent_shell": True, }, "browser": { @@ -1391,6 +1396,7 @@ def set_config_value(key: str, value: str): "terminal.cwd": "TERMINAL_CWD", "terminal.timeout": "TERMINAL_TIMEOUT", "terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR", + "terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL", } if key in _config_to_env_sync: save_env_value(_config_to_env_sync[key], str(value)) diff --git a/tests/tools/test_ssh_environment.py b/tests/tools/test_ssh_environment.py index 65469e5f5..73d1cbdd5 100644 --- a/tests/tools/test_ssh_environment.py +++ b/tests/tools/test_ssh_environment.py @@ -67,16 +67,31 @@ class TestBuildSSHCommand: class TestTerminalToolConfig: - def test_ssh_persistent_default_false(self, monkeypatch): + def test_ssh_persistent_default_true(self, monkeypatch): + """SSH persistent defaults to True (via TERMINAL_PERSISTENT_SHELL).""" monkeypatch.delenv("TERMINAL_SSH_PERSISTENT", raising=False) + monkeypatch.delenv("TERMINAL_PERSISTENT_SHELL", raising=False) + from tools.terminal_tool import _get_env_config + assert _get_env_config()["ssh_persistent"] is True + + def test_ssh_persistent_explicit_false(self, monkeypatch): + """Per-backend env var overrides the global default.""" + monkeypatch.setenv("TERMINAL_SSH_PERSISTENT", "false") from tools.terminal_tool import _get_env_config assert _get_env_config()["ssh_persistent"] is False - def test_ssh_persistent_true(self, monkeypatch): + def test_ssh_persistent_explicit_true(self, monkeypatch): monkeypatch.setenv("TERMINAL_SSH_PERSISTENT", "true") from tools.terminal_tool import _get_env_config assert _get_env_config()["ssh_persistent"] is True + def test_ssh_persistent_respects_config(self, monkeypatch): + """TERMINAL_PERSISTENT_SHELL=false disables SSH persistent by default.""" + monkeypatch.delenv("TERMINAL_SSH_PERSISTENT", raising=False) + monkeypatch.setenv("TERMINAL_PERSISTENT_SHELL", "false") + from tools.terminal_tool import _get_env_config + assert _get_env_config()["ssh_persistent"] is False + def _setup_ssh_env(monkeypatch, persistent: bool): monkeypatch.setenv("TERMINAL_ENV", "ssh") diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 327e12210..ec2e0ec86 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -505,7 +505,13 @@ def _get_env_config() -> Dict[str, Any]: "ssh_user": os.getenv("TERMINAL_SSH_USER", ""), "ssh_port": _parse_env_var("TERMINAL_SSH_PORT", "22"), "ssh_key": os.getenv("TERMINAL_SSH_KEY", ""), - "ssh_persistent": os.getenv("TERMINAL_SSH_PERSISTENT", "false").lower() in ("true", "1", "yes"), + # Persistent shell: SSH defaults to the config-level persistent_shell + # setting (true by default for non-local backends); local is always opt-in. + # Per-backend env vars override if explicitly set. + "ssh_persistent": os.getenv( + "TERMINAL_SSH_PERSISTENT", + os.getenv("TERMINAL_PERSISTENT_SHELL", "true"), + ).lower() in ("true", "1", "yes"), "local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in ("true", "1", "yes"), # Container resource config (applies to docker, singularity, modal, daytona -- ignored for local/ssh) "container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"),