2026-02-23 02:11:33 -08:00
|
|
|
"""Modal cloud execution environment wrapping mini-swe-agent's SwerexModalEnvironment.
|
2026-02-21 22:31:43 -08:00
|
|
|
|
2026-02-23 02:11:33 -08:00
|
|
|
Supports persistent filesystem snapshots: when enabled, the sandbox's filesystem
|
|
|
|
|
is snapshotted on cleanup and restored on next creation, so installed packages,
|
|
|
|
|
project files, and config changes survive across sessions.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
2026-02-21 22:31:43 -08:00
|
|
|
import uuid
|
2026-02-23 02:11:33 -08:00
|
|
|
from pathlib import Path
|
|
|
|
|
from typing import Any, Dict, Optional
|
2026-02-21 22:31:43 -08:00
|
|
|
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
from hermes_cli.config import get_hermes_home
|
2026-02-21 22:31:43 -08:00
|
|
|
from tools.environments.base import BaseEnvironment
|
2026-02-23 02:11:33 -08:00
|
|
|
from tools.interrupt import is_interrupted
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
fix(cli): respect HERMES_HOME in all remaining hardcoded ~/.hermes paths
Several files resolved paths via Path.home() / ".hermes" or
os.path.expanduser("~/.hermes/..."), bypassing the HERMES_HOME
environment variable. This broke isolation when running multiple
Hermes instances with distinct HERMES_HOME directories.
Replace all hardcoded paths with calls to get_hermes_home() from
hermes_cli.config, consistent with the rest of the codebase.
Files fixed:
- tools/process_registry.py (processes.json)
- gateway/pairing.py (pairing/)
- gateway/sticker_cache.py (sticker_cache.json)
- gateway/channel_directory.py (channel_directory.json, sessions.json)
- gateway/config.py (gateway.json, config.yaml, sessions_dir)
- gateway/mirror.py (sessions/)
- gateway/hooks.py (hooks/)
- gateway/platforms/base.py (image_cache/, audio_cache/, document_cache/)
- gateway/platforms/whatsapp.py (whatsapp/session)
- gateway/delivery.py (cron/output)
- agent/auxiliary_client.py (auth.json)
- agent/prompt_builder.py (SOUL.md)
- cli.py (config.yaml, images/, pastes/, history)
- run_agent.py (logs/)
- tools/environments/base.py (sandboxes/)
- tools/environments/modal.py (modal_snapshots.json)
- tools/environments/singularity.py (singularity_snapshots.json)
- tools/tts_tool.py (audio_cache)
- hermes_cli/status.py (cron/jobs.json, sessions.json)
- hermes_cli/gateway.py (logs/, whatsapp session)
- hermes_cli/main.py (whatsapp/session)
Tests updated to use HERMES_HOME env var instead of patching Path.home().
Closes #892
(cherry picked from commit 78ac1bba43b8b74a934c6172f2c29bb4d03164b9)
2026-03-11 07:31:41 +01:00
|
|
|
_SNAPSHOT_STORE = get_hermes_home() / "modal_snapshots.json"
|
2026-02-23 02:11:33 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_snapshots() -> Dict[str, str]:
|
|
|
|
|
"""Load snapshot ID mapping from disk."""
|
|
|
|
|
if _SNAPSHOT_STORE.exists():
|
|
|
|
|
try:
|
|
|
|
|
return json.loads(_SNAPSHOT_STORE.read_text())
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _save_snapshots(data: Dict[str, str]) -> None:
|
|
|
|
|
"""Persist snapshot ID mapping to disk."""
|
|
|
|
|
_SNAPSHOT_STORE.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
_SNAPSHOT_STORE.write_text(json.dumps(data, indent=2))
|
2026-02-21 22:31:43 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class ModalEnvironment(BaseEnvironment):
|
|
|
|
|
"""Modal cloud execution via mini-swe-agent.
|
|
|
|
|
|
2026-02-23 02:11:33 -08:00
|
|
|
Wraps SwerexModalEnvironment and adds sudo -S support, configurable
|
|
|
|
|
resources (CPU, memory, disk), and optional filesystem persistence
|
|
|
|
|
via Modal's snapshot_filesystem() API.
|
2026-02-21 22:31:43 -08:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
_patches_applied = False
|
|
|
|
|
|
2026-02-23 02:11:33 -08:00
|
|
|
def __init__(
|
|
|
|
|
self,
|
|
|
|
|
image: str,
|
2026-03-09 18:36:28 -05:00
|
|
|
cwd: str = "/root",
|
2026-02-23 02:11:33 -08:00
|
|
|
timeout: int = 60,
|
|
|
|
|
modal_sandbox_kwargs: Optional[Dict[str, Any]] = None,
|
|
|
|
|
persistent_filesystem: bool = True,
|
|
|
|
|
task_id: str = "default",
|
|
|
|
|
):
|
2026-02-21 22:31:43 -08:00
|
|
|
super().__init__(cwd=cwd, timeout=timeout)
|
|
|
|
|
|
|
|
|
|
if not ModalEnvironment._patches_applied:
|
|
|
|
|
try:
|
|
|
|
|
from environments.patches import apply_patches
|
|
|
|
|
apply_patches()
|
|
|
|
|
except ImportError:
|
|
|
|
|
pass
|
|
|
|
|
ModalEnvironment._patches_applied = True
|
|
|
|
|
|
2026-02-23 02:11:33 -08:00
|
|
|
self._persistent = persistent_filesystem
|
|
|
|
|
self._task_id = task_id
|
|
|
|
|
self._base_image = image
|
|
|
|
|
|
|
|
|
|
sandbox_kwargs = dict(modal_sandbox_kwargs or {})
|
|
|
|
|
|
|
|
|
|
# If persistent, try to restore from a previous snapshot
|
|
|
|
|
restored_image = None
|
|
|
|
|
if self._persistent:
|
|
|
|
|
snapshot_id = _load_snapshots().get(self._task_id)
|
|
|
|
|
if snapshot_id:
|
|
|
|
|
try:
|
|
|
|
|
import modal
|
|
|
|
|
restored_image = modal.Image.from_id(snapshot_id)
|
|
|
|
|
logger.info("Modal: restoring from snapshot %s", snapshot_id[:20])
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("Modal: failed to restore snapshot, using base image: %s", e)
|
|
|
|
|
restored_image = None
|
|
|
|
|
|
|
|
|
|
effective_image = restored_image if restored_image else image
|
|
|
|
|
|
2026-02-21 22:31:43 -08:00
|
|
|
from minisweagent.environments.extra.swerex_modal import SwerexModalEnvironment
|
|
|
|
|
self._inner = SwerexModalEnvironment(
|
2026-02-23 02:11:33 -08:00
|
|
|
image=effective_image,
|
|
|
|
|
cwd=cwd,
|
|
|
|
|
timeout=timeout,
|
|
|
|
|
startup_timeout=180.0,
|
|
|
|
|
runtime_timeout=3600.0,
|
|
|
|
|
modal_sandbox_kwargs=sandbox_kwargs,
|
2026-03-09 18:36:28 -05:00
|
|
|
install_pipx=True, # Required: installs pipx + swe-rex runtime (swerex-remote)
|
2026-02-21 22:31:43 -08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def execute(self, command: str, cwd: str = "", *,
|
|
|
|
|
timeout: int | None = None,
|
|
|
|
|
stdin_data: str | None = None) -> dict:
|
|
|
|
|
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}"
|
|
|
|
|
|
2026-03-08 17:46:11 +03:30
|
|
|
exec_command, sudo_stdin = self._prepare_command(command)
|
|
|
|
|
|
|
|
|
|
# Modal sandboxes execute commands via the Modal SDK and cannot pipe
|
|
|
|
|
# subprocess stdin directly the way a local Popen can. When a sudo
|
|
|
|
|
# password is present, use a shell-level pipe from printf so that the
|
|
|
|
|
# password feeds sudo -S without appearing as an echo argument embedded
|
|
|
|
|
# in the shell string. The password is still visible in the remote
|
|
|
|
|
# sandbox's command line, but it is not exposed on the user's local
|
|
|
|
|
# machine — which is the primary threat being mitigated.
|
|
|
|
|
if sudo_stdin is not None:
|
|
|
|
|
import shlex
|
|
|
|
|
exec_command = (
|
|
|
|
|
f"printf '%s\\n' {shlex.quote(sudo_stdin.rstrip())} | {exec_command}"
|
|
|
|
|
)
|
2026-02-23 02:11:33 -08:00
|
|
|
|
|
|
|
|
# Run in a background thread so we can poll for interrupts
|
|
|
|
|
result_holder = {"value": None, "error": None}
|
|
|
|
|
|
|
|
|
|
def _run():
|
|
|
|
|
try:
|
|
|
|
|
result_holder["value"] = self._inner.execute(exec_command, cwd=cwd, timeout=timeout)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
result_holder["error"] = e
|
|
|
|
|
|
|
|
|
|
t = threading.Thread(target=_run, daemon=True)
|
|
|
|
|
t.start()
|
|
|
|
|
while t.is_alive():
|
|
|
|
|
t.join(timeout=0.2)
|
|
|
|
|
if is_interrupted():
|
|
|
|
|
try:
|
|
|
|
|
self._inner.stop()
|
|
|
|
|
except Exception:
|
|
|
|
|
pass
|
|
|
|
|
return {
|
|
|
|
|
"output": "[Command interrupted - Modal sandbox terminated]",
|
|
|
|
|
"returncode": 130,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if result_holder["error"]:
|
|
|
|
|
return {"output": f"Modal execution error: {result_holder['error']}", "returncode": 1}
|
|
|
|
|
return result_holder["value"]
|
2026-02-21 22:31:43 -08:00
|
|
|
|
|
|
|
|
def cleanup(self):
|
2026-02-23 02:11:33 -08:00
|
|
|
"""Snapshot the filesystem (if persistent) then stop the sandbox."""
|
2026-03-07 21:34:06 +00:00
|
|
|
# Check if _inner was ever set (init may have failed)
|
|
|
|
|
if not hasattr(self, '_inner') or self._inner is None:
|
|
|
|
|
return
|
|
|
|
|
|
2026-02-23 02:11:33 -08:00
|
|
|
if self._persistent:
|
|
|
|
|
try:
|
|
|
|
|
sandbox = getattr(self._inner, 'deployment', None)
|
|
|
|
|
sandbox = getattr(sandbox, '_sandbox', None) if sandbox else None
|
|
|
|
|
if sandbox:
|
|
|
|
|
import asyncio
|
|
|
|
|
async def _snapshot():
|
|
|
|
|
img = await sandbox.snapshot_filesystem.aio()
|
|
|
|
|
return img.object_id
|
|
|
|
|
try:
|
|
|
|
|
snapshot_id = asyncio.run(_snapshot())
|
|
|
|
|
except RuntimeError:
|
|
|
|
|
import concurrent.futures
|
|
|
|
|
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
|
|
|
|
|
snapshot_id = pool.submit(
|
|
|
|
|
asyncio.run, _snapshot()
|
|
|
|
|
).result(timeout=60)
|
|
|
|
|
|
|
|
|
|
snapshots = _load_snapshots()
|
|
|
|
|
snapshots[self._task_id] = snapshot_id
|
|
|
|
|
_save_snapshots(snapshots)
|
|
|
|
|
logger.info("Modal: saved filesystem snapshot %s for task %s",
|
|
|
|
|
snapshot_id[:20], self._task_id)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("Modal: filesystem snapshot failed: %s", e)
|
|
|
|
|
|
2026-02-21 22:31:43 -08:00
|
|
|
if hasattr(self._inner, 'stop'):
|
|
|
|
|
self._inner.stop()
|