diff --git a/cli-config.yaml.example b/cli-config.yaml.example index bd63901e12..dba9a0d1d8 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -226,8 +226,23 @@ terminal: # daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # container_disk: 10240 # Daytona max is 10GB per sandbox +# ----------------------------------------------------------------------------- +# OPTION 7: Koyeb cloud execution +# Commands run in Koyeb cloud sandboxes +# Great for: Serverless execution, global deployment, GPU access +# Requires: pip install koyeb-sdk, KOYEB_API_TOKEN env var +# ----------------------------------------------------------------------------- +# terminal: +# backend: "koyeb" +# cwd: "/root" +# timeout: 180 +# lifetime_seconds: 300 +# koyeb_image: "nikolaik/python-nodejs:python3.11-nodejs20" +# koyeb_instance_type: "micro" # Options: micro, small, medium, large, xlarge, 2xlarge +# koyeb_region: "na" # Options: na, eu, fr, etc. + # -# --- Container resource limits (docker, singularity, modal, daytona -- ignored for local/ssh) --- +# --- Container resource limits (docker, singularity, modal, daytona, koyeb -- 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/tests/integration/test_koyeb_terminal.py b/tests/integration/test_koyeb_terminal.py new file mode 100644 index 0000000000..91724b1f26 --- /dev/null +++ b/tests/integration/test_koyeb_terminal.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +Test Koyeb Terminal Tool + +This script tests that the Koyeb terminal backend is correctly configured +and can execute commands in Koyeb sandboxes. + +Usage: + # Run with Koyeb backend + TERMINAL_ENV=koyeb python tests/test_koyeb_terminal.py + + # Or run directly (will use whatever TERMINAL_ENV is set in .env) + python tests/test_koyeb_terminal.py +""" + +import pytest +pytestmark = pytest.mark.integration + +import os +import sys +import json +from pathlib import Path + +# Try to load .env file if python-dotenv is available +try: + from dotenv import load_dotenv + load_dotenv() +except ImportError: + # Manually load .env if dotenv not available + env_file = Path(__file__).parent.parent.parent / ".env" + if env_file.exists(): + with open(env_file) as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + # Remove quotes if present + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key.strip(), value) + +# Add project root to path for imports +parent_dir = Path(__file__).parent.parent.parent +sys.path.insert(0, str(parent_dir)) + +# Import terminal_tool module directly using importlib to avoid tools/__init__.py +import importlib.util +terminal_tool_path = parent_dir / "tools" / "terminal_tool.py" +spec = importlib.util.spec_from_file_location("terminal_tool", terminal_tool_path) +terminal_module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(terminal_module) + +terminal_tool = terminal_module.terminal_tool +check_terminal_requirements = terminal_module.check_terminal_requirements +_get_env_config = terminal_module._get_env_config +cleanup_vm = terminal_module.cleanup_vm + + +def test_koyeb_requirements(): + """Test that Koyeb requirements are met.""" + print("\n" + "=" * 60) + print("TEST 1: Koyeb Requirements Check") + print("=" * 60) + + config = _get_env_config() + print(f"Current TERMINAL_ENV: {config['env_type']}") + print(f"Koyeb image: {config['koyeb_image']}") + + # Check for Koyeb authentication + koyeb_token = os.getenv("KOYEB_API_TOKEN") + + print(f"\nKoyeb authentication:") + print(f" KOYEB_API_TOKEN env var: {'✅ Set' if koyeb_token else '❌ Not set'}") + + if config['env_type'] != 'koyeb': + print(f"\n⚠️ TERMINAL_ENV is '{config['env_type']}', not 'koyeb'") + print(" Set TERMINAL_ENV=koyeb in .env or export it to test Koyeb backend") + return False + + requirements_met = check_terminal_requirements() + print(f"\nRequirements check: {'✅ Passed' if requirements_met else '❌ Failed'}") + + return requirements_met + + +def test_simple_command(): + """Test executing a simple command.""" + print("\n" + "=" * 60) + print("TEST 2: Simple Command Execution") + print("=" * 60) + + test_task_id = "koyeb_test_simple" + + print("Executing: echo 'Hello from Koyeb!'") + result = terminal_tool("echo 'Hello from Koyeb!'", task_id=test_task_id) + result_json = json.loads(result) + + print(f"\nResult:") + print(f" Output: {result_json.get('output', '')[:200]}") + print(f" Exit code: {result_json.get('exit_code')}") + print(f" Error: {result_json.get('error')}") + + success = result_json.get('exit_code') == 0 and 'Hello from Koyeb!' in result_json.get('output', '') + print(f"\nTest: {'✅ Passed' if success else '❌ Failed'}") + + # Cleanup + cleanup_vm(test_task_id) + + return success + + +def test_python_execution(): + """Test executing Python code in Koyeb.""" + print("\n" + "=" * 60) + print("TEST 3: Python Execution") + print("=" * 60) + + test_task_id = "koyeb_test_python" + + python_cmd = 'python3 -c "import sys; print(f\'Python {sys.version}\')"' + print(f"Executing: {python_cmd}") + + result = terminal_tool(python_cmd, task_id=test_task_id) + result_json = json.loads(result) + + print(f"\nResult:") + print(f" Output: {result_json.get('output', '')[:200]}") + print(f" Exit code: {result_json.get('exit_code')}") + print(f" Error: {result_json.get('error')}") + + success = result_json.get('exit_code') == 0 and 'Python' in result_json.get('output', '') + print(f"\nTest: {'✅ Passed' if success else '❌ Failed'}") + + # Cleanup + cleanup_vm(test_task_id) + + return success + + +def test_filesystem_operations(): + """Test filesystem operations in Koyeb.""" + print("\n" + "=" * 60) + print("TEST 4: Filesystem Operations") + print("=" * 60) + + test_task_id = "koyeb_test_fs" + + # Create a file + print("Step 1: Creating test file...") + result1 = terminal_tool("echo 'koyeb filesystem test' > /tmp/koyeb_test.txt", task_id=test_task_id) + result1_json = json.loads(result1) + print(f" Exit code: {result1_json.get('exit_code')}") + + # Read the file back + print("Step 2: Reading test file...") + result2 = terminal_tool("cat /tmp/koyeb_test.txt", task_id=test_task_id) + result2_json = json.loads(result2) + print(f" Output: {result2_json.get('output', '')}") + print(f" Exit code: {result2_json.get('exit_code')}") + + success = ( + result1_json.get('exit_code') == 0 and + result2_json.get('exit_code') == 0 and + 'koyeb filesystem test' in result2_json.get('output', '') + ) + print(f"\nTest: {'✅ Passed' if success else '❌ Failed'}") + + # Cleanup + cleanup_vm(test_task_id) + + return success + + +def test_environment_isolation(): + """Test that different task_ids get isolated environments.""" + print("\n" + "=" * 60) + print("TEST 5: Environment Isolation") + print("=" * 60) + + task1 = "koyeb_test_iso_1" + task2 = "koyeb_test_iso_2" + + # Create file in task1 + print("Step 1: Creating file in task1...") + result1 = terminal_tool("echo 'task1 data' > /tmp/isolated.txt", task_id=task1) + + # Try to read from task2 (should not exist) + print("Step 2: Trying to read file from task2 (should not exist)...") + result2 = terminal_tool("cat /tmp/isolated.txt 2>&1 || echo 'FILE_NOT_FOUND'", task_id=task2) + result2_json = json.loads(result2) + + # The file should either not exist or be empty in task2 + output = result2_json.get('output', '') + isolated = 'task1 data' not in output or 'FILE_NOT_FOUND' in output or 'No such file' in output + + print(f" Task2 output: {output[:200]}") + print(f"\nTest: {'✅ Passed (environments isolated)' if isolated else '❌ Failed (environments NOT isolated)'}") + + # Cleanup + cleanup_vm(task1) + cleanup_vm(task2) + + return isolated + + +def main(): + """Run all Koyeb terminal tests.""" + print("🧪 Koyeb Terminal Tool Test Suite") + print("=" * 60) + + # Check current config + config = _get_env_config() + print(f"\nCurrent configuration:") + print(f" TERMINAL_ENV: {config['env_type']}") + print(f" TERMINAL_KOYEB_IMAGE: {config['koyeb_image']}") + print(f" TERMINAL_TIMEOUT: {config['timeout']}s") + + if config['env_type'] != 'koyeb': + print(f"\n⚠️ WARNING: TERMINAL_ENV is set to '{config['env_type']}', not 'koyeb'") + print(" To test Koyeb specifically, set TERMINAL_ENV=koyeb") + response = input("\n Continue testing with current backend? (y/n): ") + if response.lower() != 'y': + print("Aborting.") + return + + results = {} + + # Run tests + results['requirements'] = test_koyeb_requirements() + + if not results['requirements']: + print("\n❌ Requirements not met. Cannot continue with other tests.") + return + + results['simple_command'] = test_simple_command() + results['python_execution'] = test_python_execution() + results['filesystem_operations'] = test_filesystem_operations() + results['environment_isolation'] = test_environment_isolation() + + # Summary + print("\n" + "=" * 60) + print("TEST SUMMARY") + print("=" * 60) + + passed = sum(1 for v in results.values() if v) + total = len(results) + + for test_name, passed_test in results.items(): + status = "✅ PASSED" if passed_test else "❌ FAILED" + print(f" {test_name}: {status}") + + print(f"\nTotal: {passed}/{total} tests passed") + + return passed == total + + +if __name__ == "__main__": + success = main() + sys.exit(0 if success else 1) diff --git a/tools/environments/koyeb.py b/tools/environments/koyeb.py new file mode 100644 index 0000000000..0b012a8f1c --- /dev/null +++ b/tools/environments/koyeb.py @@ -0,0 +1,241 @@ +"""Koyeb cloud execution environment. + +Uses the Koyeb 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 os +import shlex +import threading +from pathlib import Path +from typing import Any + +from tools.environments.base import ( + BaseEnvironment, + _ThreadedProcessHandle, +) +from tools.environments.file_sync import ( + FileSyncManager, + iter_sync_files, + quoted_mkdir_command, + quoted_rm_command, + unique_parent_dirs, +) + +logger = logging.getLogger(__name__) + + +class KoyebEnvironment(BaseEnvironment): + """Koyeb cloud sandbox execution backend. + + Spawn-per-call via _ThreadedProcessHandle wrapping blocking SDK calls. + cancel_fn wired to sandbox.delete() for interrupt support. + Shell timeout wrapper preserved (SDK timeout unreliable). + """ + + _stdin_mode = "heredoc" + + def __init__( + self, + image: str, + cwd: str = "/root", + timeout: int = 60, + cpu: int = 1, + memory: int = 5120, + disk: int = 10240, + persistent_filesystem: bool = True, + task_id: str = "default", + instance_type: str = "micro", + region: str = None, + ): + requested_cwd = cwd + super().__init__(cwd=cwd, timeout=timeout) + + from koyeb import Sandbox + + self._persistent = persistent_filesystem + self._task_id = task_id + self._sandbox = None + self._lock = threading.Lock() + self._instance_type = instance_type + self._region = region or os.getenv("KOYEB_REGION", "na") + self._api_token = os.getenv("KOYEB_API_TOKEN") + + # Convert memory from MB to GB (Koyeb uses GB) + memory_gib = max(1, math.ceil(memory / 1024)) + + # Koyeb instance types: micro, small, medium, large, xlarge, 2xlarge, etc. + # For now, we'll use the instance_type parameter directly + # cpu and memory parameters are kept for compatibility but may be overridden by instance_type + + sandbox_name = f"hermes-{task_id}" + labels = {"hermes_task_id": task_id} + + # Try to reuse existing sandbox if persistent + if self._persistent: + try: + # List existing sandboxes with our label + existing = Sandbox.list(api_token=self._api_token, labels=labels) + if existing: + self._sandbox = existing[0] + logger.info("Koyeb: resumed sandbox %s for task %s", + self._sandbox.id, task_id) + except Exception as e: + logger.debug("Koyeb: could not resume sandbox for task %s: %s", + task_id, e) + self._sandbox = None + + # Create new sandbox if needed + if self._sandbox is None: + try: + self._sandbox = Sandbox.create( + image=image, + name=sandbox_name, + wait_ready=True, + instance_type=self._instance_type, + region=self._region, + api_token=self._api_token, + timeout=300, + idle_timeout=0, # Disable auto-sleep for persistent sandboxes + delete_after_delay=0, + delete_after_inactivity_delay=0, + ) + logger.info("Koyeb: created sandbox %s for task %s", + self._sandbox.id, task_id) + except Exception as e: + logger.error("Koyeb: failed to create sandbox: %s", e) + raise + + # Detect remote home dir + self._remote_home = "/root" + try: + home = self._sandbox.exec("echo $HOME").stdout.strip() + if home: + self._remote_home = home + if requested_cwd in ("~", "/root"): + self.cwd = home + except Exception: + pass + logger.info("Koyeb: resolved home to %s, cwd to %s", self._remote_home, self.cwd) + + self._sync_manager = FileSyncManager( + get_files_fn=lambda: iter_sync_files(f"{self._remote_home}/.hermes"), + upload_fn=self._koyeb_upload, + delete_fn=self._koyeb_delete, + bulk_upload_fn=self._koyeb_bulk_upload, + bulk_download_fn=self._koyeb_bulk_download, + ) + self._sync_manager.sync(force=True) + self.init_session() + + def _koyeb_upload(self, host_path: str, remote_path: str) -> None: + """Upload a single file via Koyeb SDK.""" + parent = str(Path(remote_path).parent) + self._sandbox.exec(f"mkdir -p {shlex.quote(parent)}") + self._sandbox.filesystem.upload_file(host_path, remote_path) + + def _koyeb_bulk_upload(self, files: list[tuple[str, str]]) -> None: + """Upload many files via Koyeb SDK.""" + if not files: + return + + parents = unique_parent_dirs(files) + if parents: + self._sandbox.exec(quoted_mkdir_command(parents)) + + # Upload files one by one (Koyeb SDK doesn't have bulk upload for files) + for host_path, remote_path in files: + self._sandbox.filesystem.upload_file(host_path, remote_path) + + def _koyeb_bulk_download(self, dest: Path) -> None: + """Download remote .hermes/ as a tar archive.""" + rel_base = f"{self._remote_home}/.hermes".lstrip("/") + # PID-suffixed remote temp path avoids collisions if sync_back fires + # concurrently for the same sandbox (e.g. retry after partial failure). + remote_tar = f"/tmp/.hermes_sync.{os.getpid()}.tar" + self._sandbox.exec( + f"tar cf {shlex.quote(remote_tar)} -C / {shlex.quote(rel_base)}" + ) + self._sandbox.filesystem.download_file(remote_tar, str(dest)) + # Clean up remote temp file + try: + self._sandbox.exec(f"rm -f {shlex.quote(remote_tar)}") + except Exception: + pass # best-effort cleanup + + def _koyeb_delete(self, remote_paths: list[str]) -> None: + """Batch-delete remote files via SDK exec.""" + self._sandbox.exec(quoted_rm_command(remote_paths)) + + # ------------------------------------------------------------------ + # Sandbox lifecycle + # ------------------------------------------------------------------ + + def _ensure_sandbox_ready(self) -> None: + """Restart sandbox if it was stopped (e.g., by a previous interrupt).""" + # Koyeb sandboxes don't have a stopped state like Daytona + # They're either running or need to be recreated + pass + + def _before_execute(self) -> None: + """Ensure sandbox is ready, then sync files via FileSyncManager.""" + with self._lock: + self._ensure_sandbox_ready() + self._sync_manager.sync() + + def _run_bash(self, cmd_string: str, *, login: bool = False, + timeout: int = 120, + stdin_data: str | None = None): + """Return a _ThreadedProcessHandle wrapping a blocking Koyeb SDK call.""" + sandbox = self._sandbox + lock = self._lock + + def cancel(): + with lock: + try: + sandbox.delete() + except Exception: + pass + + if login: + shell_cmd = f"bash -l -c {shlex.quote(cmd_string)}" + else: + shell_cmd = f"bash -c {shlex.quote(cmd_string)}" + + def exec_fn() -> tuple[str, int]: + result = sandbox.exec(shell_cmd, timeout=timeout) + output = result.stdout or "" + if result.stderr: + output = f"{output}\n{result.stderr}" if output else result.stderr + return (output, result.exit_code) + + return _ThreadedProcessHandle(exec_fn, cancel_fn=cancel) + + def cleanup(self): + with self._lock: + if self._sandbox is None: + return + + # Sync remote changes back to host before teardown + if self._sync_manager: + logger.info("Koyeb: syncing files from sandbox...") + try: + self._sync_manager.sync_back() + except Exception as e: + logger.warning("Koyeb: sync_back failed: %s", e) + + try: + if self._persistent: + # For persistent sandboxes, we don't delete them + # They'll be reused on next creation + logger.info("Koyeb: keeping sandbox %s (filesystem preserved)", + self._sandbox.id) + else: + self._sandbox.delete() + logger.info("Koyeb: deleted sandbox %s", self._sandbox.id) + except Exception as e: + logger.warning("Koyeb: cleanup failed: %s", e) + self._sandbox = None diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index b288d4ad9b..1f0eccbe6e 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -2,16 +2,17 @@ """ Terminal Tool Module -A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, and Daytona environments. -Supports local execution, containerized backends, and Modal cloud sandboxes, including managed gateway mode. +A terminal tool that executes commands in local, Docker, Modal, SSH, Singularity, Daytona, and Koyeb environments. +Supports local execution, containerized backends, and cloud sandboxes (Modal, Daytona, Koyeb), including managed gateway mode. Environment Selection (via TERMINAL_ENV environment variable): - "local": Execute directly on the host machine (default, fastest) - "docker": Execute in Docker containers (isolated, requires Docker) - "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway) +- "koyeb": Execute in Koyeb cloud sandboxes Features: -- Multiple execution backends (local, docker, modal) +- Multiple execution backends (local, docker, modal, koyeb) - Background task support - VM/container lifecycle management - Automatic cleanup after inactivity @@ -855,7 +856,7 @@ def _get_env_config() -> Dict[str, Any]: ): host_cwd = candidate cwd = "/workspace" - elif env_type in ("modal", "docker", "singularity", "daytona") and cwd: + elif env_type in ("modal", "docker", "singularity", "daytona", "koyeb") and cwd: # Host paths and relative paths that won't work inside containers is_host_path = any(cwd.startswith(p) for p in host_prefixes) is_relative = not os.path.isabs(cwd) # e.g. "." or "src/" @@ -873,6 +874,7 @@ def _get_env_config() -> Dict[str, Any]: "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), + "koyeb_image": os.getenv("TERMINAL_KOYEB_IMAGE", default_image), "cwd": cwd, "host_cwd": host_cwd, "docker_mount_cwd_to_workspace": mount_docker_cwd, @@ -891,12 +893,15 @@ def _get_env_config() -> Dict[str, Any]: 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 resource config (applies to docker, singularity, modal, daytona, koyeb -- ignored for local/ssh) "container_cpu": _parse_env_var("TERMINAL_CONTAINER_CPU", "1", float, "number"), "container_memory": _parse_env_var("TERMINAL_CONTAINER_MEMORY", "5120"), # MB (default 5GB) "container_disk": _parse_env_var("TERMINAL_CONTAINER_DISK", "51200"), # MB (default 50GB) "container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("true", "1", "yes"), "docker_volumes": _parse_env_var("TERMINAL_DOCKER_VOLUMES", "[]", json.loads, "valid JSON"), + # Koyeb-specific config + "koyeb_instance_type": os.getenv("TERMINAL_KOYEB_INSTANCE_TYPE", "micro"), + "koyeb_region": os.getenv("KOYEB_REGION", "na"), } @@ -918,8 +923,8 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, Create an execution environment for sandboxed command execution. Args: - env_type: One of "local", "docker", "singularity", "modal", "daytona", "ssh" - image: Docker/Singularity/Modal image name (ignored for local/ssh) + env_type: One of "local", "docker", "singularity", "modal", "daytona", "koyeb", "ssh" + image: Docker/Singularity/Modal/Koyeb image name (ignored for local/ssh) cwd: Working directory timeout: Default command timeout ssh_config: SSH connection config (for env_type="ssh") @@ -1022,6 +1027,22 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, persistent_filesystem=persistent, task_id=task_id, ) + elif env_type == "koyeb": + # Lazy import so koyeb SDK is only required when backend is selected. + from tools.environments.koyeb import KoyebEnvironment as _KoyebEnvironment + + # Get Koyeb-specific configuration + koyeb_instance_type = cc.get("koyeb_instance_type", "micro") + koyeb_region = cc.get("koyeb_region") + + return _KoyebEnvironment( + image=image, cwd=cwd, timeout=timeout, + cpu=int(cpu), memory=memory, disk=disk, + persistent_filesystem=persistent, task_id=task_id, + instance_type=koyeb_instance_type, + region=koyeb_region, + ) + 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") @@ -1035,7 +1056,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, ) else: - raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', 'singularity', 'modal', 'daytona', or 'ssh'") + raise ValueError(f"Unknown environment type: {env_type}. Use 'local', 'docker', 'singularity', 'modal', 'daytona', 'koyeb', or 'ssh'") def _cleanup_inactive_envs(lifetime_seconds: int = 300): @@ -1462,6 +1483,8 @@ def terminal_tool( image = overrides.get("modal_image") or config["modal_image"] elif env_type == "daytona": image = overrides.get("daytona_image") or config["daytona_image"] + elif env_type == "koyeb": + image = overrides.get("koyeb_image") or config["koyeb_image"] else: image = "" @@ -1538,7 +1561,7 @@ def terminal_tool( } container_config = None - if env_type in ("docker", "singularity", "modal", "daytona"): + if env_type in ("docker", "singularity", "modal", "daytona", "koyeb"): container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), @@ -1948,10 +1971,14 @@ def check_terminal_requirements() -> bool: from daytona import Daytona # noqa: F401 — SDK presence check return os.getenv("DAYTONA_API_KEY") is not None + elif env_type == "koyeb": + from koyeb import Sandbox # noqa: F401 — SDK presence check + return os.getenv("KOYEB_API_TOKEN") is not None + else: logger.error( "Unknown TERMINAL_ENV '%s'. Use one of: local, docker, singularity, " - "modal, daytona, ssh.", + "modal, daytona, koyeb, ssh.", env_type, ) return False @@ -1970,6 +1997,7 @@ if __name__ == "__main__": print(f" Environment type: {config['env_type']}") print(f" Docker image: {config['docker_image']}") print(f" Modal image: {config['modal_image']}") + print(f" Koyeb image: {config['koyeb_image']}") print(f" Working directory: {config['cwd']}") print(f" Default timeout: {config['timeout']}s") print(f" Lifetime: {config['lifetime_seconds']}s") @@ -1991,11 +2019,12 @@ 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/daytona/ssh)") + print(f" TERMINAL_ENV: {os.getenv('TERMINAL_ENV', 'local')} (local/docker/singularity/modal/daytona/koyeb/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_KOYEB_IMAGE: {os.getenv('TERMINAL_KOYEB_IMAGE', default_img)}") print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', os.getcwd())}") from hermes_constants import display_hermes_home as _dhh print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}") diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 42280b4df2..2755d228e1 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -125,6 +125,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `TINKER_API_KEY` | RL training ([tinker-console.thinkingmachines.ai](https://tinker-console.thinkingmachines.ai/)) | | `WANDB_API_KEY` | RL training metrics ([wandb.ai](https://wandb.ai/)) | | `DAYTONA_API_KEY` | Daytona cloud sandboxes ([daytona.io](https://daytona.io/)) | +| `KOYEB_API_TOKEN` | Koyeb cloud sandboxes ([koyeb.com](https://www.koyeb.com/)) | ### Nous Tool Gateway @@ -141,7 +142,7 @@ These variables configure the [Tool Gateway](/docs/user-guide/features/tool-gate | Variable | Description | |----------|-------------| -| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona` | +| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona`, `koyeb` | | `TERMINAL_DOCKER_IMAGE` | Docker image (default: `nikolaik/python-nodejs:python3.11-nodejs20`) | | `TERMINAL_DOCKER_FORWARD_ENV` | JSON array of env var names to explicitly forward into Docker terminal sessions. Note: skill-declared `required_environment_variables` are forwarded automatically — you only need this for vars not declared by any skill. | | `TERMINAL_DOCKER_VOLUMES` | Additional Docker volume mounts (comma-separated `host:container` pairs) | @@ -149,6 +150,7 @@ These variables configure the [Tool Gateway](/docs/user-guide/features/tool-gate | `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path | | `TERMINAL_MODAL_IMAGE` | Modal container image | | `TERMINAL_DAYTONA_IMAGE` | Daytona sandbox image | +| `TERMINAL_KOYEB_IMAGE` | Koyeb sandbox image | | `TERMINAL_TIMEOUT` | Command timeout in seconds | | `TERMINAL_LIFETIME_SECONDS` | Max lifetime for terminal sessions in seconds | | `TERMINAL_CWD` | Working directory for all terminal sessions | @@ -166,7 +168,7 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `TERMINAL_SSH_KEY` | Path to private key | | `TERMINAL_SSH_PERSISTENT` | Override persistent shell for SSH (default: follows `TERMINAL_PERSISTENT_SHELL`) | -## Container Resources (Docker, Singularity, Modal, Daytona) +## Container Resources (Docker, Singularity, Modal, Daytona, Koyeb) | Variable | Description | |----------|-------------| @@ -176,6 +178,13 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `TERMINAL_CONTAINER_PERSISTENT` | Persist container filesystem across sessions (default: `true`) | | `TERMINAL_SANDBOX_DIR` | Host directory for workspaces and overlays (default: `~/.hermes/sandboxes/`) | +## Koyeb Backend + +| Variable | Description | +|----------|-------------| +| `TERMINAL_KOYEB_INSTANCE_TYPE` | Koyeb instance type (default: `micro`). Options: `micro`, `small`, `medium`, `large`, `xlarge`, `2xlarge`, etc. | +| `KOYEB_REGION` | Koyeb region (default: `na`). Options: `na`, `eu`, `fr`, etc. | + ## Persistent Shell | Variable | Description |