diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 433f10d1f..cd4922875 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -155,7 +155,7 @@ hermes-agent/ │ ├── skill_tools.py # Skill search, load, manage │ └── environments/ # Terminal execution backends │ ├── base.py # BaseEnvironment ABC -│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py +│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py │ ├── gateway/ # Messaging gateway │ ├── run.py # GatewayRunner — platform lifecycle, message routing, cron diff --git a/README.md b/README.md index d19f455b3..831c40bb8 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open Grows the longer it runsPersistent memory across sessions. When it solves a hard problem, it writes a skill document for next time. Skills are searchable, shareable, and compatible with the agentskills.io open standard. Scheduled automationsBuilt-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. Delegates and parallelizesSpawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns. -Real sandboxingFive terminal backends — local, Docker, SSH, Singularity, and Modal — with persistent workspaces and container security hardening. +Real sandboxingSix terminal backends — local, Docker, SSH, Singularity, Modal, and Daytona — with persistent workspaces and container security hardening. Research-readyBatch trajectory generation, Atropos RL environments, trajectory compression for training the next generation of tool-calling models. diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 0ca406358..c9d2c2e50 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -116,8 +116,23 @@ terminal: # timeout: 180 # lifetime_seconds: 300 # modal_image: "nikolaik/python-nodejs:python3.11-nodejs20" + +# ----------------------------------------------------------------------------- +# OPTION 6: Daytona cloud execution +# Commands run in Daytona cloud sandboxes +# Great for: Cloud dev environments, persistent workspaces, team collaboration +# Requires: pip install daytona, DAYTONA_API_KEY env var +# ----------------------------------------------------------------------------- +# terminal: +# backend: "daytona" +# cwd: "~" +# timeout: 180 +# lifetime_seconds: 300 +# daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" +# container_disk: 10240 # Daytona max is 10GB per sandbox + # -# --- Container resource limits (docker, singularity, modal -- ignored for local/ssh) --- +# --- Container resource limits (docker, singularity, modal, daytona -- ignored for local/ssh) --- # These settings apply to all container backends. They control the resources # allocated to the sandbox and whether its filesystem persists across sessions. container_cpu: 1 # CPU cores diff --git a/cli.py b/cli.py index 5aa87d413..850db4102 100755 --- a/cli.py +++ b/cli.py @@ -158,6 +158,7 @@ def load_cli_config() -> Dict[str, Any]: "docker_image": "python:3.11", "singularity_image": "docker://python:3.11", "modal_image": "python:3.11", + "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20", }, "browser": { "inactivity_timeout": 120, # Auto-cleanup inactive browser sessions after 2 min @@ -284,12 +285,13 @@ def load_cli_config() -> Dict[str, Any]: "docker_image": "TERMINAL_DOCKER_IMAGE", "singularity_image": "TERMINAL_SINGULARITY_IMAGE", "modal_image": "TERMINAL_MODAL_IMAGE", + "daytona_image": "TERMINAL_DAYTONA_IMAGE", # SSH config "ssh_host": "TERMINAL_SSH_HOST", "ssh_user": "TERMINAL_SSH_USER", "ssh_port": "TERMINAL_SSH_PORT", "ssh_key": "TERMINAL_SSH_KEY", - # Container resource config (docker, singularity, modal -- ignored for local/ssh) + # Container resource config (docker, singularity, modal, daytona -- ignored for local/ssh) "container_cpu": "TERMINAL_CONTAINER_CPU", "container_memory": "TERMINAL_CONTAINER_MEMORY", "container_disk": "TERMINAL_CONTAINER_DISK", diff --git a/gateway/run.py b/gateway/run.py index 3af04f1ea..44ac37507 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -66,6 +66,7 @@ if _config_path.exists(): "docker_image": "TERMINAL_DOCKER_IMAGE", "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", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 7e444cb9a..042a4ad28 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -71,7 +71,8 @@ DEFAULT_CONFIG = { "docker_image": "nikolaik/python-nodejs:python3.11-nodejs20", "singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20", "modal_image": "nikolaik/python-nodejs:python3.11-nodejs20", - # Container resource limits (docker, singularity, modal — ignored for local/ssh) + "daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20", + # Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh) "container_cpu": 1, "container_memory": 5120, # MB (default 5GB) "container_disk": 51200, # MB (default 50GB) @@ -761,6 +762,10 @@ def show_config(): print(f" Modal image: {terminal.get('modal_image', 'python:3.11')}") modal_token = get_env_value('MODAL_TOKEN_ID') print(f" Modal token: {'configured' if modal_token else '(not set)'}") + elif terminal.get('backend') == 'daytona': + print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}") + daytona_key = get_env_value('DAYTONA_API_KEY') + print(f" API key: {'configured' if daytona_key else '(not set)'}") elif terminal.get('backend') == 'ssh': ssh_host = get_env_value('TERMINAL_SSH_HOST') ssh_user = get_env_value('TERMINAL_SSH_USER') @@ -886,6 +891,7 @@ def set_config_value(key: str, value: str): "terminal.docker_image": "TERMINAL_DOCKER_IMAGE", "terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE", "terminal.modal_image": "TERMINAL_MODAL_IMAGE", + "terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE", "terminal.cwd": "TERMINAL_CWD", "terminal.timeout": "TERMINAL_TIMEOUT", } diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 031c6eaf8..36795c016 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -355,6 +355,21 @@ def run_doctor(args): check_fail("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)") issues.append("Set TERMINAL_SSH_HOST in .env") + # Daytona (if using daytona backend) + if terminal_env == "daytona": + daytona_key = os.getenv("DAYTONA_API_KEY") + if daytona_key: + check_ok("Daytona API key", "(configured)") + else: + check_fail("DAYTONA_API_KEY not set", "(required for TERMINAL_ENV=daytona)") + issues.append("Set DAYTONA_API_KEY environment variable") + try: + from daytona import Daytona + check_ok("daytona SDK", "(installed)") + except ImportError: + check_fail("daytona SDK not installed", "(pip install daytona)") + issues.append("Install daytona SDK: pip install daytona") + # Node.js + agent-browser (for browser automation tools) if shutil.which("node"): check_ok("Node.js") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 8a1dc78d1..a312a20fd 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -980,19 +980,20 @@ def run_setup_wizard(args): terminal_choices.extend([ "Modal (cloud execution, GPU access, serverless)", + "Daytona (cloud sandboxes, persistent workspaces)", "SSH (run commands on a remote server)", f"Keep current ({current_backend})" ]) # Build index map based on available choices if is_linux: - backend_to_idx = {'local': 0, 'docker': 1, 'singularity': 2, 'modal': 3, 'ssh': 4} - idx_to_backend = {0: 'local', 1: 'docker', 2: 'singularity', 3: 'modal', 4: 'ssh'} - keep_current_idx = 5 + backend_to_idx = {'local': 0, 'docker': 1, 'singularity': 2, 'modal': 3, 'daytona': 4, 'ssh': 5} + idx_to_backend = {0: 'local', 1: 'docker', 2: 'singularity', 3: 'modal', 4: 'daytona', 5: 'ssh'} + keep_current_idx = 6 else: - backend_to_idx = {'local': 0, 'docker': 1, 'modal': 2, 'ssh': 3} - idx_to_backend = {0: 'local', 1: 'docker', 2: 'modal', 3: 'ssh'} - keep_current_idx = 4 + backend_to_idx = {'local': 0, 'docker': 1, 'modal': 2, 'daytona': 3, 'ssh': 4} + idx_to_backend = {0: 'local', 1: 'docker', 2: 'modal', 3: 'daytona', 4: 'ssh'} + keep_current_idx = 5 if current_backend == 'singularity': print_warning("Singularity is only available on Linux - please select a different backend") @@ -1067,7 +1068,7 @@ def run_setup_wizard(args): print() print_info("Note: Container resource settings (CPU, memory, disk, persistence)") - print_info("are in your config but only apply to Docker/Singularity/Modal backends.") + print_info("are in your config but only apply to Docker/Singularity/Modal/Daytona backends.") if prompt_yes_no(" Enable sudo support? (allows agent to run sudo commands)", False): print_warning(" SECURITY WARNING: Sudo password will be stored in plaintext") @@ -1151,7 +1152,52 @@ def run_setup_wizard(args): _prompt_container_resources(config) print_success("Terminal set to Modal") - + + elif selected_backend == 'daytona': + config.setdefault('terminal', {})['backend'] = 'daytona' + default_daytona = config.get('terminal', {}).get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20') + print_info("Daytona Cloud Configuration:") + print_info("Get your API key at: https://app.daytona.io/dashboard/keys") + + # Check if daytona SDK is installed + try: + from daytona import Daytona + print_info("daytona SDK: installed ✓") + except ImportError: + print_info("Installing required package: daytona...") + import subprocess + import shutil + uv_bin = shutil.which("uv") + if uv_bin: + result = subprocess.run( + [uv_bin, "pip", "install", "daytona"], + capture_output=True, text=True + ) + else: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "daytona"], + capture_output=True, text=True + ) + if result.returncode == 0: + print_success("daytona SDK installed") + else: + print_warning("Failed to install daytona SDK — install manually:") + print_info(' pip install daytona') + + daytona_image = prompt(" Container image", default_daytona) + config['terminal']['daytona_image'] = daytona_image + + current_key = get_env_value('DAYTONA_API_KEY') + if current_key: + print_info(f" API Key: {current_key[:8]}... (configured)") + + api_key = prompt(" Daytona API key", current_key or "", password=True) + if api_key: + save_env_value("DAYTONA_API_KEY", api_key) + + _prompt_container_resources(config) + print_success("Terminal set to Daytona") + elif selected_backend == 'ssh': config.setdefault('terminal', {})['backend'] = 'ssh' print_info("SSH Remote Execution Configuration:") @@ -1181,7 +1227,7 @@ def run_setup_wizard(args): print() print_info("Note: Container resource settings (CPU, memory, disk, persistence)") - print_info("are in your config but only apply to Docker/Singularity/Modal backends.") + print_info("are in your config but only apply to Docker/Singularity/Modal/Daytona backends.") print_success("Terminal set to SSH") # else: Keep current (selected_backend is None) @@ -1192,6 +1238,9 @@ def run_setup_wizard(args): docker_image = config.get('terminal', {}).get('docker_image') if docker_image: save_env_value("TERMINAL_DOCKER_IMAGE", docker_image) + daytona_image = config.get('terminal', {}).get('daytona_image') + if daytona_image: + save_env_value("TERMINAL_DAYTONA_IMAGE", daytona_image) # ========================================================================= # Step 5: Agent Settings diff --git a/hermes_cli/status.py b/hermes_cli/status.py index f1d3a7edf..48c962def 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -163,6 +163,9 @@ def show_status(args): elif terminal_env == "docker": docker_image = os.getenv("TERMINAL_DOCKER_IMAGE", "python:3.11-slim") print(f" Docker Image: {docker_image}") + elif terminal_env == "daytona": + daytona_image = os.getenv("TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20") + print(f" Daytona Image: {daytona_image}") sudo_password = os.getenv("SUDO_PASSWORD", "") print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}") diff --git a/pyproject.toml b/pyproject.toml index de4c50ea8..38a6347ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ [project.optional-dependencies] modal = ["swe-rex[modal]>=1.4.0"] +daytona = ["daytona>=0.148.0"] dev = ["pytest", "pytest-asyncio"] messaging = ["python-telegram-bot>=20.0", "discord.py>=2.0", "aiohttp>=3.9.0", "slack-bolt>=1.18.0", "slack-sdk>=3.27.0"] cron = ["croniter"] @@ -51,6 +52,7 @@ mcp = ["mcp>=1.2.0"] homeassistant = ["aiohttp>=3.9.0"] all = [ "hermes-agent[modal]", + "hermes-agent[daytona]", "hermes-agent[messaging]", "hermes-agent[cron]", "hermes-agent[cli]", diff --git a/tests/integration/test_daytona_terminal.py b/tests/integration/test_daytona_terminal.py new file mode 100644 index 000000000..b8b72fb26 --- /dev/null +++ b/tests/integration/test_daytona_terminal.py @@ -0,0 +1,123 @@ +"""Integration tests for the Daytona terminal backend. + +Requires DAYTONA_API_KEY to be set. Run with: + TERMINAL_ENV=daytona pytest tests/integration/test_daytona_terminal.py -v +""" + +import json +import os +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + +# Skip entire module if no API key +if not os.getenv("DAYTONA_API_KEY"): + pytest.skip("DAYTONA_API_KEY not set", allow_module_level=True) + +# Import terminal_tool via importlib to avoid tools/__init__.py side effects +import importlib.util + +parent_dir = Path(__file__).parent.parent.parent +sys.path.insert(0, str(parent_dir)) + +spec = importlib.util.spec_from_file_location( + "terminal_tool", parent_dir / "tools" / "terminal_tool.py" +) +terminal_module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(terminal_module) + +terminal_tool = terminal_module.terminal_tool +cleanup_vm = terminal_module.cleanup_vm + + +@pytest.fixture(autouse=True) +def _force_daytona(monkeypatch): + monkeypatch.setenv("TERMINAL_ENV", "daytona") + monkeypatch.setenv("TERMINAL_CONTAINER_DISK", "10240") + monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "false") + + +@pytest.fixture() +def task_id(request): + """Provide a unique task_id and clean up the sandbox after the test.""" + tid = f"daytona_test_{request.node.name}" + yield tid + cleanup_vm(tid) + + +def _run(command, task_id, **kwargs): + result = terminal_tool(command, task_id=task_id, **kwargs) + return json.loads(result) + + +class TestDaytonaBasic: + def test_echo(self, task_id): + r = _run("echo 'Hello from Daytona!'", task_id) + assert r["exit_code"] == 0 + assert "Hello from Daytona!" in r["output"] + + def test_python_version(self, task_id): + r = _run("python3 --version", task_id) + assert r["exit_code"] == 0 + assert "Python" in r["output"] + + def test_nonzero_exit(self, task_id): + r = _run("exit 42", task_id) + assert r["exit_code"] == 42 + + def test_os_info(self, task_id): + r = _run("uname -a", task_id) + assert r["exit_code"] == 0 + assert "Linux" in r["output"] + + +class TestDaytonaFilesystem: + def test_write_and_read_file(self, task_id): + _run("echo 'test content' > /tmp/daytona_test.txt", task_id) + r = _run("cat /tmp/daytona_test.txt", task_id) + assert r["exit_code"] == 0 + assert "test content" in r["output"] + + def test_persistence_within_session(self, task_id): + _run("pip install cowsay 2>/dev/null", task_id, timeout=120) + r = _run('python3 -c "import cowsay; print(cowsay.__file__)"', task_id) + assert r["exit_code"] == 0 + assert "cowsay" in r["output"] + + +class TestDaytonaPersistence: + def test_filesystem_survives_stop_and_resume(self): + """Write a file, stop the sandbox, resume it, assert the file persists.""" + task = "daytona_test_persist" + try: + # Enable persistence for this test + os.environ["TERMINAL_CONTAINER_PERSISTENT"] = "true" + + # Write a marker file and stop the sandbox + _run("echo 'survive' > /tmp/persist_test.txt", task) + cleanup_vm(task) # stops (not deletes) because persistent=true + + # Resume with the same task_id — file should still exist + r = _run("cat /tmp/persist_test.txt", task) + assert r["exit_code"] == 0 + assert "survive" in r["output"] + finally: + # Force-delete so the sandbox doesn't leak + os.environ["TERMINAL_CONTAINER_PERSISTENT"] = "false" + cleanup_vm(task) + + +class TestDaytonaIsolation: + def test_different_tasks_isolated(self): + task_a = "daytona_test_iso_a" + task_b = "daytona_test_iso_b" + try: + _run("echo 'secret' > /tmp/isolated.txt", task_a) + r = _run("cat /tmp/isolated.txt 2>&1 || echo NOT_FOUND", task_b) + assert "secret" not in r["output"] or "NOT_FOUND" in r["output"] + finally: + cleanup_vm(task_a) + cleanup_vm(task_b) diff --git a/tests/tools/test_daytona_environment.py b/tests/tools/test_daytona_environment.py new file mode 100644 index 000000000..6d32f7441 --- /dev/null +++ b/tests/tools/test_daytona_environment.py @@ -0,0 +1,381 @@ +"""Unit tests for the Daytona cloud sandbox environment backend.""" + +import threading +from types import SimpleNamespace +from unittest.mock import MagicMock, patch, PropertyMock + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers to build mock Daytona SDK objects +# --------------------------------------------------------------------------- + +def _make_exec_response(result="", exit_code=0): + return SimpleNamespace(result=result, exit_code=exit_code) + + +def _make_sandbox(sandbox_id="sb-123", state="started"): + sb = MagicMock() + sb.id = sandbox_id + sb.state = state + sb.process.exec.return_value = _make_exec_response() + return sb + + +def _patch_daytona_imports(monkeypatch): + """Patch the daytona SDK so DaytonaEnvironment can be imported without it.""" + import types as _types + + import enum + + class _SandboxState(str, enum.Enum): + STARTED = "started" + STOPPED = "stopped" + ARCHIVED = "archived" + ERROR = "error" + + daytona_mod = _types.ModuleType("daytona") + daytona_mod.Daytona = MagicMock + daytona_mod.CreateSandboxFromImageParams = MagicMock + daytona_mod.DaytonaError = type("DaytonaError", (Exception,), {}) + daytona_mod.Resources = MagicMock(name="Resources") + daytona_mod.SandboxState = _SandboxState + + monkeypatch.setitem(__import__("sys").modules, "daytona", daytona_mod) + return daytona_mod + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def daytona_sdk(monkeypatch): + """Provide a mock daytona SDK module and return it for assertions.""" + return _patch_daytona_imports(monkeypatch) + + +@pytest.fixture() +def make_env(daytona_sdk, monkeypatch): + """Factory that creates a DaytonaEnvironment with a mocked SDK.""" + # Prevent is_interrupted from interfering + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + def _factory( + sandbox=None, + find_one_side_effect=None, + home_dir="/root", + persistent=True, + **kwargs, + ): + sandbox = sandbox or _make_sandbox() + # Mock the $HOME detection + sandbox.process.exec.return_value = _make_exec_response(result=home_dir) + + mock_client = MagicMock() + mock_client.create.return_value = sandbox + + if find_one_side_effect is not None: + mock_client.find_one.side_effect = find_one_side_effect + else: + # Default: no existing sandbox found + mock_client.find_one.side_effect = daytona_sdk.DaytonaError("not found") + + daytona_sdk.Daytona = MagicMock(return_value=mock_client) + + from tools.environments.daytona import DaytonaEnvironment + + kwargs.setdefault("disk", 10240) + env = DaytonaEnvironment( + image="test-image:latest", + persistent_filesystem=persistent, + **kwargs, + ) + env._mock_client = mock_client # expose for assertions + return env + + return _factory + + +# --------------------------------------------------------------------------- +# Constructor / cwd resolution +# --------------------------------------------------------------------------- + +class TestCwdResolution: + def test_default_cwd_resolves_home(self, make_env): + env = make_env(home_dir="/home/testuser") + assert env.cwd == "/home/testuser" + + def test_tilde_cwd_resolves_home(self, make_env): + env = make_env(cwd="~", home_dir="/home/testuser") + assert env.cwd == "/home/testuser" + + def test_explicit_cwd_not_overridden(self, make_env): + env = make_env(cwd="/workspace", home_dir="/root") + assert env.cwd == "/workspace" + + def test_home_detection_failure_keeps_default_cwd(self, make_env): + sb = _make_sandbox() + sb.process.exec.side_effect = RuntimeError("exec failed") + env = make_env(sandbox=sb) + assert env.cwd == "/home/daytona" # keeps constructor default + + def test_empty_home_keeps_default_cwd(self, make_env): + env = make_env(home_dir="") + assert env.cwd == "/home/daytona" # keeps constructor default + + +# --------------------------------------------------------------------------- +# Sandbox persistence / resume +# --------------------------------------------------------------------------- + +class TestPersistence: + def test_persistent_resumes_existing_sandbox(self, make_env): + existing = _make_sandbox(sandbox_id="sb-existing") + existing.process.exec.return_value = _make_exec_response(result="/root") + env = make_env(find_one_side_effect=lambda **kw: existing, persistent=True) + existing.start.assert_called_once() + # Should NOT have called create since find_one succeeded + env._mock_client.create.assert_not_called() + + def test_persistent_creates_new_when_none_found(self, make_env, daytona_sdk): + env = make_env( + find_one_side_effect=daytona_sdk.DaytonaError("not found"), + persistent=True, + ) + env._mock_client.create.assert_called_once() + + def test_non_persistent_skips_find_one(self, make_env): + env = make_env(persistent=False) + env._mock_client.find_one.assert_not_called() + env._mock_client.create.assert_called_once() + + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +class TestCleanup: + def test_persistent_cleanup_stops_sandbox(self, make_env): + env = make_env(persistent=True) + sb = env._sandbox + env.cleanup() + sb.stop.assert_called_once() + + def test_non_persistent_cleanup_deletes_sandbox(self, make_env): + env = make_env(persistent=False) + sb = env._sandbox + env.cleanup() + env._mock_client.delete.assert_called_once_with(sb) + + def test_cleanup_idempotent(self, make_env): + env = make_env(persistent=True) + env.cleanup() + env.cleanup() # should not raise + + def test_cleanup_swallows_errors(self, make_env): + env = make_env(persistent=True) + env._sandbox.stop.side_effect = RuntimeError("stop failed") + env.cleanup() # should not raise + assert env._sandbox is None + + +# --------------------------------------------------------------------------- +# Execute +# --------------------------------------------------------------------------- + +class TestExecute: + def test_basic_command(self, make_env): + sb = _make_sandbox() + # First call: $HOME detection; subsequent calls: actual commands + sb.process.exec.side_effect = [ + _make_exec_response(result="/root"), # $HOME + _make_exec_response(result="hello", exit_code=0), # actual cmd + ] + sb.state = "started" + env = make_env(sandbox=sb) + + result = env.execute("echo hello") + assert result["output"] == "hello" + assert result["returncode"] == 0 + + def test_command_wrapped_with_shell_timeout(self, make_env): + sb = _make_sandbox() + sb.process.exec.side_effect = [ + _make_exec_response(result="/root"), + _make_exec_response(result="ok", exit_code=0), + ] + sb.state = "started" + env = make_env(sandbox=sb, timeout=42) + + env.execute("echo hello") + # The command sent to exec should be wrapped with `timeout N sh -c '...'` + call_args = sb.process.exec.call_args_list[-1] + cmd = call_args[0][0] + assert cmd.startswith("timeout 42 sh -c ") + # SDK timeout param should NOT be passed + assert "timeout" not in call_args[1] + + def test_timeout_returns_exit_code_124(self, make_env): + """Shell timeout utility returns exit code 124.""" + sb = _make_sandbox() + sb.process.exec.side_effect = [ + _make_exec_response(result="/root"), + _make_exec_response(result="", exit_code=124), + ] + sb.state = "started" + env = make_env(sandbox=sb) + + result = env.execute("sleep 300", timeout=5) + assert result["returncode"] == 124 + + def test_nonzero_exit_code(self, make_env): + sb = _make_sandbox() + sb.process.exec.side_effect = [ + _make_exec_response(result="/root"), + _make_exec_response(result="not found", exit_code=127), + ] + sb.state = "started" + env = make_env(sandbox=sb) + + result = env.execute("bad_cmd") + assert result["returncode"] == 127 + + def test_stdin_data_wraps_heredoc(self, make_env): + sb = _make_sandbox() + sb.process.exec.side_effect = [ + _make_exec_response(result="/root"), + _make_exec_response(result="ok", exit_code=0), + ] + sb.state = "started" + env = make_env(sandbox=sb) + + env.execute("python3", stdin_data="print('hi')") + # Check that the command passed to exec contains heredoc markers + # (single quotes get shell-escaped by shlex.quote, so check components) + call_args = sb.process.exec.call_args_list[-1] + cmd = call_args[0][0] + assert "HERMES_EOF_" in cmd + assert "print" in cmd + assert "hi" in cmd + + def test_custom_cwd_passed_through(self, make_env): + sb = _make_sandbox() + sb.process.exec.side_effect = [ + _make_exec_response(result="/root"), + _make_exec_response(result="/tmp", exit_code=0), + ] + sb.state = "started" + env = make_env(sandbox=sb) + + env.execute("pwd", cwd="/tmp") + call_kwargs = sb.process.exec.call_args_list[-1][1] + assert call_kwargs["cwd"] == "/tmp" + + def test_daytona_error_triggers_retry(self, make_env, daytona_sdk): + sb = _make_sandbox() + sb.state = "started" + sb.process.exec.side_effect = [ + _make_exec_response(result="/root"), # $HOME + daytona_sdk.DaytonaError("transient"), # first attempt fails + _make_exec_response(result="ok", exit_code=0), # retry succeeds + ] + env = make_env(sandbox=sb) + + result = env.execute("echo retry") + assert result["output"] == "ok" + assert result["returncode"] == 0 + + +# --------------------------------------------------------------------------- +# Resource conversion +# --------------------------------------------------------------------------- + +class TestResourceConversion: + def _get_resources_kwargs(self, daytona_sdk): + return daytona_sdk.Resources.call_args.kwargs + + def test_memory_converted_to_gib(self, make_env, daytona_sdk): + env = make_env(memory=5120) + assert self._get_resources_kwargs(daytona_sdk)["memory"] == 5 + + def test_disk_converted_to_gib(self, make_env, daytona_sdk): + env = make_env(disk=10240) + assert self._get_resources_kwargs(daytona_sdk)["disk"] == 10 + + def test_small_values_clamped_to_1(self, make_env, daytona_sdk): + env = make_env(memory=100, disk=100) + kw = self._get_resources_kwargs(daytona_sdk) + assert kw["memory"] == 1 + assert kw["disk"] == 1 + + +# --------------------------------------------------------------------------- +# Ensure sandbox ready +# --------------------------------------------------------------------------- + +class TestInterrupt: + def test_interrupt_stops_sandbox_and_returns_130(self, make_env, monkeypatch): + sb = _make_sandbox() + sb.state = "started" + event = threading.Event() + calls = {"n": 0} + + def exec_side_effect(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + return _make_exec_response(result="/root") # $HOME detection + event.wait(timeout=5) # simulate long-running command + return _make_exec_response(result="done", exit_code=0) + + sb.process.exec.side_effect = exec_side_effect + env = make_env(sandbox=sb) + + monkeypatch.setattr( + "tools.environments.daytona.is_interrupted", lambda: True + ) + try: + result = env.execute("sleep 10") + assert result["returncode"] == 130 + sb.stop.assert_called() + finally: + event.set() + + +# --------------------------------------------------------------------------- +# Retry exhaustion +# --------------------------------------------------------------------------- + +class TestRetryExhausted: + def test_both_attempts_fail(self, make_env, daytona_sdk): + sb = _make_sandbox() + sb.state = "started" + sb.process.exec.side_effect = [ + _make_exec_response(result="/root"), # $HOME + daytona_sdk.DaytonaError("fail1"), # first attempt + daytona_sdk.DaytonaError("fail2"), # retry + ] + env = make_env(sandbox=sb) + + result = env.execute("echo x") + assert result["returncode"] == 1 + assert "Daytona execution error" in result["output"] + + +# --------------------------------------------------------------------------- +# Ensure sandbox ready +# --------------------------------------------------------------------------- + +class TestEnsureSandboxReady: + def test_restarts_stopped_sandbox(self, make_env): + env = make_env() + env._sandbox.state = "stopped" + env._ensure_sandbox_ready() + env._sandbox.start.assert_called() + + def test_no_restart_when_running(self, make_env): + env = make_env() + env._sandbox.state = "started" + env._ensure_sandbox_ready() + env._sandbox.start.assert_not_called() diff --git a/tools/approval.py b/tools/approval.py index f2356533f..cdf19e443 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -247,7 +247,7 @@ def check_dangerous_command(command: str, env_type: str, Returns: {"approved": True/False, "message": str or None, ...} """ - if env_type in ("docker", "singularity", "modal"): + if env_type in ("docker", "singularity", "modal", "daytona"): return {"approved": True, "message": None} is_dangerous, pattern_key, description = detect_dangerous_command(command) diff --git a/tools/environments/__init__.py b/tools/environments/__init__.py index 42b49b6f2..7ffcce1c6 100644 --- a/tools/environments/__init__.py +++ b/tools/environments/__init__.py @@ -2,7 +2,7 @@ Each backend provides the same interface (BaseEnvironment ABC) for running shell commands in a specific execution context: local, Docker, Singularity, -SSH, or Modal. +SSH, Modal, or Daytona. The terminal_tool.py factory (_create_environment) selects the backend based on the TERMINAL_ENV configuration. diff --git a/tools/environments/daytona.py b/tools/environments/daytona.py new file mode 100644 index 000000000..c8df198c1 --- /dev/null +++ b/tools/environments/daytona.py @@ -0,0 +1,220 @@ +"""Daytona cloud execution environment. + +Uses the Daytona Python SDK to run commands in cloud sandboxes. +Supports persistent sandboxes: when enabled, sandboxes are stopped on cleanup +and resumed on next creation, preserving the filesystem across sessions. +""" + +import logging +import math +import shlex +import threading +import uuid +import warnings +from typing import Optional + +from tools.environments.base import BaseEnvironment +from tools.interrupt import is_interrupted + +logger = logging.getLogger(__name__) + + +class DaytonaEnvironment(BaseEnvironment): + """Daytona cloud sandbox execution backend. + + Uses stopped/started sandbox lifecycle for filesystem persistence + instead of snapshots, making it faster and stateless on the host. + """ + + def __init__( + self, + image: str, + cwd: str = "/home/daytona", + timeout: int = 60, + cpu: int = 1, + memory: int = 5120, # MB (hermes convention) + disk: int = 10240, # MB (Daytona platform max is 10GB) + persistent_filesystem: bool = True, + task_id: str = "default", + ): + self._requested_cwd = cwd + super().__init__(cwd=cwd, timeout=timeout) + + from daytona import ( + Daytona, + CreateSandboxFromImageParams, + DaytonaError, + Resources, + SandboxState, + ) + + self._persistent = persistent_filesystem + self._task_id = task_id + self._SandboxState = SandboxState + self._daytona = Daytona() + self._sandbox = None + self._lock = threading.Lock() + + memory_gib = max(1, math.ceil(memory / 1024)) + disk_gib = max(1, math.ceil(disk / 1024)) + if disk_gib > 10: + warnings.warn( + f"Daytona: requested disk ({disk_gib}GB) exceeds platform limit (10GB). " + f"Capping to 10GB. Set container_disk: 10240 in config to silence this.", + stacklevel=2, + ) + disk_gib = 10 + resources = Resources(cpu=cpu, memory=memory_gib, disk=disk_gib) + + labels = {"hermes_task_id": task_id} + + # Try to resume an existing stopped sandbox for this task + if self._persistent: + try: + self._sandbox = self._daytona.find_one(labels=labels) + self._sandbox.start() + logger.info("Daytona: resumed sandbox %s for task %s", + self._sandbox.id, task_id) + except DaytonaError: + self._sandbox = None + except Exception as e: + logger.warning("Daytona: failed to resume sandbox for task %s: %s", + task_id, e) + self._sandbox = None + + # Create a fresh sandbox if we don't have one + if self._sandbox is None: + self._sandbox = self._daytona.create( + CreateSandboxFromImageParams( + image=image, + labels=labels, + auto_stop_interval=0, + resources=resources, + ) + ) + logger.info("Daytona: created sandbox %s for task %s", + self._sandbox.id, task_id) + + # Resolve cwd: detect actual home dir inside the sandbox + if self._requested_cwd in ("~", "/home/daytona"): + try: + home = self._sandbox.process.exec("echo $HOME").result.strip() + if home: + self.cwd = home + except Exception: + pass # leave cwd as-is; sandbox will use its own default + logger.info("Daytona: resolved cwd to %s", self.cwd) + + def _ensure_sandbox_ready(self): + """Restart sandbox if it was stopped (e.g., by a previous interrupt).""" + self._sandbox.refresh_data() + if self._sandbox.state in (self._SandboxState.STOPPED, self._SandboxState.ARCHIVED): + self._sandbox.start() + logger.info("Daytona: restarted sandbox %s", self._sandbox.id) + + def _exec_in_thread(self, exec_command: str, cwd: Optional[str], timeout: int) -> dict: + """Run exec in a background thread with interrupt polling. + + The Daytona SDK's exec(timeout=...) parameter is unreliable (the + server-side timeout is not enforced and the SDK has no client-side + fallback), so we wrap the command with the shell ``timeout`` utility + which reliably kills the process and returns exit code 124. + """ + # Wrap with shell `timeout` to enforce the deadline reliably. + # Add a small buffer so the shell timeout fires before any SDK-level + # timeout would, giving us a clean exit code 124. + timed_command = f"timeout {timeout} sh -c {shlex.quote(exec_command)}" + + result_holder: dict = {"value": None, "error": None} + + def _run(): + try: + response = self._sandbox.process.exec( + timed_command, cwd=cwd, + ) + result_holder["value"] = { + "output": response.result or "", + "returncode": response.exit_code, + } + except Exception as e: + result_holder["error"] = e + + t = threading.Thread(target=_run, daemon=True) + t.start() + # Wait for timeout + generous buffer for network/SDK overhead + deadline = timeout + 10 + while t.is_alive(): + t.join(timeout=0.2) + deadline -= 0.2 + if is_interrupted(): + with self._lock: + try: + self._sandbox.stop() + except Exception: + pass + return { + "output": "[Command interrupted - Daytona sandbox stopped]", + "returncode": 130, + } + if deadline <= 0: + # Shell timeout didn't fire and SDK is hung — force stop + with self._lock: + try: + self._sandbox.stop() + except Exception: + pass + return self._timeout_result(timeout) + + if result_holder["error"]: + return {"error": result_holder["error"]} + return result_holder["value"] + + def execute(self, command: str, cwd: str = "", *, + timeout: Optional[int] = None, + stdin_data: Optional[str] = None) -> dict: + with self._lock: + self._ensure_sandbox_ready() + + if stdin_data is not None: + marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}" + while marker in stdin_data: + marker = f"HERMES_EOF_{uuid.uuid4().hex[:8]}" + command = f"{command} << '{marker}'\n{stdin_data}\n{marker}" + + exec_command = self._prepare_command(command) + effective_cwd = cwd or self.cwd or None + effective_timeout = timeout or self.timeout + + result = self._exec_in_thread(exec_command, effective_cwd, effective_timeout) + + if "error" in result: + from daytona import DaytonaError + err = result["error"] + if isinstance(err, DaytonaError): + with self._lock: + try: + self._ensure_sandbox_ready() + except Exception: + return {"output": f"Daytona execution error: {err}", "returncode": 1} + result = self._exec_in_thread(exec_command, effective_cwd, effective_timeout) + if "error" not in result: + return result + return {"output": f"Daytona execution error: {err}", "returncode": 1} + + return result + + def cleanup(self): + with self._lock: + if self._sandbox is None: + return + try: + if self._persistent: + self._sandbox.stop() + logger.info("Daytona: stopped sandbox %s (filesystem preserved)", + self._sandbox.id) + else: + self._daytona.delete(self._sandbox) + logger.info("Daytona: deleted sandbox %s", self._sandbox.id) + except Exception as e: + logger.warning("Daytona: cleanup failed: %s", e) + self._sandbox = None diff --git a/tools/file_tools.py b/tools/file_tools.py index a864f4392..b29d2d274 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -75,6 +75,8 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: image = overrides.get("singularity_image") or config["singularity_image"] elif env_type == "modal": image = overrides.get("modal_image") or config["modal_image"] + elif env_type == "daytona": + image = overrides.get("daytona_image") or config["daytona_image"] else: image = "" @@ -82,7 +84,7 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: logger.info("Creating new %s environment for task %s...", env_type, task_id[:8]) container_config = None - if env_type in ("docker", "singularity", "modal"): + if env_type in ("docker", "singularity", "modal", "daytona"): container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 096ac207f..e123262c5 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -423,7 +423,7 @@ def _get_env_config() -> Dict[str, Any]: # catches the case where cli.py (or .env) leaked the host's CWD. # SSH is excluded since /home/ paths are valid on remote machines. cwd = os.getenv("TERMINAL_CWD", default_cwd) - if env_type in ("modal", "docker", "singularity") and cwd: + if env_type in ("modal", "docker", "singularity", "daytona") and cwd: host_prefixes = ("/Users/", "C:\\", "C:/") if any(cwd.startswith(p) for p in host_prefixes) and cwd != default_cwd: logger.info("Ignoring TERMINAL_CWD=%r for %s backend " @@ -436,6 +436,7 @@ def _get_env_config() -> Dict[str, Any]: "docker_image": os.getenv("TERMINAL_DOCKER_IMAGE", default_image), "singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"), "modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image), + "daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image), "cwd": cwd, "timeout": int(os.getenv("TERMINAL_TIMEOUT", "180")), "lifetime_seconds": int(os.getenv("TERMINAL_LIFETIME_SECONDS", "300")), @@ -444,7 +445,7 @@ def _get_env_config() -> Dict[str, Any]: "ssh_user": os.getenv("TERMINAL_SSH_USER", ""), "ssh_port": int(os.getenv("TERMINAL_SSH_PORT", "22")), "ssh_key": os.getenv("TERMINAL_SSH_KEY", ""), - # Container resource config (applies to docker, singularity, modal -- ignored for local/ssh) + # Container resource config (applies to docker, singularity, modal, daytona -- ignored for local/ssh) "container_cpu": float(os.getenv("TERMINAL_CONTAINER_CPU", "1")), "container_memory": int(os.getenv("TERMINAL_CONTAINER_MEMORY", "5120")), # MB (default 5GB) "container_disk": int(os.getenv("TERMINAL_CONTAINER_DISK", "51200")), # MB (default 50GB) @@ -460,7 +461,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, Create an execution environment from mini-swe-agent. Args: - env_type: One of "local", "docker", "singularity", "modal", "ssh" + env_type: One of "local", "docker", "singularity", "modal", "daytona", "ssh" image: Docker/Singularity/Modal image name (ignored for local/ssh) cwd: Working directory timeout: Default command timeout @@ -511,6 +512,15 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, persistent_filesystem=persistent, task_id=task_id, ) + elif env_type == "daytona": + # Lazy import so daytona SDK is only required when backend is selected. + from tools.environments.daytona import DaytonaEnvironment as _DaytonaEnvironment + return _DaytonaEnvironment( + image=image, cwd=cwd, timeout=timeout, + cpu=int(cpu), memory=memory, disk=disk, + persistent_filesystem=persistent, task_id=task_id, + ) + elif env_type == "ssh": if not ssh_config or not ssh_config.get("host") or not ssh_config.get("user"): raise ValueError("SSH environment requires ssh_host and ssh_user to be configured") @@ -522,9 +532,9 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, cwd=cwd, timeout=timeout, ) - + else: - raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', 'singularity', 'modal', or 'ssh'") + raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', 'singularity', 'modal', 'daytona', or 'ssh'") def _cleanup_inactive_envs(lifetime_seconds: int = 300): @@ -799,9 +809,11 @@ def terminal_tool( image = overrides.get("singularity_image") or config["singularity_image"] elif env_type == "modal": image = overrides.get("modal_image") or config["modal_image"] + elif env_type == "daytona": + image = overrides.get("daytona_image") or config["daytona_image"] else: image = "" - + cwd = overrides.get("cwd") or config["cwd"] default_timeout = config["timeout"] effective_timeout = timeout or default_timeout @@ -851,7 +863,7 @@ def terminal_tool( } container_config = None - if env_type in ("docker", "singularity", "modal"): + if env_type in ("docker", "singularity", "modal", "daytona"): container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), @@ -1090,6 +1102,9 @@ def check_terminal_requirements() -> bool: from minisweagent.environments.extra.swerex_modal import SwerexModalEnvironment # Check for modal token return os.getenv("MODAL_TOKEN_ID") is not None or Path.home().joinpath(".modal.toml").exists() + elif env_type == "daytona": + from daytona import Daytona + return os.getenv("DAYTONA_API_KEY") is not None else: return False except Exception as e: @@ -1128,10 +1143,11 @@ if __name__ == "__main__": print("\nEnvironment Variables:") default_img = "nikolaik/python-nodejs:python3.11-nodejs20" - print(f" TERMINAL_ENV: {os.getenv('TERMINAL_ENV', 'local')} (local/docker/singularity/modal/ssh)") + print(f" TERMINAL_ENV: {os.getenv('TERMINAL_ENV', 'local')} (local/docker/singularity/modal/daytona/ssh)") print(f" TERMINAL_DOCKER_IMAGE: {os.getenv('TERMINAL_DOCKER_IMAGE', default_img)}") print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}") print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}") + print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}") print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', os.getcwd())}") print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', '~/.hermes/sandboxes')}") print(f" TERMINAL_TIMEOUT: {os.getenv('TERMINAL_TIMEOUT', '60')}") diff --git a/website/docs/developer-guide/architecture.md b/website/docs/developer-guide/architecture.md index b8ae1fc14..ef5bd9d63 100644 --- a/website/docs/developer-guide/architecture.md +++ b/website/docs/developer-guide/architecture.md @@ -55,7 +55,7 @@ hermes-agent/ │ ├── skill_manager_tool.py # Skill management │ └── environments/ # Terminal execution backends │ ├── base.py # BaseEnvironment ABC -│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py +│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py │ ├── gateway/ # Messaging gateway │ ├── run.py # GatewayRunner — platform lifecycle, message routing diff --git a/website/docs/index.md b/website/docs/index.md index 7a5a3ad3d..6849e3ad3 100644 --- a/website/docs/index.md +++ b/website/docs/index.md @@ -42,7 +42,7 @@ It's not a coding copilot tethered to an IDE or a chatbot wrapper around a singl - **Grows the longer it runs** — Persistent memory and self-created skills - **Scheduled automations** — Built-in cron with delivery to any platform - **Delegates & parallelizes** — Spawn isolated subagents for parallel workstreams -- **Real sandboxing** — 5 terminal backends: local, Docker, SSH, Singularity, Modal +- **Real sandboxing** — 6 terminal backends: local, Docker, SSH, Singularity, Modal, Daytona - **Full web control** — Search, extract, browse, vision, image generation, TTS - **MCP support** — Connect to any MCP server for extended tool capabilities - **Research-ready** — Batch processing, trajectory export, RL training integration diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 7273d57d2..022380fa7 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -49,7 +49,7 @@ All variables go in `~/.hermes/.env`. You can also set them with `hermes config | Variable | Description | |----------|-------------| -| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal` | +| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona` | | `TERMINAL_DOCKER_IMAGE` | Docker image (default: `python:3.11`) | | `TERMINAL_DOCKER_VOLUMES` | Additional Docker volume mounts (comma-separated `host:container` pairs) | | `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path | diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 6c38a3e81..fffe620ca 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -133,7 +133,7 @@ Configure which environment the agent uses for terminal commands: ```yaml terminal: - backend: local # or: docker, ssh, singularity, modal + backend: local # or: docker, ssh, singularity, modal, daytona cwd: "." # Working directory ("." = current dir) timeout: 180 # Command timeout in seconds ``` diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index 5ad1d8d8f..1f1036fb5 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -62,7 +62,7 @@ The terminal tool can execute commands in different environments: ```yaml # In ~/.hermes/config.yaml terminal: - backend: local # or: docker, ssh, singularity, modal + backend: local # or: docker, ssh, singularity, modal, daytona cwd: "." # Working directory timeout: 180 # Command timeout in seconds ```