feat(koyeb): add Koyeb backend support for cloud execution and environment management

This commit is contained in:
Fabzerito 2026-04-22 23:00:03 +02:00
parent 57e33cf284
commit abd5eacb6e
11 changed files with 479 additions and 64 deletions

View file

@ -32,7 +32,7 @@ hermes-agent/
├── agent/ # Agent internals (provider adapters, memory, caching, compression, etc.)
├── hermes_cli/ # CLI subcommands, setup wizard, plugins loader, skin engine
├── tools/ # Tool implementations — auto-discovered via tools/registry.py
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity)
│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity, koyeb)
├── gateway/ # Messaging gateway — run.py + session.py + platforms/
│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
│ │ # homeassistant, signal, matrix, mattermost, email, sms,

View file

@ -157,7 +157,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, daytona.py
│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py, koyeb.py
├── gateway/ # Messaging gateway
│ ├── run.py # GatewayRunner — platform lifecycle, message routing, cron

View file

@ -21,7 +21,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
<tr><td><b>A closed learning loop</b></td><td>Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. <a href="https://github.com/plastic-labs/honcho">Honcho</a> dialectic user modeling. Compatible with the <a href="https://agentskills.io">agentskills.io</a> open standard.</td></tr>
<tr><td><b>Scheduled automations</b></td><td>Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended.</td></tr>
<tr><td><b>Delegates and parallelizes</b></td><td>Spawn isolated subagents for parallel workstreams. Write Python scripts that call tools via RPC, collapsing multi-step pipelines into zero-context-cost turns.</td></tr>
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Six terminal backends — local, Docker, SSH, Daytona, Singularity, and Modal. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster.</td></tr>
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Seven terminal backends — local, Docker, SSH, Daytona, Singularity, Modal, and Koyeb. Daytona, Modal, and Koyeb offer serverless cloud sandboxes — your agent's environment spins up on demand and is deleted when done. Run it on a $5 VPS or a GPU cluster.</td></tr>
<tr><td><b>Research-ready</b></td><td>Batch trajectory generation, Atropos RL environments, trajectory compression for training the next generation of tool-calling models.</td></tr>
</table>

View file

@ -437,7 +437,8 @@ DEFAULT_CONFIG = {
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
# Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)
"koyeb_image": "koyeb/sandbox:latest",
# Container resource limits (docker, singularity, modal, daytona, koyeb — ignored for local/ssh)
"container_cpu": 1,
"container_memory": 5120, # MB (default 5GB)
"container_disk": 51200, # MB (default 50GB)
@ -3694,6 +3695,10 @@ def show_config():
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') == 'koyeb':
print(f" Koyeb image: {terminal.get('koyeb_image', 'koyeb/sandbox:latest')}")
koyeb_token = get_env_value('KOYEB_API_TOKEN')
print(f" API token: {'configured' if koyeb_token else '(not set)'}")
elif terminal.get('backend') == 'ssh':
ssh_host = get_env_value('TERMINAL_SSH_HOST')
ssh_user = get_env_value('TERMINAL_SSH_USER')
@ -3886,6 +3891,7 @@ def set_config_value(key: str, value: str):
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
"terminal.koyeb_image": "TERMINAL_KOYEB_IMAGE",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.cwd": "TERMINAL_CWD",
"terminal.timeout": "TERMINAL_TIMEOUT",

View file

@ -777,6 +777,21 @@ def run_doctor(args):
check_fail("daytona SDK not installed", "(pip install daytona)")
issues.append("Install daytona SDK: pip install daytona")
# Koyeb (if using koyeb backend)
if terminal_env == "koyeb":
koyeb_token = os.getenv("KOYEB_API_TOKEN")
if koyeb_token:
check_ok("Koyeb API token", "(configured)")
else:
check_fail("KOYEB_API_TOKEN not set", "(required for TERMINAL_ENV=koyeb)")
issues.append("Set KOYEB_API_TOKEN environment variable")
try:
from koyeb import Sandbox # noqa: F401 — SDK presence check
check_ok("koyeb SDK", "(installed)")
except ImportError:
check_fail("koyeb SDK not installed", "(pip install koyeb-sdk)")
issues.append("Install koyeb SDK: pip install koyeb-sdk")
# Node.js + agent-browser (for browser automation tools)
if shutil.which("node"):
check_ok("Node.js")

View file

@ -1182,11 +1182,12 @@ def setup_terminal_backend(config: dict):
"Modal - serverless cloud sandbox",
"SSH - run on a remote machine",
"Daytona - persistent cloud development environment",
"Koyeb - cloud sandbox execution",
]
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"}
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4}
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "koyeb"}
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "koyeb": 5}
next_idx = 5
next_idx = 6
if is_linux:
terminal_choices.append("Singularity/Apptainer - HPC-friendly container")
idx_to_backend[next_idx] = "singularity"
@ -1441,6 +1442,64 @@ def setup_terminal_backend(config: dict):
_prompt_container_resources(config)
elif selected_backend == "koyeb":
print_success("Terminal backend: Koyeb")
print_info("Cloud sandbox execution via Koyeb.")
print_info("Sign up at: https://www.koyeb.com")
# Check if koyeb SDK is installed
try:
__import__("koyeb")
except ImportError:
print_info("Installing koyeb SDK...")
import subprocess
uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, "koyeb-sdk"],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "koyeb-sdk"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print_success("koyeb SDK installed")
else:
print_warning("Install failed — run manually: pip install koyeb-sdk")
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")
# Koyeb API token
print()
existing_key = get_env_value("KOYEB_API_TOKEN")
if existing_key:
print_info(" Koyeb API token: already configured")
if prompt_yes_no(" Update API token?", False):
api_key = prompt(" Koyeb API token", password=True)
if api_key:
save_env_value("KOYEB_API_TOKEN", api_key)
print_success(" Updated")
else:
api_key = prompt(" Koyeb API token", password=True)
if api_key:
save_env_value("KOYEB_API_TOKEN", api_key)
print_success(" Configured")
# Koyeb image
current_image = config.get("terminal", {}).get(
"koyeb_image", "koyeb/sandbox:latest"
)
image = prompt(" Sandbox image", current_image)
config["terminal"]["koyeb_image"] = image
save_env_value("TERMINAL_KOYEB_IMAGE", image)
_prompt_container_resources(config)
elif selected_backend == "ssh":
print_success("Terminal backend: SSH")
print_info("Run commands on a remote machine via SSH.")

View file

@ -39,6 +39,7 @@ dependencies = [
[project.optional-dependencies]
modal = ["modal>=1.0.0,<2"]
daytona = ["daytona>=0.148.0,<1"]
koyeb = ["koyeb-sdk>=1.4.0,<2"]
dev = ["debugpy>=1.8.0,<2", "pytest>=9.0.2,<10", "pytest-asyncio>=1.3.0,<2", "pytest-xdist>=3.0,<4", "mcp>=1.2.0,<2", "ty>=0.0.1a29,<0.0.22", "ruff"]
messaging = ["python-telegram-bot[webhooks]>=22.6,<23", "discord.py[voice]>=2.7.1,<3", "aiohttp>=3.13.3,<4", "slack-bolt>=1.18.0,<2", "slack-sdk>=3.27.0,<4", "qrcode>=7.0,<8"]
cron = ["croniter>=6.0.0,<7"]

View file

@ -0,0 +1,266 @@
"""Unit tests for the Koyeb cloud sandbox environment backend."""
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers to build mock Koyeb SDK objects
# ---------------------------------------------------------------------------
def _make_exec_response(stdout="", stderr="", exit_code=0):
return SimpleNamespace(stdout=stdout, stderr=stderr, exit_code=exit_code)
def _make_sandbox(sandbox_id="sb-koyeb-123"):
sb = MagicMock()
sb.id = sandbox_id
sb.exec.return_value = _make_exec_response()
sb.filesystem = MagicMock()
return sb
def _patch_koyeb_imports(monkeypatch):
"""Patch the koyeb SDK so KoyebEnvironment can be imported without it."""
import types as _types
koyeb_mod = _types.ModuleType("koyeb")
koyeb_mod.Sandbox = MagicMock()
monkeypatch.setitem(__import__("sys").modules, "koyeb", koyeb_mod)
return koyeb_mod
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def koyeb_sdk(monkeypatch):
"""Provide a mock koyeb SDK module and return it for assertions."""
return _patch_koyeb_imports(monkeypatch)
@pytest.fixture()
def make_env(koyeb_sdk, monkeypatch):
"""Factory that creates a KoyebEnvironment with a mocked SDK."""
monkeypatch.setattr("tools.environments.base.is_interrupted", lambda: False)
monkeypatch.setattr("tools.credential_files.get_credential_file_mounts", lambda: [])
monkeypatch.setattr("tools.credential_files.get_skills_directory_mount", lambda **kw: None)
monkeypatch.setattr("tools.credential_files.iter_skills_files", lambda **kw: [])
def _factory(
sandbox=None,
home_dir="/root",
**kwargs,
):
sandbox = sandbox or _make_sandbox()
# Mock the $HOME detection
sandbox.exec.return_value = _make_exec_response(stdout=home_dir)
koyeb_sdk.Sandbox.create.return_value = sandbox
from tools.environments.koyeb import KoyebEnvironment
kwargs.setdefault("task_id", "test-task")
env = KoyebEnvironment(
image="koyeb/sandbox:latest",
**kwargs,
)
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.exec.side_effect = RuntimeError("exec failed")
env = make_env(sandbox=sb)
assert env.cwd == "/root" # keeps constructor default
def test_empty_home_keeps_default_cwd(self, make_env):
env = make_env(home_dir="")
assert env.cwd == "/root"
# ---------------------------------------------------------------------------
# Sandbox name sanitization
# ---------------------------------------------------------------------------
class TestSandboxNameSanitization:
def test_underscores_replaced_with_hyphens(self, make_env, koyeb_sdk):
make_env(task_id="my_test_task")
name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
assert "_" not in name_arg
assert name_arg == "hermes-my-test-task"
def test_uppercase_lowered(self, make_env, koyeb_sdk):
make_env(task_id="MyTask")
name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
assert name_arg == "hermes-mytask"
def test_special_chars_removed(self, make_env, koyeb_sdk):
make_env(task_id="task@#$123")
name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
assert name_arg == "hermes-task-123"
def test_name_truncated_to_63_chars(self, make_env, koyeb_sdk):
make_env(task_id="a" * 100)
name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
assert len(name_arg) <= 63
def test_consecutive_hyphens_collapsed(self, make_env, koyeb_sdk):
make_env(task_id="a__b---c")
name_arg = koyeb_sdk.Sandbox.create.call_args[1]["name"]
assert "--" not in name_arg
# ---------------------------------------------------------------------------
# Cleanup
# ---------------------------------------------------------------------------
class TestCleanup:
def test_cleanup_deletes_sandbox(self, make_env):
env = make_env()
sb = env._sandbox
env.cleanup()
sb.delete.assert_called_once()
def test_cleanup_idempotent(self, make_env):
env = make_env()
env.cleanup()
env.cleanup() # should not raise
def test_cleanup_swallows_errors(self, make_env):
env = make_env()
env._sandbox.delete.side_effect = RuntimeError("delete failed")
env.cleanup() # should not raise
assert env._sandbox is None
def test_cleanup_calls_sync_back_before_delete(self, make_env):
env = make_env()
call_order = []
sync_mgr = MagicMock()
sync_mgr.sync_back = lambda: call_order.append("sync_back")
env._sync_manager = sync_mgr
original_delete = env._sandbox.delete
env._sandbox.delete = lambda: (call_order.append("delete"), original_delete())
env.cleanup()
assert "sync_back" in call_order
assert "delete" in call_order
assert call_order.index("sync_back") < call_order.index("delete")
# ---------------------------------------------------------------------------
# Execute
# ---------------------------------------------------------------------------
class TestExecute:
def test_basic_command(self, make_env):
sb = _make_sandbox()
# Calls: (1) $HOME detection, (2) init_session bootstrap, (3) actual command
sb.exec.side_effect = [
_make_exec_response(stdout="/root"), # $HOME
_make_exec_response(stdout="", exit_code=0), # init_session
_make_exec_response(stdout="hello", exit_code=0), # actual cmd
]
env = make_env(sandbox=sb)
result = env.execute("echo hello")
assert "hello" in result["output"]
assert result["returncode"] == 0
def test_nonzero_exit_code(self, make_env):
sb = _make_sandbox()
sb.exec.side_effect = [
_make_exec_response(stdout="/root"),
_make_exec_response(stdout="", exit_code=0), # init_session
_make_exec_response(stdout="not found", exit_code=127),
]
env = make_env(sandbox=sb)
result = env.execute("bad_cmd")
assert result["returncode"] == 127
def test_stderr_included_in_output(self, make_env):
sb = _make_sandbox()
sb.exec.side_effect = [
_make_exec_response(stdout="/root"),
_make_exec_response(stdout="", exit_code=0), # init_session
_make_exec_response(stdout="out", stderr="err", exit_code=0),
]
env = make_env(sandbox=sb)
result = env.execute("cmd")
assert "out" in result["output"]
assert "err" in result["output"]
def test_stdin_data_wraps_heredoc(self, make_env):
sb = _make_sandbox()
sb.exec.side_effect = [
_make_exec_response(stdout="/root"),
_make_exec_response(stdout="", exit_code=0), # init_session
_make_exec_response(stdout="ok", exit_code=0),
]
env = make_env(sandbox=sb)
env.execute("python3", stdin_data="print('hi')")
call_args = sb.exec.call_args_list[-1]
cmd = call_args[0][0]
assert "HERMES_STDIN_" in cmd
assert "print" in cmd
# ---------------------------------------------------------------------------
# Interrupt
# ---------------------------------------------------------------------------
class TestInterrupt:
def test_interrupt_kills_and_returns_130(self, make_env, monkeypatch):
sb = _make_sandbox()
event = threading.Event()
calls = {"n": 0}
def exec_side_effect(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
return _make_exec_response(stdout="/root") # $HOME
if calls["n"] == 2:
return _make_exec_response(stdout="", exit_code=0) # init_session
event.wait(timeout=5) # simulate long-running command
return _make_exec_response(stdout="done", exit_code=0)
sb.exec.side_effect = exec_side_effect
env = make_env(sandbox=sb)
monkeypatch.setattr(
"tools.environments.base.is_interrupted", lambda: True
)
try:
result = env.execute("sleep 10")
assert result["returncode"] == 130
sb.delete.assert_called() # cancel_fn calls sandbox.delete()
finally:
event.set()

View file

@ -10,6 +10,7 @@ import pytest
from tools.environments import ssh as ssh_env
from tools.environments import modal as modal_env
from tools.environments import daytona as daytona_env
from tools.environments import koyeb as koyeb_env
from tools.environments.ssh import SSHEnvironment
@ -95,6 +96,20 @@ def _make_mock_daytona_env():
return env
# ── Koyeb helpers ────────────────────────────────────────────────────
def _make_mock_koyeb_env():
"""Create a minimal KoyebEnvironment without calling __init__."""
env = object.__new__(koyeb_env.KoyebEnvironment)
env._sandbox = MagicMock()
env._remote_home = "/root"
env._sync_manager = None
env._lock = __import__("threading").Lock()
env._task_id = "test"
return env
# =====================================================================
# SSH bulk download
# =====================================================================
@ -402,6 +417,69 @@ class TestDaytonaCleanup:
assert call_order.index("sync_back") < call_order.index("stop")
# =====================================================================
# Koyeb bulk download + cleanup
# =====================================================================
class TestKoyebBulkDownload:
"""Unit tests for _koyeb_bulk_download."""
def test_koyeb_bulk_download_creates_tar_and_downloads(self, tmp_path):
"""exec and download_file should both be called."""
env = _make_mock_koyeb_env()
dest = tmp_path / "backup.tar"
env._koyeb_bulk_download(dest)
# exec called twice: tar creation + rm cleanup
assert env._sandbox.exec.call_count == 2
tar_cmd = env._sandbox.exec.call_args_list[0][0][0]
assert "tar cf" in tar_cmd
assert "/tmp/.hermes_sync." in tar_cmd
assert ".tar" in tar_cmd
assert ".hermes" in tar_cmd
cleanup_cmd = env._sandbox.exec.call_args_list[1][0][0]
assert "rm -f" in cleanup_cmd
env._sandbox.filesystem.download_file.assert_called_once()
download_args = env._sandbox.filesystem.download_file.call_args[0]
assert download_args[0].startswith("/tmp/.hermes_sync.")
assert download_args[1] == str(dest)
def test_koyeb_bulk_download_uses_remote_home(self, tmp_path):
"""The tar command should use the env's _remote_home."""
env = _make_mock_koyeb_env()
env._remote_home = "/home/koyeb"
dest = tmp_path / "backup.tar"
env._koyeb_bulk_download(dest)
tar_cmd = env._sandbox.exec.call_args_list[0][0][0]
assert "home/koyeb/.hermes" in tar_cmd
class TestKoyebCleanup:
"""Verify Koyeb cleanup() calls sync_back() before delete."""
def test_koyeb_cleanup_calls_sync_back(self):
"""cleanup() should call sync_back() before sandbox.delete()."""
env = _make_mock_koyeb_env()
call_order = []
sync_mgr = MagicMock()
sync_mgr.sync_back = lambda: call_order.append("sync_back")
env._sync_manager = sync_mgr
env._sandbox.delete = lambda: call_order.append("delete")
env.cleanup()
assert "sync_back" in call_order
assert "delete" in call_order
assert call_order.index("sync_back") < call_order.index("delete")
# =====================================================================
# FileSyncManager wiring: bulk_download_fn passed by each backend
# =====================================================================

View file

@ -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, Modal, or Daytona.
SSH, Modal, Daytona, or Koyeb.
The terminal_tool.py factory (_create_environment) selects the backend
based on the TERMINAL_ENV configuration.

View file

@ -1,13 +1,13 @@
"""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.
Each task gets its own sandbox which is deleted on cleanup.
"""
import logging
import math
import os
import re
import shlex
import threading
from pathlib import Path
@ -56,7 +56,6 @@ class KoyebEnvironment(BaseEnvironment):
from koyeb import Sandbox
self._persistent = persistent_filesystem
self._task_id = task_id
self._sandbox = None
self._lock = threading.Lock()
@ -71,43 +70,27 @@ class KoyebEnvironment(BaseEnvironment):
# 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
# Koyeb app names must be lowercase alphanumeric + hyphens only.
# Sanitize task_id: replace underscores/invalid chars with hyphens,
# collapse runs, strip leading/trailing hyphens, and truncate.
safe_id = re.sub(r"[^a-z0-9-]", "-", task_id.lower())
safe_id = re.sub(r"-{2,}", "-", safe_id).strip("-")
sandbox_name = f"hermes-{safe_id}"[:63] # Koyeb name max length
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,
)
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"
@ -135,20 +118,33 @@ class KoyebEnvironment(BaseEnvironment):
"""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)
self._sandbox.filesystem.upload_file(host_path, remote_path, encoding="base64")
def _koyeb_bulk_upload(self, files: list[tuple[str, str]]) -> None:
"""Upload many files via Koyeb SDK."""
"""Upload many files as a single tar archive to avoid per-file HTTP overhead."""
if not files:
return
parents = unique_parent_dirs(files)
if parents:
self._sandbox.exec(quoted_mkdir_command(parents))
import tarfile
import tempfile
# 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)
with tempfile.NamedTemporaryFile(suffix=".tar", delete=False) as tmp:
tmp_path = tmp.name
try:
with tarfile.open(tmp_path, "w") as tar:
for host_path, remote_path in files:
# Store with absolute remote path inside the tar
tar.add(host_path, arcname=remote_path)
remote_tar = f"/tmp/.hermes_upload.{os.getpid()}.tar"
self._sandbox.filesystem.upload_file(tmp_path, remote_tar, encoding="base64")
self._sandbox.exec(f"tar xf {shlex.quote(remote_tar)} -C / && rm -f {shlex.quote(remote_tar)}")
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
def _koyeb_bulk_download(self, dest: Path) -> None:
"""Download remote .hermes/ as a tar archive."""
@ -228,14 +224,8 @@ class KoyebEnvironment(BaseEnvironment):
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)
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