From d2e50534a9f788c96de2297a5dc03d67ce27c79d Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Thu, 21 May 2026 22:57:02 +0000 Subject: [PATCH 01/20] feat(terminal): add Sprites cloud sandbox backend Adds a new TERMINAL_ENV=sprites option backed by the sprites-py SDK (Fly.io). Persistent by default; sprites are keyed by hermes-{task_id} so sessions resume cleanly across restarts. Verified end-to-end against api.sprites.dev (exec, cwd tracking, env persistence, stdin heredoc, exit codes, file sync, ephemeral vs persistent cleanup). Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/setup.py | 78 +++++++++++- pyproject.toml | 3 +- tools/code_execution_tool.py | 3 +- tools/environments/__init__.py | 4 +- tools/environments/sprites.py | 226 +++++++++++++++++++++++++++++++++ tools/file_operations.py | 2 +- tools/lazy_deps.py | 1 + tools/terminal_tool.py | 37 +++++- 8 files changed, 339 insertions(+), 15 deletions(-) create mode 100644 tools/environments/sprites.py diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 1e4b6d7fc7bda..65ff24f86c866 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1409,11 +1409,12 @@ def setup_terminal_backend(config: dict): "SSH - run on a remote machine", "Daytona - persistent cloud development environment", "Vercel Sandbox - cloud microVM with snapshot filesystem persistence", + "Sprites - Fly.io cloud sandbox with checkpoints", ] - idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "vercel_sandbox"} - backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "vercel_sandbox": 5} + idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "vercel_sandbox", 6: "sprites"} + backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "vercel_sandbox": 5, "sprites": 6} - next_idx = 6 + next_idx = 7 if is_linux: terminal_choices.append("Singularity/Apptainer - HPC-friendly container") idx_to_backend[next_idx] = "singularity" @@ -1692,6 +1693,77 @@ def setup_terminal_backend(config: dict): _prompt_vercel_sandbox_settings(config) + elif selected_backend == "sprites": + print_success("Terminal backend: Sprites") + print_info("Fly.io-backed cloud sandboxes with native checkpoint support.") + print_info("Sprites persist between sessions and are reused by task_id.") + print_info("Sign up at: https://sprites.dev") + + try: + __import__("sprites") + except ImportError: + print_info("Installing sprites-py SDK...") + import subprocess + + uv_bin = shutil.which("uv") + if uv_bin: + result = subprocess.run( + [uv_bin, "pip", "install", "--python", sys.executable, "sprites-py"], + capture_output=True, + text=True, + ) + else: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", "sprites-py"], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print_success("sprites-py installed") + else: + print_warning("Install failed — run manually: pip install 'hermes-agent[sprites]'") + if result.stderr: + print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") + + # Sprites API token + print() + existing_token = get_env_value("SPRITES_TOKEN") + if existing_token: + print_info(" Sprites token: already configured") + if prompt_yes_no(" Update token?", False): + token = prompt(" Sprites token", password=True) + if token: + save_env_value("SPRITES_TOKEN", token) + print_success(" Updated") + else: + print_info(" Get a token with: sprite login (or `sprite auth setup --token ...`)") + token = prompt(" Sprites token", password=True) + if token: + save_env_value("SPRITES_TOKEN", token) + print_success(" Configured") + + # Optional custom API base URL + print() + current_base = get_env_value("SPRITES_BASE_URL") or "" + base = prompt(" Sprites API base URL (blank for default)", current_base) + if base: + save_env_value("SPRITES_BASE_URL", base) + elif current_base: + remove_env_value("SPRITES_BASE_URL") + + # Region (passed to SpriteConfig at creation time) + print() + current_region = cfg_get(config, "terminal", "sprites_region", default="") + region = prompt(" Region (blank to let Sprites choose)", current_region) + if region: + config.setdefault("terminal", {})["sprites_region"] = region + save_env_value("TERMINAL_SPRITES_REGION", region) + elif current_region: + config["terminal"]["sprites_region"] = "" + remove_env_value("TERMINAL_SPRITES_REGION") + + _prompt_container_resources(config) + elif selected_backend == "ssh": print_success("Terminal backend: SSH") print_info("Run commands on a remote machine via SSH.") diff --git a/pyproject.toml b/pyproject.toml index ae2472b7a1051..0c1df579dddf5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,6 +83,7 @@ edge-tts = ["edge-tts==7.2.7"] modal = ["modal==1.3.4"] daytona = ["daytona==0.155.0"] vercel = ["vercel==0.5.7"] +sprites = ["sprites-py==0.0.1rc37"] hindsight = ["hindsight-client==0.6.1"] dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"] messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] @@ -183,7 +184,7 @@ all = [ # # Removed from [all] on 2026-05-12 (covered by lazy-install): # anthropic, exa, firecrawl, parallel-web, fal, edge-tts, - # modal, daytona, vercel, messaging (telegram/discord/slack), + # modal, daytona, vercel, sprites, messaging (telegram/discord/slack), # matrix, slack, honcho, voice (faster-whisper), # dingtalk, feishu, bedrock, tts-premium (elevenlabs) # diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index bdbc4bfbe1bfb..cef1a3c236ed4 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -612,13 +612,14 @@ def _get_or_create_env(task_id: str): cwd = overrides.get("cwd") or config["cwd"] container_config = None - if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox", "sprites"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), "container_disk": config.get("container_disk", 51200), "container_persistent": config.get("container_persistent", True), "vercel_runtime": config.get("vercel_runtime", ""), + "sprites_region": config.get("sprites_region", ""), "docker_volumes": config.get("docker_volumes", []), "docker_run_as_host_user": config.get("docker_run_as_host_user", False), } diff --git a/tools/environments/__init__.py b/tools/environments/__init__.py index 0134dc16dcb88..a67de672353ca 100644 --- a/tools/environments/__init__.py +++ b/tools/environments/__init__.py @@ -2,8 +2,8 @@ Each backend provides the same interface (BaseEnvironment ABC) for running shell commands in a specific execution context: local, Docker, SSH, -Singularity, Modal, Daytona, or Vercel Sandbox. (Modal additionally has -direct and Nous-managed modes, selected via terminal.modal_mode.) +Singularity, Modal, Daytona, Vercel Sandbox, or Sprites. (Modal additionally +has direct and Nous-managed modes, selected via terminal.modal_mode.) The terminal_tool.py factory (_create_environment) selects the backend based on the TERMINAL_ENV configuration. diff --git a/tools/environments/sprites.py b/tools/environments/sprites.py new file mode 100644 index 0000000000000..2697e0a985989 --- /dev/null +++ b/tools/environments/sprites.py @@ -0,0 +1,226 @@ +"""Sprites cloud execution environment. + +Uses the sprites-py SDK (https://github.com/superfly/sprites-py) to run +commands in Fly.io-backed sprite sandboxes. Persistent by default — sprites +outlive sessions and are reused via a deterministic ``hermes-{task_id}`` name. +Cleanup leaves the sprite running when ``persistent_filesystem`` is True; the +sprite is deleted otherwise. +""" + +import logging +import os +import shlex +import threading +from pathlib import Path + +from tools.environments.base import ( + BaseEnvironment, + _ThreadedProcessHandle, +) +from tools.environments.file_sync import ( + FileSyncManager, + iter_sync_files, +) + +logger = logging.getLogger(__name__) + + +class SpritesEnvironment(BaseEnvironment): + """Sprites (Fly.io) cloud sandbox backend. + + Spawn-per-call via ``_ThreadedProcessHandle`` wrapping blocking + ``sprite.command(...).combined_output()`` calls. The SDK timeout is + used (rather than wrapping the shell), since the SDK already cancels + the underlying WebSocket exec on deadline. + """ + + _stdin_mode = "heredoc" + + def __init__( + self, + cwd: str = "/root", + timeout: int = 60, + cpu: int = 1, + memory: int = 5120, + disk: int = 51200, + persistent_filesystem: bool = True, + task_id: str = "default", + region: str | None = None, + ): + requested_cwd = cwd + super().__init__(cwd=cwd, timeout=timeout) + + try: + from tools.lazy_deps import ensure as _lazy_ensure + _lazy_ensure("terminal.sprites", prompt=False) + except ImportError: + pass + except Exception as e: + raise ImportError(str(e)) + + from sprites import SpritesClient + from sprites.exceptions import NotFoundError, SpriteError + from sprites.types import SpriteConfig + + self._NotFoundError = NotFoundError + self._SpriteError = SpriteError + + token = os.getenv("SPRITES_TOKEN") or os.getenv("SPRITE_TOKEN") + if not token: + raise ValueError( + "Sprites backend requires SPRITES_TOKEN. " + "Run `hermes setup terminal` or set SPRITES_TOKEN in .env." + ) + base_url = os.getenv("SPRITES_BASE_URL", "https://api.sprites.dev") + + self._client = SpritesClient( + token=token, + base_url=base_url, + timeout=max(30.0, float(timeout)), + ) + self._persistent = persistent_filesystem + self._task_id = task_id + self._lock = threading.Lock() + self._sprite = None + self._cmd_timeout = float(timeout) if timeout and timeout > 0 else None + + sprite_name = f"hermes-{task_id}" + config = SpriteConfig( + ram_mb=int(memory) if memory else None, + cpus=int(cpu) if cpu else None, + region=region or None, + storage_gb=max(1, int(disk) // 1024) if disk else None, + ) + + # Try to get an existing sprite first (no remote call); fall back + # to creating one when the first command surfaces NotFoundError. + # This mirrors Daytona's persistent-resume pattern but defers the + # round-trip until we actually need to talk to the sprite. + try: + self._sprite = self._client.get_sprite(sprite_name) + logger.info( + "Sprites: resumed existing sprite %s for task %s", + self._sprite.name, task_id, + ) + except NotFoundError: + self._sprite = self._client.create_sprite(sprite_name, config=config) + logger.info( + "Sprites: created sprite %s for task %s", + self._sprite.name, task_id, + ) + + # Detect remote home dir for .hermes sync target. + self._remote_home = "/root" + try: + from sprites.exceptions import ExitError + cmd = self._sprite.command("bash", "-c", "echo $HOME", timeout=15) + home = cmd.combined_output().decode().strip() + if home: + self._remote_home = home + if requested_cwd in {"~", "/root"}: + self.cwd = home + except Exception: + pass + + self._fs = self._sprite.filesystem("/") + self._sync_manager = FileSyncManager( + get_files_fn=lambda: iter_sync_files(f"{self._remote_home}/.hermes"), + upload_fn=self._sprite_upload, + delete_fn=self._sprite_delete, + ) + self._sync_manager.sync(force=True) + self.init_session() + + # ------------------------------------------------------------------ + # File sync callbacks + # ------------------------------------------------------------------ + + def _sprite_upload(self, host_path: str, remote_path: str) -> None: + """Upload a single file via the SpriteFilesystem API.""" + data = Path(host_path).read_bytes() + remote = self._fs / remote_path + remote.parent.mkdir(parents=True, exist_ok=True) + remote.write_bytes(data) + + def _sprite_delete(self, remote_paths: list[str]) -> None: + """Delete remote files; missing entries are tolerated.""" + for rp in remote_paths: + try: + (self._fs / rp).unlink(missing_ok=True) + except Exception as e: + logger.debug("Sprites: delete %s failed: %s", rp, e) + + # ------------------------------------------------------------------ + # Execution + # ------------------------------------------------------------------ + + def _before_execute(self) -> None: + 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 SDK call.""" + sprite = self._sprite + from sprites.exceptions import ExitError, TimeoutError as SpritesTimeout + + if login: + shell_cmd = ["bash", "-l", "-c", cmd_string] + else: + shell_cmd = ["bash", "-c", cmd_string] + + # The SDK timeout cancels the WebSocket cleanly, so prefer it over + # the shell-level ``timeout`` wrapper used by other backends. + cmd_timeout = float(timeout) if timeout and timeout > 0 else None + + def exec_fn() -> tuple[str, int]: + cmd = sprite.command(*shell_cmd, timeout=cmd_timeout) + try: + output = cmd.combined_output() + return (output.decode("utf-8", errors="replace"), 0) + except ExitError as e: + # ``e.stdout`` carries the combined output when raised from + # combined_output(); ``e.stderr`` is empty in that path. + buf = (e.stdout or b"") + (e.stderr or b"") + return (buf.decode("utf-8", errors="replace"), + e.exit_code() if callable(getattr(e, "exit_code", None)) else 1) + except SpritesTimeout: + return (f"command timed out after {cmd_timeout}s\n", 124) + + # No external cancel: the SDK does not expose a kill hook on a + # running Cmd. The deadline above is the cancellation path. + return _ThreadedProcessHandle(exec_fn, cancel_fn=None) + + # ------------------------------------------------------------------ + # Cleanup + # ------------------------------------------------------------------ + + def cleanup(self): + with self._lock: + if self._sprite is None: + return + + if self._sync_manager: + logger.info("Sprites: syncing files from sprite...") + try: + self._sync_manager.sync_back() + except Exception as e: + logger.warning("Sprites: sync_back failed: %s", e) + + try: + if self._persistent: + logger.info( + "Sprites: leaving sprite %s running (persistent)", + self._sprite.name, + ) + else: + self._sprite.delete() + logger.info("Sprites: deleted sprite %s", self._sprite.name) + except Exception as e: + logger.warning("Sprites: cleanup failed: %s", e) + finally: + try: + self._client.close() + except Exception: + pass + self._sprite = None diff --git a/tools/file_operations.py b/tools/file_operations.py index c25dc332cb0a7..b9cba423e13cd 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -3,7 +3,7 @@ File Operations Module Provides file manipulation capabilities (read, write, patch, search) that work -across all terminal backends (local, docker, ssh, singularity, modal, daytona, vercel_sandbox). +across all terminal backends (local, docker, ssh, singularity, modal, daytona, vercel_sandbox, sprites). The key insight is that all file operations can be expressed as shell commands, so we wrap the terminal backend's execute() interface to provide a unified file API. diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 1a8708ef25c0c..9d4472c8cbc30 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -153,6 +153,7 @@ "terminal.modal": ("modal==1.3.4",), "terminal.daytona": ("daytona==0.155.0",), "terminal.vercel": ("vercel==0.5.7",), + "terminal.sprites": ("sprites-py==0.0.1rc37",), # ─── Skills ──────────────────────────────────────────────────────────── "skill.google_workspace": ( diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 387e27881adf2..f81f4e61118e2 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -3,18 +3,19 @@ Terminal Tool Module A terminal tool that executes commands in local, Docker, Modal, SSH, -Singularity, Daytona, and Vercel Sandbox environments. Supports local -execution, containerized backends, and cloud sandboxes, including managed -Modal mode. +Singularity, Daytona, Vercel Sandbox, and Sprites environments. Supports +local execution, containerized backends, and cloud sandboxes, including +managed Modal 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) - "vercel_sandbox": Execute in Vercel Sandbox cloud sandboxes +- "sprites": Execute in Sprites (Fly.io) cloud sandboxes with checkpoint support Features: -- Multiple execution backends (local, docker, modal, vercel_sandbox) +- Multiple execution backends (local, docker, modal, vercel_sandbox, sprites) - Background task support - VM/container lifecycle management - Automatic cleanup after inactivity @@ -1113,7 +1114,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, Args: env_type: One of "local", "docker", "singularity", "modal", - "daytona", "vercel_sandbox", "ssh" + "daytona", "vercel_sandbox", "sprites", "ssh" image: Docker/Singularity/Modal image name (ignored for local/ssh/vercel) cwd: Working directory timeout: Default command timeout @@ -1235,6 +1236,15 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, task_id=task_id, ) + elif env_type == "sprites": + from tools.environments.sprites import SpritesEnvironment as _SpritesEnvironment + return _SpritesEnvironment( + cwd=cwd, timeout=timeout, + cpu=int(cpu), memory=memory, disk=disk, + persistent_filesystem=persistent, task_id=task_id, + region=cc.get("sprites_region") or None, + ) + 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") @@ -1250,7 +1260,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', " - f"'singularity', 'modal', 'daytona', 'vercel_sandbox', or 'ssh'" + f"'singularity', 'modal', 'daytona', 'vercel_sandbox', 'sprites', or 'ssh'" ) @@ -2246,10 +2256,23 @@ 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 == "sprites": + import importlib.util as _iu + if _iu.find_spec("sprites") is None: + logger.error( + "sprites-py is required for sprites terminal backend: " + "pip install 'hermes-agent[sprites]'" + ) + return False + if not (os.getenv("SPRITES_TOKEN") or os.getenv("SPRITE_TOKEN")): + logger.error("SPRITES_TOKEN is required for sprites terminal backend") + return False + return True + else: logger.error( "Unknown TERMINAL_ENV '%s'. Use one of: local, docker, singularity, " - "modal, daytona, vercel_sandbox, ssh.", + "modal, daytona, vercel_sandbox, sprites, ssh.", env_type, ) return False From 4954e364cf5f1a0845808d1bf001fa9fdb0cf6a3 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Thu, 21 May 2026 23:23:15 +0000 Subject: [PATCH 02/20] docs(terminal): document Sprites backend; drop region + compute knobs Sprites does not yet expose region or per-sandbox compute sizing (CPU/memory/disk) to API consumers, so the SpriteConfig and the setup flow are simplified to match: sprite creation no longer passes a SpriteConfig at all, the setup wizard no longer prompts for region or container resources, and the docs YAML example drops the container_cpu/memory/disk knobs with a note that they are ignored on this backend. Adds the per-backend section (mirrors Daytona/Vercel pattern), the SPRITES_TOKEN and SPRITES_BASE_URL env-var rows, and the troubleshooting bullet. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/setup.py | 13 ++----- tools/code_execution_tool.py | 3 +- tools/environments/sprites.py | 23 +++--------- tools/terminal_tool.py | 2 -- .../docs/reference/environment-variables.md | 2 ++ website/docs/user-guide/configuration.md | 35 +++++++++++++++++-- 6 files changed, 42 insertions(+), 36 deletions(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 65ff24f86c866..19c7285c6bfd5 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1751,18 +1751,9 @@ def setup_terminal_backend(config: dict): elif current_base: remove_env_value("SPRITES_BASE_URL") - # Region (passed to SpriteConfig at creation time) print() - current_region = cfg_get(config, "terminal", "sprites_region", default="") - region = prompt(" Region (blank to let Sprites choose)", current_region) - if region: - config.setdefault("terminal", {})["sprites_region"] = region - save_env_value("TERMINAL_SPRITES_REGION", region) - elif current_region: - config["terminal"]["sprites_region"] = "" - remove_env_value("TERMINAL_SPRITES_REGION") - - _prompt_container_resources(config) + print_info("Note: Sprites uses platform-default compute sizing.") + print_info("CPU/memory/disk and region are not yet user-configurable.") elif selected_backend == "ssh": print_success("Terminal backend: SSH") diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index cef1a3c236ed4..bdbc4bfbe1bfb 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -612,14 +612,13 @@ def _get_or_create_env(task_id: str): cwd = overrides.get("cwd") or config["cwd"] container_config = None - if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox", "sprites"}: + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), "container_disk": config.get("container_disk", 51200), "container_persistent": config.get("container_persistent", True), "vercel_runtime": config.get("vercel_runtime", ""), - "sprites_region": config.get("sprites_region", ""), "docker_volumes": config.get("docker_volumes", []), "docker_run_as_host_user": config.get("docker_run_as_host_user", False), } diff --git a/tools/environments/sprites.py b/tools/environments/sprites.py index 2697e0a985989..9280b2879c974 100644 --- a/tools/environments/sprites.py +++ b/tools/environments/sprites.py @@ -40,12 +40,8 @@ def __init__( self, cwd: str = "/root", timeout: int = 60, - cpu: int = 1, - memory: int = 5120, - disk: int = 51200, persistent_filesystem: bool = True, task_id: str = "default", - region: str | None = None, ): requested_cwd = cwd super().__init__(cwd=cwd, timeout=timeout) @@ -60,7 +56,6 @@ def __init__( from sprites import SpritesClient from sprites.exceptions import NotFoundError, SpriteError - from sprites.types import SpriteConfig self._NotFoundError = NotFoundError self._SpriteError = SpriteError @@ -82,20 +77,12 @@ def __init__( self._task_id = task_id self._lock = threading.Lock() self._sprite = None - self._cmd_timeout = float(timeout) if timeout and timeout > 0 else None + # Sprites does not yet honor SpriteConfig sizing knobs (cpu / ram / + # storage / region) — sandboxes get default sizing. We omit SpriteConfig + # entirely so the wire format stays minimal until the platform exposes + # these knobs. sprite_name = f"hermes-{task_id}" - config = SpriteConfig( - ram_mb=int(memory) if memory else None, - cpus=int(cpu) if cpu else None, - region=region or None, - storage_gb=max(1, int(disk) // 1024) if disk else None, - ) - - # Try to get an existing sprite first (no remote call); fall back - # to creating one when the first command surfaces NotFoundError. - # This mirrors Daytona's persistent-resume pattern but defers the - # round-trip until we actually need to talk to the sprite. try: self._sprite = self._client.get_sprite(sprite_name) logger.info( @@ -103,7 +90,7 @@ def __init__( self._sprite.name, task_id, ) except NotFoundError: - self._sprite = self._client.create_sprite(sprite_name, config=config) + self._sprite = self._client.create_sprite(sprite_name) logger.info( "Sprites: created sprite %s for task %s", self._sprite.name, task_id, diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index f81f4e61118e2..6425845a47df7 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1240,9 +1240,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, from tools.environments.sprites import SpritesEnvironment as _SpritesEnvironment return _SpritesEnvironment( cwd=cwd, timeout=timeout, - cpu=int(cpu), memory=memory, disk=disk, persistent_filesystem=persistent, task_id=task_id, - region=cc.get("sprites_region") or None, ) elif env_type == "ssh": diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index e9403337063e4..d11efdbdec26a 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -161,6 +161,8 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `VERCEL_PROJECT_ID` | Vercel project ID (required with `VERCEL_TOKEN`) | | `VERCEL_TEAM_ID` | Vercel team ID (required with `VERCEL_TOKEN`) | | `VERCEL_OIDC_TOKEN` | Vercel short-lived OIDC token (development-only alternative) | +| `SPRITES_TOKEN` | Sprites (Fly.io) cloud sandboxes ([sprites.dev](https://sprites.dev/)) | +| `SPRITES_BASE_URL` | Sprites API base URL (default: `https://api.sprites.dev`) | ### Langfuse Observability diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index ad63ed84c0960..c055f8e6dd9bb 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -83,11 +83,11 @@ Leaving these unset keeps the legacy defaults (`HERMES_API_TIMEOUT=1800`s, `HERM ## Terminal Backend Configuration -Hermes supports seven terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), a Daytona workspace, a Vercel Sandbox, or a Singularity/Apptainer container. +Hermes supports eight terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), a Daytona workspace, a Vercel Sandbox, a Sprites (Fly.io) sandbox, or a Singularity/Apptainer container. ```yaml terminal: - backend: local # local | docker | ssh | modal | daytona | vercel_sandbox | singularity + backend: local # local | docker | ssh | modal | daytona | vercel_sandbox | sprites | singularity cwd: "." # Gateway/cron working directory (CLI always uses launch dir) timeout: 180 # Per-command timeout in seconds env_passthrough: [] # Env var names to forward to sandboxed execution (terminal + execute_code) @@ -96,7 +96,7 @@ terminal: daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Daytona backend ``` -For cloud sandboxes such as Modal, Daytona, and Vercel Sandbox, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later. +For cloud sandboxes such as Modal, Daytona, Vercel Sandbox, and Sprites, `container_persistent: true` means Hermes will try to preserve filesystem state across sandbox recreation. It does not promise that the same live sandbox, PID space, or background processes will still be running later. ### Backend Overview @@ -108,6 +108,7 @@ For cloud sandboxes such as Modal, Daytona, and Vercel Sandbox, `container_persi | **modal** | Modal cloud sandbox | Full (cloud VM) | Ephemeral cloud compute, evals | | **daytona** | Daytona workspace | Full (cloud container) | Managed cloud dev environments | | **vercel_sandbox** | Vercel Sandbox | Full (cloud microVM) | Cloud execution with snapshot-backed filesystem persistence | +| **sprites** | Sprites (Fly.io) sandbox | Full (cloud VM) | Persistent cloud sandboxes with native checkpoint support | | **singularity** | Singularity/Apptainer container | Namespaces (--containall) | HPC clusters, shared machines | ### Local Backend @@ -275,6 +276,33 @@ OIDC tokens are short-lived and should not be used as the documented deployment **Disk sizing:** Vercel Sandbox does not currently support Hermes' `container_disk` resource knob. Leave `container_disk` unset or at the shared default `51200`; non-default values fail diagnostics and backend creation instead of being silently ignored. +### Sprites Backend + +Runs commands in a [Sprites](https://sprites.dev) cloud sandbox backed by Fly.io. Sprites persist between sessions by default and are reused by task identity — Hermes names sandboxes `hermes-{task_id}` and on each session start either resumes the existing sprite or creates a fresh one. + +```yaml +terminal: + backend: sprites + cwd: /home/sprite # Sprite default home; "/root" / "~" auto-rewrite + container_persistent: true # Leave sprite alive on cleanup (delete if false) +``` + +Sprites uses platform-default compute sizing — CPU, memory, disk, and region are not yet user-configurable, so the usual `container_cpu` / `container_memory` / `container_disk` knobs are ignored on this backend. + +**Required install:** Install the optional SDK extra: + +```bash +pip install 'hermes-agent[sprites]' +``` + +**Required authentication:** `SPRITES_TOKEN` environment variable. Get a token with `sprite login` or `sprite auth setup --token …` from the [Sprites CLI](https://sprites.dev). + +**Optional:** `SPRITES_BASE_URL` overrides the default `https://api.sprites.dev` endpoint (useful for self-hosted deployments). + +**Persistence:** With `container_persistent: true`, `cleanup()` leaves the sprite running so the next session can resume against the same filesystem and live VM. With `persistent: false`, the sprite is deleted on cleanup. Sprites also support server-side checkpoints exposed by the SDK (`sprite.create_checkpoint`, `sprite.restore_checkpoint`), but Hermes does not invoke them automatically — manage checkpoints via the `sprite-env` CLI if needed. + +**Credential files:** Hermes pushes `~/.hermes/` credentials/skills/cache into the sprite at startup via the Sprites filesystem API. Files modified by the agent inside the sprite are **not** synced back to the host on cleanup (see _Remote-to-Host File Sync_ — sync_back is unsupported on this backend). + ### Singularity/Apptainer Backend Runs commands in a [Singularity/Apptainer](https://apptainer.org) container. Designed for HPC clusters and shared machines where Docker isn't available. @@ -305,6 +333,7 @@ If terminal commands fail immediately or the terminal tool is reported as disabl - **SSH** — Both `TERMINAL_SSH_HOST` and `TERMINAL_SSH_USER` must be set. Hermes logs a clear error if either is missing. - **Modal** — Needs `MODAL_TOKEN_ID` env var or `~/.modal.toml`. Run `hermes doctor` to check. - **Daytona** — Needs `DAYTONA_API_KEY`. The Daytona SDK handles server URL configuration. +- **Sprites** — Needs `SPRITES_TOKEN` plus the optional `sprites-py` SDK (`pip install 'hermes-agent[sprites]'`). - **Singularity** — Needs `apptainer` or `singularity` in `$PATH`. Common on HPC clusters. When in doubt, set `terminal.backend` back to `local` and verify that commands run there first. From be5ba8449b4777317c94898e62fdc7da4a2acc1c Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Thu, 21 May 2026 23:30:39 +0000 Subject: [PATCH 03/20] docs(terminal): align Sprites language with sprites.dev positioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per sprites.dev, a Sprite is a hardware-isolated, stateful Firecracker VM on Fly.io with checkpoint & restore — not just a generic "cloud sandbox." This normalizes the wording across the user guide, env-var reference, setup wizard, and module docstrings: - "Sprite" (singular, capitalized) for an instance; "Sprites" for the service/product - Backend description leans on Firecracker / Fly.io / stateful framing instead of the generic "cloud sandbox / cloud VM" labels - Compute-sizing note is reworded to match the platform's dynamic allocation model (up to 8 CPU / 16 GB RAM) rather than implying static defaults Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/setup.py | 8 ++++---- tools/environments/sprites.py | 13 +++++++------ tools/terminal_tool.py | 2 +- website/docs/reference/environment-variables.md | 2 +- website/docs/user-guide/configuration.md | 14 +++++++------- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 19c7285c6bfd5..622d838ac2a62 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1409,7 +1409,7 @@ def setup_terminal_backend(config: dict): "SSH - run on a remote machine", "Daytona - persistent cloud development environment", "Vercel Sandbox - cloud microVM with snapshot filesystem persistence", - "Sprites - Fly.io cloud sandbox with checkpoints", + "Sprites - stateful Firecracker VM sandbox on Fly.io, with checkpoint & restore", ] idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "vercel_sandbox", 6: "sprites"} backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "vercel_sandbox": 5, "sprites": 6} @@ -1695,7 +1695,7 @@ def setup_terminal_backend(config: dict): elif selected_backend == "sprites": print_success("Terminal backend: Sprites") - print_info("Fly.io-backed cloud sandboxes with native checkpoint support.") + print_info("Stateful Firecracker VM sandboxes on Fly.io, with checkpoint & restore.") print_info("Sprites persist between sessions and are reused by task_id.") print_info("Sign up at: https://sprites.dev") @@ -1752,8 +1752,8 @@ def setup_terminal_backend(config: dict): remove_env_value("SPRITES_BASE_URL") print() - print_info("Note: Sprites uses platform-default compute sizing.") - print_info("CPU/memory/disk and region are not yet user-configurable.") + print_info("Note: Sprites allocates compute dynamically (up to 8 CPU / 16 GB RAM).") + print_info("Manual CPU/memory/disk/region knobs are not yet exposed.") elif selected_backend == "ssh": print_success("Terminal backend: SSH") diff --git a/tools/environments/sprites.py b/tools/environments/sprites.py index 9280b2879c974..6c0df89639f46 100644 --- a/tools/environments/sprites.py +++ b/tools/environments/sprites.py @@ -1,10 +1,11 @@ -"""Sprites cloud execution environment. +"""Sprites execution environment. Uses the sprites-py SDK (https://github.com/superfly/sprites-py) to run -commands in Fly.io-backed sprite sandboxes. Persistent by default — sprites -outlive sessions and are reused via a deterministic ``hermes-{task_id}`` name. -Cleanup leaves the sprite running when ``persistent_filesystem`` is True; the -sprite is deleted otherwise. +commands in Sprites — stateful Firecracker VM sandboxes on Fly.io, with +checkpoint & restore. Persistent by default: each Sprite outlives the session +and is reused via a deterministic ``hermes-{task_id}`` name. Cleanup leaves +the Sprite running when ``persistent_filesystem`` is True; the Sprite is +deleted otherwise. """ import logging @@ -26,7 +27,7 @@ class SpritesEnvironment(BaseEnvironment): - """Sprites (Fly.io) cloud sandbox backend. + """Sprites backend: stateful Firecracker VM sandboxes on Fly.io. Spawn-per-call via ``_ThreadedProcessHandle`` wrapping blocking ``sprite.command(...).combined_output()`` calls. The SDK timeout is diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 6425845a47df7..f0059daca9e39 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -12,7 +12,7 @@ - "docker": Execute in Docker containers (isolated, requires Docker) - "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway) - "vercel_sandbox": Execute in Vercel Sandbox cloud sandboxes -- "sprites": Execute in Sprites (Fly.io) cloud sandboxes with checkpoint support +- "sprites": Execute in Sprites — stateful Firecracker VM sandboxes on Fly.io, with checkpoint & restore Features: - Multiple execution backends (local, docker, modal, vercel_sandbox, sprites) diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index d11efdbdec26a..104d86316b1a3 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -161,7 +161,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `VERCEL_PROJECT_ID` | Vercel project ID (required with `VERCEL_TOKEN`) | | `VERCEL_TEAM_ID` | Vercel team ID (required with `VERCEL_TOKEN`) | | `VERCEL_OIDC_TOKEN` | Vercel short-lived OIDC token (development-only alternative) | -| `SPRITES_TOKEN` | Sprites (Fly.io) cloud sandboxes ([sprites.dev](https://sprites.dev/)) | +| `SPRITES_TOKEN` | Sprites: stateful Firecracker VM sandboxes on Fly.io ([sprites.dev](https://sprites.dev/)) | | `SPRITES_BASE_URL` | Sprites API base URL (default: `https://api.sprites.dev`) | ### Langfuse Observability diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index c055f8e6dd9bb..ca8a805b2a9a4 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -83,7 +83,7 @@ Leaving these unset keeps the legacy defaults (`HERMES_API_TIMEOUT=1800`s, `HERM ## Terminal Backend Configuration -Hermes supports eight terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), a Daytona workspace, a Vercel Sandbox, a Sprites (Fly.io) sandbox, or a Singularity/Apptainer container. +Hermes supports eight terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), a Daytona workspace, a Vercel Sandbox, a Sprite (Fly.io Firecracker VM), or a Singularity/Apptainer container. ```yaml terminal: @@ -108,7 +108,7 @@ For cloud sandboxes such as Modal, Daytona, Vercel Sandbox, and Sprites, `contai | **modal** | Modal cloud sandbox | Full (cloud VM) | Ephemeral cloud compute, evals | | **daytona** | Daytona workspace | Full (cloud container) | Managed cloud dev environments | | **vercel_sandbox** | Vercel Sandbox | Full (cloud microVM) | Cloud execution with snapshot-backed filesystem persistence | -| **sprites** | Sprites (Fly.io) sandbox | Full (cloud VM) | Persistent cloud sandboxes with native checkpoint support | +| **sprites** | Sprite (Fly.io Firecracker VM) | Full (hardware-isolated microVM) | Stateful sandboxes with checkpoint & restore | | **singularity** | Singularity/Apptainer container | Namespaces (--containall) | HPC clusters, shared machines | ### Local Backend @@ -278,16 +278,16 @@ OIDC tokens are short-lived and should not be used as the documented deployment ### Sprites Backend -Runs commands in a [Sprites](https://sprites.dev) cloud sandbox backed by Fly.io. Sprites persist between sessions by default and are reused by task identity — Hermes names sandboxes `hermes-{task_id}` and on each session start either resumes the existing sprite or creates a fresh one. +Runs commands in a [Sprite](https://sprites.dev) — a stateful Firecracker VM backed by Fly.io, with checkpoint & restore. Sprites persist between sessions by default and are reused by task identity: Hermes names them `hermes-{task_id}` and on each session start either resumes the existing Sprite or creates a fresh one. ```yaml terminal: backend: sprites cwd: /home/sprite # Sprite default home; "/root" / "~" auto-rewrite - container_persistent: true # Leave sprite alive on cleanup (delete if false) + container_persistent: true # Leave the Sprite alive on cleanup (delete if false) ``` -Sprites uses platform-default compute sizing — CPU, memory, disk, and region are not yet user-configurable, so the usual `container_cpu` / `container_memory` / `container_disk` knobs are ignored on this backend. +Sprites allocates compute dynamically (up to 8 CPU / 16 GB RAM per Sprite); user-selectable CPU/memory/disk/region knobs aren't exposed yet, so the usual `container_cpu` / `container_memory` / `container_disk` settings are ignored on this backend. **Required install:** Install the optional SDK extra: @@ -299,9 +299,9 @@ pip install 'hermes-agent[sprites]' **Optional:** `SPRITES_BASE_URL` overrides the default `https://api.sprites.dev` endpoint (useful for self-hosted deployments). -**Persistence:** With `container_persistent: true`, `cleanup()` leaves the sprite running so the next session can resume against the same filesystem and live VM. With `persistent: false`, the sprite is deleted on cleanup. Sprites also support server-side checkpoints exposed by the SDK (`sprite.create_checkpoint`, `sprite.restore_checkpoint`), but Hermes does not invoke them automatically — manage checkpoints via the `sprite-env` CLI if needed. +**Persistence:** With `container_persistent: true`, `cleanup()` leaves the Sprite running so the next session can resume against the same filesystem and live VM. With `persistent: false`, the Sprite is deleted on cleanup. Sprites also support server-side checkpoints exposed by the SDK (`sprite.create_checkpoint`, `sprite.restore_checkpoint`), but Hermes does not invoke them automatically — manage checkpoints via the `sprite-env` CLI if needed. -**Credential files:** Hermes pushes `~/.hermes/` credentials/skills/cache into the sprite at startup via the Sprites filesystem API. Files modified by the agent inside the sprite are **not** synced back to the host on cleanup (see _Remote-to-Host File Sync_ — sync_back is unsupported on this backend). +**Credential files:** Hermes pushes `~/.hermes/` credentials/skills/cache into the Sprite at startup via the Sprite filesystem API. Files modified by the agent inside the Sprite are **not** synced back to the host on cleanup (see _Remote-to-Host File Sync_ — sync_back is unsupported on this backend). ### Singularity/Apptainer Backend From 0190403900bc35ede0eeef91eef6ae7ccf55fee5 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Thu, 21 May 2026 23:34:49 +0000 Subject: [PATCH 04/20] docs(terminal): drop "Firecracker" from Sprites language, prefer "cloud sandbox" Per maintainer preference, the public-facing description shouldn't lean on the underlying hypervisor name. Keeps the "stateful sandbox / checkpoint & restore" framing aligned with sprites.dev but reverts the implementation detail to the generic "cloud sandbox" wording used by Modal/Daytona/Vercel. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/setup.py | 4 ++-- tools/environments/sprites.py | 4 ++-- tools/terminal_tool.py | 2 +- website/docs/reference/environment-variables.md | 2 +- website/docs/user-guide/configuration.md | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 622d838ac2a62..89ca93c2cc8e9 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1409,7 +1409,7 @@ def setup_terminal_backend(config: dict): "SSH - run on a remote machine", "Daytona - persistent cloud development environment", "Vercel Sandbox - cloud microVM with snapshot filesystem persistence", - "Sprites - stateful Firecracker VM sandbox on Fly.io, with checkpoint & restore", + "Sprites - stateful cloud sandbox on Fly.io, with checkpoint & restore", ] idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona", 5: "vercel_sandbox", 6: "sprites"} backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "vercel_sandbox": 5, "sprites": 6} @@ -1695,7 +1695,7 @@ def setup_terminal_backend(config: dict): elif selected_backend == "sprites": print_success("Terminal backend: Sprites") - print_info("Stateful Firecracker VM sandboxes on Fly.io, with checkpoint & restore.") + print_info("Stateful cloud sandboxes on Fly.io, with checkpoint & restore.") print_info("Sprites persist between sessions and are reused by task_id.") print_info("Sign up at: https://sprites.dev") diff --git a/tools/environments/sprites.py b/tools/environments/sprites.py index 6c0df89639f46..9507e7fa17292 100644 --- a/tools/environments/sprites.py +++ b/tools/environments/sprites.py @@ -1,7 +1,7 @@ """Sprites execution environment. Uses the sprites-py SDK (https://github.com/superfly/sprites-py) to run -commands in Sprites — stateful Firecracker VM sandboxes on Fly.io, with +commands in Sprites — stateful cloud sandboxes on Fly.io, with checkpoint & restore. Persistent by default: each Sprite outlives the session and is reused via a deterministic ``hermes-{task_id}`` name. Cleanup leaves the Sprite running when ``persistent_filesystem`` is True; the Sprite is @@ -27,7 +27,7 @@ class SpritesEnvironment(BaseEnvironment): - """Sprites backend: stateful Firecracker VM sandboxes on Fly.io. + """Sprites backend: stateful cloud sandboxes on Fly.io. Spawn-per-call via ``_ThreadedProcessHandle`` wrapping blocking ``sprite.command(...).combined_output()`` calls. The SDK timeout is diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index f0059daca9e39..2ee8b30bd6ea3 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -12,7 +12,7 @@ - "docker": Execute in Docker containers (isolated, requires Docker) - "modal": Execute in Modal cloud sandboxes (direct Modal or managed gateway) - "vercel_sandbox": Execute in Vercel Sandbox cloud sandboxes -- "sprites": Execute in Sprites — stateful Firecracker VM sandboxes on Fly.io, with checkpoint & restore +- "sprites": Execute in Sprites — stateful cloud sandboxes on Fly.io, with checkpoint & restore Features: - Multiple execution backends (local, docker, modal, vercel_sandbox, sprites) diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 104d86316b1a3..84e60cbfd876f 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -161,7 +161,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `VERCEL_PROJECT_ID` | Vercel project ID (required with `VERCEL_TOKEN`) | | `VERCEL_TEAM_ID` | Vercel team ID (required with `VERCEL_TOKEN`) | | `VERCEL_OIDC_TOKEN` | Vercel short-lived OIDC token (development-only alternative) | -| `SPRITES_TOKEN` | Sprites: stateful Firecracker VM sandboxes on Fly.io ([sprites.dev](https://sprites.dev/)) | +| `SPRITES_TOKEN` | Sprites: stateful cloud sandboxes on Fly.io ([sprites.dev](https://sprites.dev/)) | | `SPRITES_BASE_URL` | Sprites API base URL (default: `https://api.sprites.dev`) | ### Langfuse Observability diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index ca8a805b2a9a4..66e508dd64b57 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -83,7 +83,7 @@ Leaving these unset keeps the legacy defaults (`HERMES_API_TIMEOUT=1800`s, `HERM ## Terminal Backend Configuration -Hermes supports eight terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), a Daytona workspace, a Vercel Sandbox, a Sprite (Fly.io Firecracker VM), or a Singularity/Apptainer container. +Hermes supports eight terminal backends. Each determines where the agent's shell commands actually execute — your local machine, a Docker container, a remote server via SSH, a Modal cloud sandbox (direct or via the Nous-managed gateway), a Daytona workspace, a Vercel Sandbox, a Sprite (Fly.io cloud sandbox), or a Singularity/Apptainer container. ```yaml terminal: @@ -108,7 +108,7 @@ For cloud sandboxes such as Modal, Daytona, Vercel Sandbox, and Sprites, `contai | **modal** | Modal cloud sandbox | Full (cloud VM) | Ephemeral cloud compute, evals | | **daytona** | Daytona workspace | Full (cloud container) | Managed cloud dev environments | | **vercel_sandbox** | Vercel Sandbox | Full (cloud microVM) | Cloud execution with snapshot-backed filesystem persistence | -| **sprites** | Sprite (Fly.io Firecracker VM) | Full (hardware-isolated microVM) | Stateful sandboxes with checkpoint & restore | +| **sprites** | Sprite (Fly.io cloud sandbox) | Full (hardware-isolated VM) | Stateful sandboxes with checkpoint & restore | | **singularity** | Singularity/Apptainer container | Namespaces (--containall) | HPC clusters, shared machines | ### Local Backend @@ -278,7 +278,7 @@ OIDC tokens are short-lived and should not be used as the documented deployment ### Sprites Backend -Runs commands in a [Sprite](https://sprites.dev) — a stateful Firecracker VM backed by Fly.io, with checkpoint & restore. Sprites persist between sessions by default and are reused by task identity: Hermes names them `hermes-{task_id}` and on each session start either resumes the existing Sprite or creates a fresh one. +Runs commands in a [Sprite](https://sprites.dev) — a stateful cloud sandbox on Fly.io, with checkpoint & restore. Sprites persist between sessions by default and are reused by task identity: Hermes names them `hermes-{task_id}` and on each session start either resumes the existing Sprite or creates a fresh one. ```yaml terminal: From 2f6a3495416349e860edd1aa835de62e7ae16417 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Thu, 21 May 2026 23:39:54 +0000 Subject: [PATCH 05/20] docs(terminal): add Sprites OPTION 7 to cli-config.yaml.example Mirrors the Daytona section structure: requirements, what it's good for, the dynamic-compute-allocation caveat, and a minimal YAML stanza. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli-config.yaml.example | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 68c716daab06c..ac28b6dc84a7d 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -257,8 +257,26 @@ terminal: # daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # container_disk: 10240 # Daytona max is 10GB per sandbox +# ----------------------------------------------------------------------------- +# OPTION 7: Sprites cloud execution +# Commands run in a Sprite — a stateful cloud sandbox on Fly.io with native +# checkpoint & restore. Persistent by default; each Sprite is keyed by task_id +# (hermes-{task_id}) so sessions resume cleanly across Hermes restarts. +# Great for: Persistent cloud sandboxes, agent loops that need state to survive +# Requires: pip install 'hermes-agent[sprites]', SPRITES_TOKEN env var +# Note: Sprites allocates compute dynamically (up to 8 CPU / 16 GB RAM). +# container_cpu / container_memory / container_disk are ignored on +# this backend; no region selector yet. +# ----------------------------------------------------------------------------- +# terminal: +# backend: "sprites" +# cwd: "/home/sprite" # Sprite default home; "/root" / "~" auto-rewrite +# timeout: 180 +# lifetime_seconds: 300 +# container_persistent: true # Leave Sprite alive on cleanup (delete if false) + # -# --- Container resource limits (docker, singularity, modal, daytona -- ignored for local/ssh) --- +# --- Container resource limits (docker, singularity, modal, daytona -- ignored for local/ssh/sprites) --- # 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 From 4df877e09ccb71691ea1137f9046ce624f51539c Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Fri, 22 May 2026 08:03:04 +0000 Subject: [PATCH 06/20] feat(terminal): drop SPRITES_BASE_URL override; endpoint is fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sprites API endpoint is static (api.sprites.dev) — there is no self-hosted deployment story to support, so exposing a base-URL override in setup, .env, and docs was just noise. SpritesClient is now constructed with no base_url kwarg (lets the SDK use its own default). Setup keeps a one-line cleanup that removes any previously-saved SPRITES_BASE_URL from existing users' .env on next `hermes setup terminal` run. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/setup.py | 9 ++------- tools/environments/sprites.py | 3 --- website/docs/reference/environment-variables.md | 1 - website/docs/user-guide/configuration.md | 2 -- 4 files changed, 2 insertions(+), 13 deletions(-) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 89ca93c2cc8e9..ddd9cf6975b3d 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1742,13 +1742,8 @@ def setup_terminal_backend(config: dict): save_env_value("SPRITES_TOKEN", token) print_success(" Configured") - # Optional custom API base URL - print() - current_base = get_env_value("SPRITES_BASE_URL") or "" - base = prompt(" Sprites API base URL (blank for default)", current_base) - if base: - save_env_value("SPRITES_BASE_URL", base) - elif current_base: + # Drop any previously-saved override of the base URL — it's now fixed. + if get_env_value("SPRITES_BASE_URL"): remove_env_value("SPRITES_BASE_URL") print() diff --git a/tools/environments/sprites.py b/tools/environments/sprites.py index 9507e7fa17292..38f9da9545bc3 100644 --- a/tools/environments/sprites.py +++ b/tools/environments/sprites.py @@ -67,11 +67,8 @@ def __init__( "Sprites backend requires SPRITES_TOKEN. " "Run `hermes setup terminal` or set SPRITES_TOKEN in .env." ) - base_url = os.getenv("SPRITES_BASE_URL", "https://api.sprites.dev") - self._client = SpritesClient( token=token, - base_url=base_url, timeout=max(30.0, float(timeout)), ) self._persistent = persistent_filesystem diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 84e60cbfd876f..374047d8ae481 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -162,7 +162,6 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `VERCEL_TEAM_ID` | Vercel team ID (required with `VERCEL_TOKEN`) | | `VERCEL_OIDC_TOKEN` | Vercel short-lived OIDC token (development-only alternative) | | `SPRITES_TOKEN` | Sprites: stateful cloud sandboxes on Fly.io ([sprites.dev](https://sprites.dev/)) | -| `SPRITES_BASE_URL` | Sprites API base URL (default: `https://api.sprites.dev`) | ### Langfuse Observability diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 66e508dd64b57..2bbff72a1cdc9 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -297,8 +297,6 @@ pip install 'hermes-agent[sprites]' **Required authentication:** `SPRITES_TOKEN` environment variable. Get a token with `sprite login` or `sprite auth setup --token …` from the [Sprites CLI](https://sprites.dev). -**Optional:** `SPRITES_BASE_URL` overrides the default `https://api.sprites.dev` endpoint (useful for self-hosted deployments). - **Persistence:** With `container_persistent: true`, `cleanup()` leaves the Sprite running so the next session can resume against the same filesystem and live VM. With `persistent: false`, the Sprite is deleted on cleanup. Sprites also support server-side checkpoints exposed by the SDK (`sprite.create_checkpoint`, `sprite.restore_checkpoint`), but Hermes does not invoke them automatically — manage checkpoints via the `sprite-env` CLI if needed. **Credential files:** Hermes pushes `~/.hermes/` credentials/skills/cache into the Sprite at startup via the Sprite filesystem API. Files modified by the agent inside the Sprite are **not** synced back to the host on cleanup (see _Remote-to-Host File Sync_ — sync_back is unsupported on this backend). From 59134fabc32df19f11fc355116df72cc5b42eeb8 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Fri, 22 May 2026 08:15:06 +0000 Subject: [PATCH 07/20] docs(terminal): clarify Sprites intentionally skips sync_back The Sprites backend deliberately doesn't copy agent-modified files back to ~/.hermes/cache/remote-syncs/... on cleanup the way SSH/Modal/Daytona do. Those backends need it because their sandboxes are torn down or reset between sessions; Sprites' ext4 filesystem is persistent and the same Sprite (by task_id) is resumed on the next session with all state intact, so a sync_back would just duplicate the canonical store. - sprites.py cleanup() drops the no-op sync_manager.sync_back() call and replaces it with a comment explaining the design choice. - configuration.md splits "Credential files" into push (still applies) and a new "No sync-back, by design" note; the Remote-to-Host File Sync section calls out the Sprites carve-out. Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/environments/sprites.py | 11 +++++------ website/docs/user-guide/configuration.md | 6 +++++- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/tools/environments/sprites.py b/tools/environments/sprites.py index 38f9da9545bc3..5352a1b18cc10 100644 --- a/tools/environments/sprites.py +++ b/tools/environments/sprites.py @@ -185,12 +185,11 @@ def cleanup(self): if self._sprite is None: return - if self._sync_manager: - logger.info("Sprites: syncing files from sprite...") - try: - self._sync_manager.sync_back() - except Exception as e: - logger.warning("Sprites: sync_back failed: %s", e) + # No sync_back: the Sprite's persistent ext4 filesystem IS the + # authoritative store. Files the agent touched stay in the Sprite + # and are visible again on the next session that resumes by the + # same task_id. (For ephemeral runs with persistent=False, the + # Sprite is intentionally deleted with its filesystem.) try: if self._persistent: diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 2bbff72a1cdc9..84a7f18dca3a9 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -299,7 +299,9 @@ pip install 'hermes-agent[sprites]' **Persistence:** With `container_persistent: true`, `cleanup()` leaves the Sprite running so the next session can resume against the same filesystem and live VM. With `persistent: false`, the Sprite is deleted on cleanup. Sprites also support server-side checkpoints exposed by the SDK (`sprite.create_checkpoint`, `sprite.restore_checkpoint`), but Hermes does not invoke them automatically — manage checkpoints via the `sprite-env` CLI if needed. -**Credential files:** Hermes pushes `~/.hermes/` credentials/skills/cache into the Sprite at startup via the Sprite filesystem API. Files modified by the agent inside the Sprite are **not** synced back to the host on cleanup (see _Remote-to-Host File Sync_ — sync_back is unsupported on this backend). +**Credential files:** Hermes pushes `~/.hermes/` credentials/skills/cache into the Sprite at startup via the Sprite filesystem API. + +**No sync-back, by design:** Unlike SSH/Modal/Daytona, this backend does **not** copy the agent's files back to the host on cleanup. The Sprite's persistent ext4 filesystem _is_ the authoritative store — anything the agent writes stays inside the Sprite and is visible again the next time Hermes resumes that `task_id`. If you opt out of persistence (`container_persistent: false`), the Sprite is deleted along with its filesystem; treat that mode as intentionally ephemeral. ### Singularity/Apptainer Backend @@ -340,6 +342,8 @@ When in doubt, set `terminal.backend` back to `local` and verify that commands r For the **SSH**, **Modal**, and **Daytona** backends (anywhere the agent's working tree lives on a different machine than the host running Hermes), Hermes tracks files the agent touched inside the remote sandbox and, on session teardown / sandbox cleanup, **syncs the modified files back to the host** under `~/.hermes/cache/remote-syncs//`. +The **Sprites** backend deliberately opts out of this flow — its persistent ext4 filesystem is the authoritative store, so files survive in the Sprite itself across sessions rather than being copied back to the host. See the [Sprites Backend](#sprites-backend) section for details. + - Triggers on: session close, `/new`, `/reset`, gateway message timeout, `delegate_task` subagent completion when the child used a remote backend. - Covers the whole tree the agent modified, not just files it explicitly opened. Additions, edits, and deletions are all captured. - The remote sandbox may have been torn down by the time you go looking; the local `~/.hermes/cache/remote-syncs/…` copy is the authoritative record of what the agent changed. From 6f67d9e3bd8c61ac9d997968e0bec609bd578dd6 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Fri, 22 May 2026 08:50:29 +0000 Subject: [PATCH 08/20] docs(terminal): document Sprites restricted tokens (prefix scoping) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprites tokens default to full-account access, but the dashboard (Account → Tokens → ⚙ → Restricted Token Options) can mint tokens scoped to a name prefix and a max-sprites cap. Pair this with our deterministic hermes-{task_id} naming by creating a hermes-prefixed token — the token can manage everything Hermes spawns and nothing else. - configuration.md: new "Restricted tokens" subsection under Sprites authentication, explaining the two restriction knobs and why the hermes prefix is the right default for CI / shared envs. - setup.py: surface the same tip inline when the wizard prompts for the token so first-time users see it before pasting an unrestricted one. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/setup.py | 2 ++ website/docs/user-guide/configuration.md | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index ddd9cf6975b3d..8388d1e2afbcc 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -1737,6 +1737,8 @@ def setup_terminal_backend(config: dict): print_success(" Updated") else: print_info(" Get a token with: sprite login (or `sprite auth setup --token ...`)") + print_info(" Tip: in the dashboard you can mint a Restricted Token with prefix=hermes") + print_info(" to scope it to hermes-* sprites only. Recommended for CI / shared use.") token = prompt(" Sprites token", password=True) if token: save_env_value("SPRITES_TOKEN", token) diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index 84a7f18dca3a9..769f980d645e2 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -297,6 +297,15 @@ pip install 'hermes-agent[sprites]' **Required authentication:** `SPRITES_TOKEN` environment variable. Get a token with `sprite login` or `sprite auth setup --token …` from the [Sprites CLI](https://sprites.dev). +#### Restricted tokens (recommended for CI / shared envs) + +The Sprites dashboard (**Account → Tokens → ⚙ Create Token → Restricted Token Options**) lets you scope a token to: + +- **Name Prefix** — the token can only create or operate on sprites whose name starts with `-…`. Set this to `hermes` and the token can manage `hermes-default`, `hermes-mytask`, `hermes-{task_id}`, etc. — i.e. every sprite Hermes creates — but nothing else in the account. +- **Max Sprites Total** — caps how many sprites the token may keep alive at once. Useful for budgeting CI / batch / multi-agent workloads against a known ceiling. + +This pairs cleanly with the deterministic `hermes-{task_id}` naming scheme: a `hermes`-prefixed restricted token is the right thing to hand to a Hermes runtime (CI, gateway, shared dev box, untrusted automation) when you don't want it touching the rest of your Sprites fleet. + **Persistence:** With `container_persistent: true`, `cleanup()` leaves the Sprite running so the next session can resume against the same filesystem and live VM. With `persistent: false`, the Sprite is deleted on cleanup. Sprites also support server-side checkpoints exposed by the SDK (`sprite.create_checkpoint`, `sprite.restore_checkpoint`), but Hermes does not invoke them automatically — manage checkpoints via the `sprite-env` CLI if needed. **Credential files:** Hermes pushes `~/.hermes/` credentials/skills/cache into the Sprite at startup via the Sprite filesystem API. From 5ebb6c0407baac8b1e50633c1295c0829fa56d38 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Fri, 22 May 2026 09:00:19 +0000 Subject: [PATCH 09/20] fix(tools): bound sprites-py to <0.2 per supply-chain pinning policy CONTRIBUTING.md (post Mar/May 2026 supply-chain rules) requires every new PyPI dependency to declare a ` --- pyproject.toml | 2 +- tools/lazy_deps.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0c1df579dddf5..2051649c371e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,7 +83,7 @@ edge-tts = ["edge-tts==7.2.7"] modal = ["modal==1.3.4"] daytona = ["daytona==0.155.0"] vercel = ["vercel==0.5.7"] -sprites = ["sprites-py==0.0.1rc37"] +sprites = ["sprites-py>=0.0.1rc37,<0.2"] hindsight = ["hindsight-client==0.6.1"] dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"] messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index 9d4472c8cbc30..734b5aa941a56 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -153,7 +153,7 @@ "terminal.modal": ("modal==1.3.4",), "terminal.daytona": ("daytona==0.155.0",), "terminal.vercel": ("vercel==0.5.7",), - "terminal.sprites": ("sprites-py==0.0.1rc37",), + "terminal.sprites": ("sprites-py>=0.0.1rc37,<0.2",), # ─── Skills ──────────────────────────────────────────────────────────── "skill.google_workspace": ( From 479911b08e204069285f915d38694cd49a1d1651 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Fri, 22 May 2026 09:13:59 +0000 Subject: [PATCH 10/20] test(terminal): add Sprites unit + integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unit tests (tests/tools/test_sprites_environment.py): 18 cases against a mocked sprites-py SDK — no token, no network. Cover construction (missing-token error, persistent get-first, create-when-not-found, no compute kwargs, no base_url kwarg), cwd resolution (default /root → detected home, ~ rewrite, explicit cwd preserved), cleanup (persistent leaves the Sprite alive, ephemeral deletes it, idempotency, client.close), _run_bash exit-code surfacing (zero, ExitError → 7, TimeoutError → 124), filesystem push (write_bytes + parent.mkdir, unlink per path), and the _stdin_mode = heredoc declaration. Integration tests (tests/integration/test_sprites_terminal.py): 8 cases against the live api.sprites.dev — gated by SPRITES_TOKEN and @pytest.mark.integration. Module-level skip when the token is absent. Token is captured at import time and re-injected via an autouse fixture because the project conftest's hermetic env wipes everything ending in _TOKEN. Covers basic exec / non-zero exit / OS info / Python availability, write+read, env var persistence across calls, the sprite-env info identity check (asserts hermes-default substring and that the in-Sprite boot_id differs from the host's), and filesystem persistence across a session recycle. Verified locally via scripts/run_tests.sh — 24,007/24,033 pass (26 pre-existing failures in unrelated test files: acp, gateway systemd, browser binary lookup, etc.). 18 unit tests pass under per-file isolation in ~5 min; integration tests pass against the live API in ~80s. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/integration/test_sprites_terminal.py | 128 ++++++++ tests/tools/test_sprites_environment.py | 332 +++++++++++++++++++++ 2 files changed, 460 insertions(+) create mode 100644 tests/integration/test_sprites_terminal.py create mode 100644 tests/tools/test_sprites_environment.py diff --git a/tests/integration/test_sprites_terminal.py b/tests/integration/test_sprites_terminal.py new file mode 100644 index 0000000000000..2d96b3a059818 --- /dev/null +++ b/tests/integration/test_sprites_terminal.py @@ -0,0 +1,128 @@ +"""Integration tests for the Sprites terminal backend. + +Requires SPRITES_TOKEN to be set. Run with: + TERMINAL_ENV=sprites pytest tests/integration/test_sprites_terminal.py -v +""" + +import json +import os +import sys +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + +# Capture the token at import time. The project-wide hermetic conftest +# wipes anything ending in _TOKEN before each test runs, so we save the +# value here and re-inject it via the autouse fixture below. +_SPRITES_TOKEN = os.getenv("SPRITES_TOKEN") +if not _SPRITES_TOKEN: + pytest.skip("SPRITES_TOKEN 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_sprites(monkeypatch): + # Re-inject the token the hermetic conftest deleted. + monkeypatch.setenv("SPRITES_TOKEN", _SPRITES_TOKEN) + monkeypatch.setenv("TERMINAL_ENV", "sprites") + # Match the documented "ephemeral test" default — tests clean up after themselves. + monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "false") + + +@pytest.fixture() +def task_id(request): + """Unique task_id per test; sprite is cleaned up afterwards.""" + tid = f"sprites_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 TestSpritesBasic: + def test_echo(self, task_id): + r = _run("echo 'Hello from a Sprite!'", task_id) + assert r["exit_code"] == 0 + assert "Hello from a Sprite!" 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"] + + def test_python_available(self, task_id): + r = _run("python3 --version || python --version", task_id) + assert r["exit_code"] == 0 + assert "Python" in r["output"] + + +class TestSpritesFilesystem: + def test_write_and_read_file(self, task_id): + _run("echo 'sprites content' > /tmp/sprites_test.txt", task_id) + r = _run("cat /tmp/sprites_test.txt", task_id) + assert r["exit_code"] == 0 + assert "sprites content" in r["output"] + + def test_env_var_persistence(self, task_id): + _run("export SPRITES_TEST_VAR=heyo", task_id) + r = _run("echo $SPRITES_TEST_VAR", task_id) + assert r["exit_code"] == 0 + assert "heyo" in r["output"] + + +class TestSpritesIdentity: + def test_runs_inside_a_sprite(self, task_id): + """Output should confirm we're in a Sprite, not on the host.""" + r = _run("sprite-env info 2>/dev/null || echo MISSING", task_id) + assert r["exit_code"] == 0 + if "MISSING" in r["output"]: + pytest.skip("sprite-env CLI not present inside the Sprite") + # Terminal-tool's `_resolve_container_task_id` collapses every + # incoming task_id to "default", so the Sprite name is always + # hermes-default regardless of what we passed to _run(). + assert "hermes-default" in r["output"] + # Sanity: the boot_id from inside the Sprite must differ from this + # process's view (i.e. command did NOT run on the host). + host_boot = open("/proc/sys/kernel/random/boot_id").read().strip() + r2 = _run("cat /proc/sys/kernel/random/boot_id", task_id) + assert host_boot not in r2["output"] + + +class TestSpritesPersistence: + def test_filesystem_survives_session_recycle(self): + """Write a marker, tear down the env, resume — file should still be there.""" + task = "sprites_test_persist" + try: + os.environ["TERMINAL_CONTAINER_PERSISTENT"] = "true" + _run("echo 'survive' > /tmp/sprites_persist.txt", task) + cleanup_vm(task) # persistent=true → leaves the sprite alive + + r = _run("cat /tmp/sprites_persist.txt", task) + assert r["exit_code"] == 0 + assert "survive" in r["output"] + finally: + os.environ["TERMINAL_CONTAINER_PERSISTENT"] = "false" + cleanup_vm(task) # force-delete on the way out diff --git a/tests/tools/test_sprites_environment.py b/tests/tools/test_sprites_environment.py new file mode 100644 index 0000000000000..c4997bc47cf1e --- /dev/null +++ b/tests/tools/test_sprites_environment.py @@ -0,0 +1,332 @@ +"""Unit tests for the Sprites cloud sandbox environment backend. + +These exercise SpritesEnvironment against a mocked sprites-py SDK; no +network or token required. Live-API checks live under +tests/integration/test_sprites_terminal.py. +""" + +import sys +import types +from unittest.mock import MagicMock, patch + +import pytest + + +# --------------------------------------------------------------------------- +# Mock sprites-py SDK +# --------------------------------------------------------------------------- + +class _NotFoundError(Exception): + pass + + +class _SpriteError(Exception): + pass + + +class _ExitError(Exception): + """Mirror of sprites.exceptions.ExitError.""" + + def __init__(self, message, exit_code, stdout=b"", stderr=b""): + super().__init__(message) + self._exit_code = exit_code + self.stdout = stdout + self.stderr = stderr + + def exit_code(self): + return self._exit_code + + +class _SpritesTimeoutError(Exception): + pass + + +def _patch_sprites_imports(monkeypatch): + """Inject a fake sprites SDK so SpritesEnvironment can import it.""" + sprites_mod = types.ModuleType("sprites") + sprites_mod.SpritesClient = MagicMock(name="SpritesClient") + + exc_mod = types.ModuleType("sprites.exceptions") + exc_mod.NotFoundError = _NotFoundError + exc_mod.SpriteError = _SpriteError + exc_mod.ExitError = _ExitError + exc_mod.TimeoutError = _SpritesTimeoutError + sprites_mod.exceptions = exc_mod + + monkeypatch.setitem(sys.modules, "sprites", sprites_mod) + monkeypatch.setitem(sys.modules, "sprites.exceptions", exc_mod) + return sprites_mod, exc_mod + + +def _make_sprite(name="hermes-default"): + sprite = MagicMock() + sprite.name = name + + # $HOME detection returns "/home/sprite" by default + home_cmd = MagicMock() + home_cmd.combined_output.return_value = b"/home/sprite\n" + + # init_session() bootstrap also goes through sprite.command(...). + # combined_output() must succeed (return bytes) for snapshot_ready=True. + bootstrap_cmd = MagicMock() + bootstrap_cmd.combined_output.return_value = b"\n__HERMES_CWD_xxx__/home/sprite__HERMES_CWD_xxx__\n" + + sprite.command.side_effect = [home_cmd, bootstrap_cmd] + sprite.filesystem.return_value = MagicMock() + return sprite + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def sprites_sdk(monkeypatch): + return _patch_sprites_imports(monkeypatch) + + +@pytest.fixture() +def make_env(sprites_sdk, monkeypatch): + """Build a SpritesEnvironment instance against a mocked SDK. + + Returns a factory; keyword args mirror SpritesEnvironment.__init__. + The factory accepts an optional ``get_side_effect`` to control what + ``client.get_sprite()`` does (e.g. raise NotFoundError to force create). + """ + monkeypatch.setenv("SPRITES_TOKEN", "test-token") + # Don't try to lazy-install the SDK during tests + monkeypatch.setattr( + "tools.lazy_deps.ensure", lambda *a, **k: None, raising=False + ) + # Skip credential-file enumeration so init doesn't bring in real ~/.hermes state + monkeypatch.setattr( + "tools.credential_files.get_credential_file_mounts", lambda: [] + ) + monkeypatch.setattr( + "tools.credential_files.iter_skills_files", lambda **kw: [] + ) + monkeypatch.setattr( + "tools.credential_files.iter_cache_files", lambda **kw: [] + ) + # Keep the base class from blocking forever on interrupt polling + monkeypatch.setattr("tools.environments.base.is_interrupted", lambda: False) + + def _factory(get_side_effect=None, sprite=None, **kwargs): + sprite = sprite or _make_sprite() + + mock_client = MagicMock() + mock_client.create_sprite.return_value = sprite + if get_side_effect is not None: + mock_client.get_sprite.side_effect = get_side_effect + else: + mock_client.get_sprite.side_effect = _NotFoundError("not found") + + sprites_mod, _ = sprites_sdk + sprites_mod.SpritesClient = MagicMock(return_value=mock_client) + + from tools.environments.sprites import SpritesEnvironment + + env = SpritesEnvironment(**kwargs) + env._mock_client = mock_client + env._mock_sprite = sprite + return env + + return _factory + + +# --------------------------------------------------------------------------- +# Construction / token handling +# --------------------------------------------------------------------------- + +class TestConstruction: + def test_missing_token_raises(self, sprites_sdk, monkeypatch): + monkeypatch.delenv("SPRITES_TOKEN", raising=False) + monkeypatch.delenv("SPRITE_TOKEN", raising=False) + monkeypatch.setattr( + "tools.lazy_deps.ensure", lambda *a, **k: None, raising=False + ) + from tools.environments.sprites import SpritesEnvironment + + with pytest.raises(ValueError, match="SPRITES_TOKEN"): + SpritesEnvironment(task_id="x") + + def test_persistent_uses_get_first(self, make_env): + existing = _make_sprite(name="hermes-mine") + env = make_env( + get_side_effect=lambda name: existing, + sprite=existing, + task_id="mine", + persistent_filesystem=True, + ) + env._mock_client.get_sprite.assert_called_once_with("hermes-mine") + env._mock_client.create_sprite.assert_not_called() + + def test_creates_when_not_found(self, make_env): + env = make_env(task_id="fresh", persistent_filesystem=True) + env._mock_client.get_sprite.assert_called_once_with("hermes-fresh") + env._mock_client.create_sprite.assert_called_once_with("hermes-fresh") + + def test_no_size_kwargs_passed_to_create(self, make_env): + """Compute sizing isn't honored yet — make sure we don't sneak it back in.""" + env = make_env(task_id="sizing") + args, kwargs = env._mock_client.create_sprite.call_args + assert args == ("hermes-sizing",) + assert kwargs == {} + + def test_no_base_url_kwarg(self, make_env, sprites_sdk): + """SpritesClient is constructed without a base_url override (endpoint is fixed).""" + env = make_env(task_id="urlcheck") + sprites_mod, _ = sprites_sdk + _, kwargs = sprites_mod.SpritesClient.call_args + assert "base_url" not in kwargs + + +# --------------------------------------------------------------------------- +# CWD / home detection +# --------------------------------------------------------------------------- + +class TestCwdResolution: + def test_default_cwd_rewrites_to_detected_home(self, make_env): + env = make_env(task_id="cwd1") # default cwd="/root" + assert env.cwd == "/home/sprite" # rewritten from "/root" → detected home + + def test_tilde_cwd_rewrites_to_detected_home(self, make_env): + env = make_env(cwd="~", task_id="cwd2") + assert env.cwd == "/home/sprite" + + def test_explicit_cwd_not_overridden(self, make_env): + sprite = _make_sprite() + env = make_env(sprite=sprite, cwd="/workspace", task_id="cwd3") + assert env.cwd == "/workspace" + + +# --------------------------------------------------------------------------- +# Cleanup +# --------------------------------------------------------------------------- + +class TestCleanup: + def test_persistent_cleanup_leaves_sprite_alive(self, make_env): + env = make_env(task_id="persist", persistent_filesystem=True) + sprite = env._mock_sprite + env.cleanup() + sprite.delete.assert_not_called() + + def test_non_persistent_cleanup_deletes_sprite(self, make_env): + env = make_env(task_id="ephem", persistent_filesystem=False) + sprite = env._mock_sprite + env.cleanup() + sprite.delete.assert_called_once() + + def test_cleanup_idempotent(self, make_env): + env = make_env(task_id="idem", persistent_filesystem=True) + env.cleanup() + env.cleanup() # second call must not raise + + def test_cleanup_closes_client(self, make_env): + env = make_env(task_id="closeit", persistent_filesystem=True) + env.cleanup() + env._mock_client.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# _run_bash exit-code surfacing +# --------------------------------------------------------------------------- + +class TestRunBashExitCodes: + def test_zero_exit_returns_output(self, make_env): + env = make_env(task_id="rb0") + # Reset side_effect; new sprite.command() call should return a fresh Cmd. + cmd = MagicMock() + cmd.combined_output.return_value = b"hi\n" + env._mock_sprite.command = MagicMock(return_value=cmd) + + handle = env._run_bash("echo hi", timeout=10) + handle.wait() + out = handle.stdout.read() + assert out == "hi\n" + assert handle.returncode == 0 + + def test_nonzero_exit_surfaces_code_from_ExitError(self, make_env, sprites_sdk): + env = make_env(task_id="rb7") + _, exc_mod = sprites_sdk + cmd = MagicMock() + cmd.combined_output.side_effect = exc_mod.ExitError( + "exit status 7", 7, b"before\n", b"" + ) + env._mock_sprite.command = MagicMock(return_value=cmd) + + handle = env._run_bash("exit 7", timeout=10) + handle.wait() + out = handle.stdout.read() + assert "before" in out + assert handle.returncode == 7 + + def test_timeout_surfaces_124(self, make_env, sprites_sdk): + env = make_env(task_id="rbto") + _, exc_mod = sprites_sdk + cmd = MagicMock() + cmd.combined_output.side_effect = exc_mod.TimeoutError("deadline") + env._mock_sprite.command = MagicMock(return_value=cmd) + + handle = env._run_bash("sleep 999", timeout=1) + handle.wait() + assert handle.returncode == 124 + + +# --------------------------------------------------------------------------- +# File-sync push (upload_fn behavior) +# --------------------------------------------------------------------------- + +class TestFileSyncPush: + def test_upload_writes_via_filesystem_api(self, make_env, tmp_path): + env = make_env(task_id="fs") + # Build a fake host file + host_file = tmp_path / "secret.txt" + host_file.write_bytes(b"hello") + + # Mock the SpritePath returned by `fs / remote_path` + remote_path_obj = MagicMock() + env._fs.__truediv__.return_value = remote_path_obj + + env._sprite_upload(str(host_file), "/home/sprite/.hermes/foo") + remote_path_obj.parent.mkdir.assert_called_once_with( + parents=True, exist_ok=True + ) + remote_path_obj.write_bytes.assert_called_once_with(b"hello") + + def test_delete_invokes_unlink_per_path(self, make_env): + env = make_env(task_id="fsdel") + remote_obj = MagicMock() + env._fs.__truediv__.return_value = remote_obj + env._sprite_delete(["/home/sprite/.hermes/a", "/home/sprite/.hermes/b"]) + # Each path → one unlink call + assert remote_obj.unlink.call_count == 2 + remote_obj.unlink.assert_any_call(missing_ok=True) + + +# --------------------------------------------------------------------------- +# _stdin_mode wiring +# --------------------------------------------------------------------------- + +class TestStdinMode: + def test_stdin_mode_is_heredoc(self): + """Ensures the base class will embed stdin via heredoc, not pipe. + + SDK calls don't accept a real stdin stream, so the backend declares + ``_stdin_mode = "heredoc"`` and the base ``execute()`` wraps stdin + into the command string before calling ``_run_bash``. + """ + # Inspect the class without constructing — no SDK needed for this check + import importlib + + # Stub the SDK so the module imports cleanly outside the make_env fixture + sys.modules.setdefault("sprites", types.ModuleType("sprites")) + sys.modules.setdefault("sprites.exceptions", types.ModuleType("sprites.exceptions")) + + # Force a clean import (in case earlier tests left it in sys.modules with + # a different SDK mocked in) + if "tools.environments.sprites" in sys.modules: + importlib.reload(sys.modules["tools.environments.sprites"]) + from tools.environments.sprites import SpritesEnvironment + + assert SpritesEnvironment._stdin_mode == "heredoc" From 4253a46d77dd04572e753ff816f6b3e85d19562f Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Fri, 22 May 2026 09:31:49 +0000 Subject: [PATCH 11/20] docs(readme): add Sprites to the terminal-backends summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps "Seven" → "Eight" and adds a one-sentence framing for Sprites: stateful Fly.io sandboxes with native checkpoint & restore that resume session-to-session (vs. Modal/Daytona, which hibernate-and-wake). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b659f56fa532d..65dd4279d6dd0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open A closed learning loopAgent-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. Honcho dialectic user modeling. 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. -Runs anywhere, not just your laptopSeven terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. 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. +Runs anywhere, not just your laptopEight terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, Vercel Sandbox, and Sprites. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Sprites are stateful Fly.io sandboxes with native checkpoint & restore — the same sandbox (filesystem plus live processes) resumes session-to-session. Run it on a $5 VPS or a GPU cluster. Research-readyBatch trajectory generation, trajectory compression for training the next generation of tool-calling models. From 76ec577cc4713ea295cac86187f19db28b19d704 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Fri, 22 May 2026 09:34:02 +0000 Subject: [PATCH 12/20] docs(readme): drop the Sprites link from the backends summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the styling of every other backend name in the same sentence — none of the others link out. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 65dd4279d6dd0..43725ca319759 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open A closed learning loopAgent-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. Honcho dialectic user modeling. 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. -Runs anywhere, not just your laptopEight terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, Vercel Sandbox, and Sprites. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Sprites are stateful Fly.io sandboxes with native checkpoint & restore — the same sandbox (filesystem plus live processes) resumes session-to-session. Run it on a $5 VPS or a GPU cluster. +Runs anywhere, not just your laptopEight terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, Vercel Sandbox, and Sprites. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Sprites are stateful Fly.io sandboxes with native checkpoint & restore — the same sandbox (filesystem plus live processes) resumes session-to-session. Run it on a $5 VPS or a GPU cluster. Research-readyBatch trajectory generation, trajectory compression for training the next generation of tool-calling models. From b0f2009c8b0a48bb713a56bdf818f78706fc8b99 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Fri, 22 May 2026 09:36:00 +0000 Subject: [PATCH 13/20] docs(readme): group Sprites with Daytona/Modal under serverless persistence Sprites' hibernate-when-idle / wake-on-demand cost model is the same as Daytona's and Modal's, so the single grouped sentence carries it without needing a dedicated callout. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 43725ca319759..d8c46a921d8b2 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open A closed learning loopAgent-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. Honcho dialectic user modeling. 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. -Runs anywhere, not just your laptopEight terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, Vercel Sandbox, and Sprites. Daytona and Modal offer serverless persistence — your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Sprites are stateful Fly.io sandboxes with native checkpoint & restore — the same sandbox (filesystem plus live processes) resumes session-to-session. Run it on a $5 VPS or a GPU cluster. +Runs anywhere, not just your laptopEight terminal backends — local, Docker, SSH, Singularity, Modal, Daytona, Vercel Sandbox, and Sprites. Daytona, Modal, and Sprites 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. Research-readyBatch trajectory generation, trajectory compression for training the next generation of tool-calling models. From 62ddef962b60ead95d97b01a977a356f0d2873e2 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Fri, 22 May 2026 10:03:52 +0000 Subject: [PATCH 14/20] feat(terminal): wire Sprites into status/doctor/config CLI surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sprites backend was already wired through the agent runtime (_create_environment, requirements check) but missing from the diagnostic CLI surfaces, so users with sprites configured got a bare "Backend: sprites" with no token/SDK detail and `hermes doctor` had no proactive check. - hermes_cli/status.py: new branch reporting sprites-py install status and whether SPRITES_TOKEN is set. - hermes_cli/doctor.py: dedicated block mirroring the Daytona/Vercel pattern — checks SPRITES_TOKEN presence, SDK install, and prints the persistence semantics ("Sprite stays alive" vs "Sprite is deleted on cleanup"). - hermes_cli/config.py: new branch in `hermes config show` reporting whether the token is configured. - AGENTS.md: add sprites (and the previously-missed vercel_sandbox) to the project-structure backends listing. Co-Authored-By: Claude Opus 4.7 (1M context) --- AGENTS.md | 2 +- hermes_cli/config.py | 3 +++ hermes_cli/doctor.py | 29 +++++++++++++++++++++++++++++ hermes_cli/status.py | 7 +++++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index dd45310ca86dd..32aad3097a6ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, vercel_sandbox, sprites, singularity) ├── gateway/ # Messaging gateway — run.py + session.py + platforms/ │ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp, │ │ # homeassistant, signal, matrix, mattermost, email, sms, diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 715fd7eb76ff3..54877324a8482 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -5093,6 +5093,9 @@ def show_config(): elif terminal.get('backend') == 'vercel_sandbox': print(f" Vercel runtime: {terminal.get('vercel_runtime', 'node24')}") print(f" Vercel auth: {'configured' if get_env_value('VERCEL_OIDC_TOKEN') or (get_env_value('VERCEL_TOKEN') and get_env_value('VERCEL_PROJECT_ID') and get_env_value('VERCEL_TEAM_ID')) else '(not set)'}") + elif terminal.get('backend') == 'sprites': + sprites_token = get_env_value('SPRITES_TOKEN') or get_env_value('SPRITE_TOKEN') + print(f" Sprites token: {'configured' if sprites_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') diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index df75ac6866408..779ba6049a525 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1222,6 +1222,35 @@ def run_doctor(args): else: check_info("Vercel persistence: ephemeral filesystem") + # Sprites (if using sprites backend) + if terminal_env == "sprites": + sprites_token = os.getenv("SPRITES_TOKEN") or os.getenv("SPRITE_TOKEN") + if sprites_token: + check_ok("Sprites token", "(configured)") + else: + _fail_and_issue( + "SPRITES_TOKEN not set", + "(required for TERMINAL_ENV=sprites)", + "Run `sprite login` (or `sprite auth setup --token …`) and put the token in ~/.hermes/.env as SPRITES_TOKEN", + issues, + ) + + if importlib.util.find_spec("sprites") is not None: + check_ok("sprites-py SDK", "(installed)") + else: + _fail_and_issue( + "sprites-py SDK not installed", + "(pip install 'hermes-agent[sprites]')", + "Install the Sprites optional dependency: pip install 'hermes-agent[sprites]'", + issues, + ) + + persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"} + if persistent: + check_info("Sprites persistence: Sprite stays alive across sessions; its ext4 filesystem is the authoritative store") + else: + check_info("Sprites persistence: Sprite is deleted on cleanup (ephemeral)") + # Node.js + agent-browser (for browser automation tools) if _safe_which("node"): check_ok("Node.js") diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 5629da03fe38b..5f58f8e85f35f 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -380,6 +380,13 @@ def _resolve_env(env_ref) -> str: elif terminal_env == "daytona": daytona_image = os.getenv("TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20") print(f" Daytona Image: {daytona_image}") + elif terminal_env == "sprites": + sdk_ok = importlib.util.find_spec("sprites") is not None + sdk_label = "installed" if sdk_ok else "missing (install: pip install 'hermes-agent[sprites]')" + token_ok = bool(os.getenv("SPRITES_TOKEN") or os.getenv("SPRITE_TOKEN")) + token_label = "configured" if token_ok else "(not set — required)" + print(f" SDK: {check_mark(sdk_ok)} {sdk_label}") + print(f" Token: {check_mark(token_ok)} {token_label}") elif terminal_env == "vercel_sandbox": runtime = os.getenv("TERMINAL_VERCEL_RUNTIME") or terminal_cfg.get("vercel_runtime") or "node24" persist = os.getenv("TERMINAL_CONTAINER_PERSISTENT") From 9ca839183a62aab14dcff006771667f5e4651104 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Fri, 22 May 2026 12:12:29 +0000 Subject: [PATCH 15/20] fix(security): register Sprites in approval/blocklist/dispatch sets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several cross-cutting registrations only listed the prior sandboxed backends (docker / singularity / modal / daytona / vercel_sandbox); Sprites is also a remote, hardware-isolated sandbox and needs the same treatment. Without this, the agent path on a Sprites backend hits false dangerous-command approval prompts, leaks SPRITES_TOKEN to local- backend subprocesses, and silently drops container_persistent overrides from the code_execution_tool / file_tools dispatch paths. - tools/approval.py: add "sprites" to both sandboxed-backend skip sets (the agent's command is running inside the Sprite, not on the host — same isolation guarantee as the other cloud backends). - tools/environments/local.py: add SPRITES_TOKEN / SPRITE_TOKEN to the provider env blocklist so they are stripped from local-backend child process environments (matches the VERCEL_*, DAYTONA_API_KEY, and MODAL_TOKEN_* treatment). - tools/skills_tool.py: add "sprites" to _REMOTE_ENV_BACKENDS so the skills tool routes its remote/local distinction correctly. - tools/file_tools.py: add "sprites" to the container_config dispatch set so container_persistent: false can take effect through the file-tool code path. - tools/code_execution_tool.py: same dispatch fix (I had removed it in 015e4fe5b on the grounds that sprites ignores CPU/memory/disk — but container_persistent IS honored). - hermes_cli/web_server.py: add "sprites" to the dashboard's terminal.backend select-control options. Surfaced by comparing this branch against NousResearch/hermes-agent#17445 (the Vercel Sandbox backend PR), which had to make every one of these registrations explicitly. Same audit applies here. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes_cli/web_server.py | 2 +- tools/approval.py | 4 ++-- tools/code_execution_tool.py | 2 +- tools/environments/local.py | 2 ++ tools/file_tools.py | 2 +- tools/skills_tool.py | 2 +- 6 files changed, 8 insertions(+), 6 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 93c4684fc2087..5c4a167876e7c 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -265,7 +265,7 @@ async def auth_middleware(request: Request, call_next): "terminal.backend": { "type": "select", "description": "Terminal execution backend", - "options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity"], + "options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "sprites", "singularity"], }, "terminal.vercel_runtime": { "type": "select", diff --git a/tools/approval.py b/tools/approval.py index bfc70cd0fb04d..825474e0424bf 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -925,7 +925,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", "daytona", "vercel_sandbox"}: + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox", "sprites"}: return {"approved": True, "message": None} # Hardline floor: commands with no recovery path (rm -rf /, mkfs, dd @@ -1050,7 +1050,7 @@ def check_all_command_guards(command: str, env_type: str, other was shown to the user. """ # Skip containers for both checks - if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox", "sprites"}: return {"approved": True, "message": None} # Hardline floor: unconditional block for catastrophic commands diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index bdbc4bfbe1bfb..6b5e3f8b547b3 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -612,7 +612,7 @@ def _get_or_create_env(task_id: str): cwd = overrides.get("cwd") or config["cwd"] container_config = None - if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox", "sprites"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), diff --git a/tools/environments/local.py b/tools/environments/local.py index 1fdc3589236f4..c158da86f62cb 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -164,6 +164,8 @@ def _build_provider_env_blocklist() -> frozenset: "VERCEL_TOKEN", "VERCEL_PROJECT_ID", "VERCEL_TEAM_ID", + "SPRITES_TOKEN", + "SPRITE_TOKEN", }) return frozenset(blocked) diff --git a/tools/file_tools.py b/tools/file_tools.py index 2cedc4bcd5f19..80e59a33c9c64 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -380,7 +380,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", "daytona", "vercel_sandbox"}: + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox", "sprites"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), diff --git a/tools/skills_tool.py b/tools/skills_tool.py index 0cd61cc751fc6..23097ca81f696 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -103,7 +103,7 @@ } _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _REMOTE_ENV_BACKENDS = frozenset( - {"docker", "singularity", "modal", "ssh", "daytona", "vercel_sandbox"} + {"docker", "singularity", "modal", "ssh", "daytona", "vercel_sandbox", "sprites"} ) _secret_capture_callback = None From fb81807ce5bc08c6b00c4bfbcd239895ec9bdd85 Mon Sep 17 00:00:00 2001 From: Kyle McLaren Date: Fri, 22 May 2026 12:14:49 +0000 Subject: [PATCH 16/20] docs(terminal): add Sprites to features/tools.md and security.md Mirrors the placement vercel_sandbox got in PR #17445: - features/tools.md: row in the backend comparison table, "sprites" added to the backend-enum comment, and a dedicated "Sprites (Fly.io)" subsection covering install + auth, the hermes-{task_id} resume model, the restricted-token recommendation for CI / shared envs, the persistence semantics, and the "no sync-back, by design" rationale. - security.md: container-bypass info note and production-tip paragraph both mention sprites; comparison table gains a row showing dangerous-command checks are skipped (because the Sprite is the security boundary). Co-Authored-By: Claude Opus 4.7 (1M context) --- website/docs/user-guide/features/tools.md | 18 +++++++++++++++++- website/docs/user-guide/security.md | 5 +++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index ec0d83b81f1b8..51d8f353e7204 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -66,13 +66,14 @@ The terminal tool can execute commands in different environments: | `modal` | Cloud execution | Serverless, scale | | `daytona` | Cloud sandbox workspace | Persistent remote dev environments | | `vercel_sandbox` | Vercel Sandbox cloud microVM | Cloud execution with snapshot-backed filesystem persistence | +| `sprites` | Sprites (Fly.io) cloud sandbox | Persistent sandboxes with native checkpoint & restore | ### Configuration ```yaml # In ~/.hermes/config.yaml terminal: - backend: local # or: docker, ssh, singularity, modal, daytona, vercel_sandbox + backend: local # or: docker, ssh, singularity, modal, daytona, vercel_sandbox, sprites cwd: "." # Working directory timeout: 180 # Command timeout in seconds ``` @@ -151,6 +152,21 @@ Background terminal commands use Hermes' generic non-local process flow: spawn, Leave `container_disk` unset or at the shared default `51200`; custom disk sizing is unsupported for Vercel Sandbox and will fail diagnostics/backend creation. +### Sprites (Fly.io) + +```bash +pip install 'hermes-agent[sprites]' +hermes config set terminal.backend sprites +``` + +Authenticate with `SPRITES_TOKEN` (`sprite login`, or `sprite auth setup --token …` from the [Sprites CLI](https://sprites.dev)). Hermes names each sandbox `hermes-{task_id}` and on every session start either resumes the existing Sprite or creates a fresh one. + +**Restricted tokens (recommended for CI / shared envs):** in the Sprites dashboard you can mint a token scoped to a name prefix and a max-sprites cap. Setting the prefix to `hermes` matches Hermes' deterministic naming exactly — the token can manage every sandbox Hermes spawns and nothing else in the account. + +With `container_persistent: true` (the default), `cleanup()` leaves the Sprite running and the next session resumes against the same filesystem and live VM. With `container_persistent: false`, the Sprite is deleted on cleanup. Sprites' ext4 filesystem is the authoritative store, so this backend deliberately skips Hermes' remote-to-host file sync — agent-modified files stay inside the Sprite and surface again on resume. + +Sprites allocates compute dynamically (up to 8 CPU / 16 GB RAM per Sprite); user-selectable `container_cpu` / `container_memory` / `container_disk` knobs are ignored on this backend until the platform exposes them. + ### Container Resources Configure CPU, memory, disk, and persistence for all container backends: diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index 0af5683342001..d203aa66b5c88 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -144,7 +144,7 @@ The following patterns trigger approval prompts (defined in `tools/approval.py`) | `gateway run` with `&`/`disown`/`nohup`/`setsid` | Prevents starting gateway outside service manager | :::info -**Container bypass**: When running in `docker`, `singularity`, `modal`, `daytona`, or `vercel_sandbox` backends, dangerous command checks are **skipped** because the container itself is the security boundary. Destructive commands inside a container can't harm the host. +**Container bypass**: When running in `docker`, `singularity`, `modal`, `daytona`, `vercel_sandbox`, or `sprites` backends, dangerous command checks are **skipped** because the container itself is the security boundary. Destructive commands inside a container can't harm the host. ::: ### Approval Flow (CLI) @@ -340,7 +340,7 @@ terminal: - **Ephemeral mode** (`container_persistent: false`): Uses tmpfs for workspace — everything is lost on cleanup :::tip -For production gateway deployments, use `docker`, `modal`, `daytona`, or `vercel_sandbox` backend to isolate agent commands from your host system. This eliminates the need for dangerous command approval entirely. +For production gateway deployments, use `docker`, `modal`, `daytona`, `vercel_sandbox`, or `sprites` backend to isolate agent commands from your host system. This eliminates the need for dangerous command approval entirely. ::: :::warning @@ -358,6 +358,7 @@ If you add names to `terminal.docker_forward_env`, those variables are intention | **modal** | Cloud sandbox | ❌ Skipped | Scalable cloud isolation | | **daytona** | Cloud sandbox | ❌ Skipped | Persistent cloud workspaces | | **vercel_sandbox** | Cloud microVM | ❌ Skipped | Cloud execution with snapshot persistence | +| **sprites** | Cloud sandbox (Fly.io) | ❌ Skipped | Persistent sandboxes with native checkpoint & restore | ## Environment Variable Passthrough {#environment-variable-passthrough} From 4c4b387b6adb5dc117956ffeaaace0208461fcdb Mon Sep 17 00:00:00 2001 From: Sprite Date: Mon, 13 Jul 2026 12:01:12 +0000 Subject: [PATCH 17/20] fix(terminal): profile-scope Sprite identity + register sprites in current backend classifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the hermes-sweeper salvage review on #30112. Problem 1 — durable state shared across sessions: the Sprite name was `hermes-{task_id}`, and the task-id resolver collapses ordinary sessions to `default`, so every session shared one live `hermes-default` Sprite (its processes, sockets, and PID space — not just a filesystem snapshot). Scope the name by the active Hermes profile via `_resolve_sprite_name` (`hermes-{profile}-{task_id}`; unchanged `hermes-{task_id}` on the default profile for backward compatibility) so independent profiles never resume into one another's live Sprite, while the same (profile, task_id) still resumes. Names are slugified to a Fly/DNS-safe form. Problem 2 — branch predated current classification paths: register `sprites` in the shared backend classifications main grew after this branch forked — `_REMOTE_TERMINAL_BACKENDS` + `_BACKEND_FALLBACK_DESCRIPTIONS` (host-info suppression / live probe in the system prompt), `_CONTAINER_BACKENDS` (cwd sanitization), and the container_config builder (so `container_persistent` reaches the backend and ephemeral mode works). Tests: add TestSpriteNaming (resume + cross-profile isolation + slugification + resolver-failure fallback); the integration identity test now derives the expected name instead of hard-coding `hermes-default`; extend the container / prompt set-pinning guards to include sprites. Co-Authored-By: Claude Opus 4.8 (1M context) --- agent/prompt_builder.py | 5 +- tests/agent/test_prompt_builder.py | 2 +- tests/integration/test_sprites_terminal.py | 11 ++- tests/tools/test_container_cwd_sanitize.py | 2 +- tests/tools/test_sprites_environment.py | 83 ++++++++++++++++++++++ tools/environments/sprites.py | 44 ++++++++++-- tools/terminal_tool.py | 4 +- 7 files changed, 138 insertions(+), 13 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index b5b2b58c3621e..10a51e97dfb47 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -890,7 +890,7 @@ def format_steer_marker(steer_text: str) -> str: # misleading — the agent should only see the machine it can actually touch. _REMOTE_TERMINAL_BACKENDS = frozenset({ "docker", "singularity", "modal", "daytona", "ssh", - "managed_modal", + "managed_modal", "sprites", }) @@ -904,6 +904,7 @@ def format_steer_marker(steer_text: str) -> str: "modal": "a Modal sandbox (Linux)", "managed_modal": "a managed Modal sandbox (Linux)", "daytona": "a Daytona workspace (Linux)", + "sprites": "a Sprite — a stateful cloud sandbox on Fly.io (Linux)", "ssh": "a remote host reached over SSH (likely Linux)", } @@ -1068,7 +1069,7 @@ def build_environment_hints() -> str: and a Windows-only note that `terminal` shells out to bash, not PowerShell). - For **remote / sandbox** terminal backends (docker, singularity, - modal, daytona, ssh): host info is **suppressed** + modal, daytona, sprites, ssh): host info is **suppressed** because the agent's tools can't touch the host — only the backend matters. A live probe inside the backend reports its OS, user, $HOME, and cwd. Falls back to a static summary if the probe fails. diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 858c880ec8fb6..e3f8d7a1bcf9a 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -1299,7 +1299,7 @@ def _fake_create_environment(*, env_type, **kwargs): def test_remote_backend_list_covers_known_sandboxes(self): """Regression guard: if someone adds a remote backend, they must list it here.""" import agent.prompt_builder as _pb - for backend in ("docker", "singularity", "modal", "daytona", "ssh"): + for backend in ("docker", "singularity", "modal", "daytona", "sprites", "ssh"): assert backend in _pb._REMOTE_TERMINAL_BACKENDS, ( f"{backend!r} must be in _REMOTE_TERMINAL_BACKENDS so its host " f"info is suppressed in the system prompt" diff --git a/tests/integration/test_sprites_terminal.py b/tests/integration/test_sprites_terminal.py index 2d96b3a059818..66b44a9347a01 100644 --- a/tests/integration/test_sprites_terminal.py +++ b/tests/integration/test_sprites_terminal.py @@ -101,9 +101,14 @@ def test_runs_inside_a_sprite(self, task_id): if "MISSING" in r["output"]: pytest.skip("sprite-env CLI not present inside the Sprite") # Terminal-tool's `_resolve_container_task_id` collapses every - # incoming task_id to "default", so the Sprite name is always - # hermes-default regardless of what we passed to _run(). - assert "hermes-default" in r["output"] + # incoming task_id to "default", and the Sprite name is then scoped by + # the active Hermes profile via `_resolve_sprite_name` (see the unit + # tests in tests/tools/test_sprites_environment.py::TestSpriteNaming). + # Assert against the resolved name for whatever profile this run is in, + # rather than hard-coding "hermes-default". + from tools.environments.sprites import _resolve_sprite_name + expected_name = _resolve_sprite_name("default") + assert expected_name in r["output"] # Sanity: the boot_id from inside the Sprite must differ from this # process's view (i.e. command did NOT run on the host). host_boot = open("/proc/sys/kernel/random/boot_id").read().strip() diff --git a/tests/tools/test_container_cwd_sanitize.py b/tests/tools/test_container_cwd_sanitize.py index 00a155e8bb826..c4468741b68f9 100644 --- a/tests/tools/test_container_cwd_sanitize.py +++ b/tests/tools/test_container_cwd_sanitize.py @@ -62,7 +62,7 @@ def test_host_prefixes_include_windows_and_posix(self): def test_container_backends_set(self): assert tt._CONTAINER_BACKENDS == frozenset( - {"docker", "singularity", "modal", "daytona"} + {"docker", "singularity", "modal", "daytona", "sprites"} ) diff --git a/tests/tools/test_sprites_environment.py b/tests/tools/test_sprites_environment.py index c4997bc47cf1e..4b23c87cb2796 100644 --- a/tests/tools/test_sprites_environment.py +++ b/tests/tools/test_sprites_environment.py @@ -110,6 +110,14 @@ def make_env(sprites_sdk, monkeypatch): ) # Keep the base class from blocking forever on interrupt polling monkeypatch.setattr("tools.environments.base.is_interrupted", lambda: False) + # Pin the active profile to "default" so Sprite names are deterministic + # regardless of the test runner's HERMES_HOME. Profile-scoping itself is + # covered explicitly in TestSpriteNaming. + monkeypatch.setattr( + "agent.file_safety._resolve_active_profile_name", + lambda: "default", + raising=False, + ) def _factory(get_side_effect=None, sprite=None, **kwargs): sprite = sprite or _make_sprite() @@ -173,6 +181,81 @@ def test_no_size_kwargs_passed_to_create(self, make_env): assert args == ("hermes-sizing",) assert kwargs == {} + +class TestSpriteNaming: + """`_resolve_sprite_name`: deterministic, profile-scoped Sprite identity. + + A Sprite is resumed *by name*, so the name is the durable identity of a + session's live sandbox. The name must (a) stay stable so resume works and + (b) differ across independent Hermes profiles so they never resume into + one another's live Sprite. + """ + + @staticmethod + def _set_profile(monkeypatch, name): + monkeypatch.setattr( + "agent.file_safety._resolve_active_profile_name", + lambda: name, + raising=False, + ) + + def test_default_profile_keeps_legacy_name(self, monkeypatch): + from tools.environments.sprites import _resolve_sprite_name + self._set_profile(monkeypatch, "default") + # Backward compatible with Sprites created before profile scoping. + assert _resolve_sprite_name("default") == "hermes-default" + assert _resolve_sprite_name("mytask") == "hermes-mytask" + + def test_named_profile_is_scoped(self, monkeypatch): + from tools.environments.sprites import _resolve_sprite_name + self._set_profile(monkeypatch, "work") + assert _resolve_sprite_name("default") == "hermes-work-default" + assert _resolve_sprite_name("mytask") == "hermes-work-mytask" + + def test_independent_profiles_do_not_collide(self, monkeypatch): + """Same task_id under two different profiles → distinct Sprites.""" + from tools.environments.sprites import _resolve_sprite_name + self._set_profile(monkeypatch, "alpha") + a = _resolve_sprite_name("default") + self._set_profile(monkeypatch, "beta") + b = _resolve_sprite_name("default") + assert a == "hermes-alpha-default" + assert b == "hermes-beta-default" + assert a != b + + def test_same_identity_resumes(self, monkeypatch): + """Same (profile, task_id) is stable across calls → resume works.""" + from tools.environments.sprites import _resolve_sprite_name + self._set_profile(monkeypatch, "work") + assert _resolve_sprite_name("t") == _resolve_sprite_name("t") == "hermes-work-t" + + def test_names_are_sanitized(self, monkeypatch): + """Messy profile/task components collapse to a Sprite-safe slug.""" + import re + from tools.environments.sprites import _resolve_sprite_name + self._set_profile(monkeypatch, "Team/Prod.01") + name = _resolve_sprite_name("sub agent_42") + assert name == "hermes-team-prod-01-sub-agent-42" + # Only lowercase alnum + single interior hyphens (Fly/DNS-safe). + assert re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name) + + def test_empty_task_id_falls_back(self, monkeypatch): + from tools.environments.sprites import _resolve_sprite_name + self._set_profile(monkeypatch, "default") + assert _resolve_sprite_name("") == "hermes-default" + + def test_profile_resolution_failure_is_non_fatal(self, monkeypatch): + """A broken profile resolver must not break Sprite naming.""" + from tools.environments import sprites as sprites_mod + + def _boom(): + raise RuntimeError("no home") + + monkeypatch.setattr( + "agent.file_safety._resolve_active_profile_name", _boom, raising=False + ) + assert sprites_mod._resolve_sprite_name("x") == "hermes-x" + def test_no_base_url_kwarg(self, make_env, sprites_sdk): """SpritesClient is constructed without a base_url override (endpoint is fixed).""" env = make_env(task_id="urlcheck") diff --git a/tools/environments/sprites.py b/tools/environments/sprites.py index 5352a1b18cc10..852443fd03a51 100644 --- a/tools/environments/sprites.py +++ b/tools/environments/sprites.py @@ -3,13 +3,14 @@ Uses the sprites-py SDK (https://github.com/superfly/sprites-py) to run commands in Sprites — stateful cloud sandboxes on Fly.io, with checkpoint & restore. Persistent by default: each Sprite outlives the session -and is reused via a deterministic ``hermes-{task_id}`` name. Cleanup leaves -the Sprite running when ``persistent_filesystem`` is True; the Sprite is -deleted otherwise. +and is reused via a deterministic, profile-scoped ``hermes-{profile}-{task_id}`` +name (``hermes-{task_id}`` on the default profile). Cleanup leaves the Sprite +running when ``persistent_filesystem`` is True; the Sprite is deleted otherwise. """ import logging import os +import re import shlex import threading from pathlib import Path @@ -26,6 +27,40 @@ logger = logging.getLogger(__name__) +def _slugify_name_component(value: str) -> str: + """Reduce an arbitrary string to a Sprite/Fly-safe name component. + + Sprite names are DNS-ish: lowercase ``[a-z0-9-]`` with no leading/trailing + or doubled hyphens. Anything else (``/``, ``.``, uppercase, unicode from a + profile directory or subagent id) is collapsed to a single hyphen. + """ + return re.sub(r"[^a-z0-9]+", "-", (value or "").lower()).strip("-") + + +def _resolve_sprite_name(task_id: str) -> str: + """Deterministic, profile-scoped Sprite name. + + A Sprite is persistent and resumed *by name*, so its name is the durable + identity of a session's live sandbox (processes, sockets, PID space — not + just a filesystem snapshot). We scope the name by the active Hermes profile + so two independent profiles never resume into one another's live Sprite, + while the same ``(profile, task_id)`` always resumes the same Sprite. + + The default profile keeps the historical ``hermes-{task_id}`` name so + already-created Sprites keep resolving after this change. + """ + try: + from agent.file_safety import _resolve_active_profile_name + profile = _resolve_active_profile_name() + except Exception: + profile = "default" + task_slug = _slugify_name_component(task_id) or "default" + profile_slug = _slugify_name_component(profile) if profile else "" + if profile_slug and profile_slug != "default": + return f"hermes-{profile_slug}-{task_slug}" + return f"hermes-{task_slug}" + + class SpritesEnvironment(BaseEnvironment): """Sprites backend: stateful cloud sandboxes on Fly.io. @@ -80,7 +115,8 @@ def __init__( # storage / region) — sandboxes get default sizing. We omit SpriteConfig # entirely so the wire format stays minimal until the platform exposes # these knobs. - sprite_name = f"hermes-{task_id}" + sprite_name = _resolve_sprite_name(task_id) + self._sprite_name = sprite_name try: self._sprite = self._client.get_sprite(sprite_name) logger.info( diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index bf2acb2902d9e..2a6122a21a116 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1214,7 +1214,7 @@ def _safe_getcwd() -> str: # cwd looks when it leaks toward a Linux container's ``-w`` flag. _HOST_CWD_PREFIXES = ("/Users/", "/home/", "C:\\", "C:/") -_CONTAINER_BACKENDS = frozenset({"docker", "singularity", "modal", "daytona"}) +_CONTAINER_BACKENDS = frozenset({"docker", "singularity", "modal", "daytona", "sprites"}) def _is_ssh_remote_tilde_cwd(backend: str, cwd: str) -> bool: @@ -2207,7 +2207,7 @@ def terminal_tool( } container_config = None - if env_type in {"docker", "singularity", "modal", "daytona"}: + if env_type in {"docker", "singularity", "modal", "daytona", "sprites"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), From 6882c22b6a60ce2a76c43882dff86682797b14d1 Mon Sep 17 00:00:00 2001 From: Sprite Date: Mon, 13 Jul 2026 12:08:16 +0000 Subject: [PATCH 18/20] test(terminal): pin sprites dispatch wiring; make _sprite_name a tested contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestDispatchWiring drives the real terminal_tool() body and asserts the container_config builder includes sprites (container_config=None would silently discard container_persistent: false, making ephemeral mode unreachable), plus pins the _create_environment → SpritesEnvironment kwarg handoff (persistent_filesystem, task_id, cwd). - _sprite_name is now read at runtime (cleanup-failure log) and asserted in the construction tests instead of being a write-only attribute. Co-Authored-By: Claude Fable 5 --- tests/tools/test_sprites_environment.py | 100 ++++++++++++++++++++++++ tools/environments/sprites.py | 4 +- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/tests/tools/test_sprites_environment.py b/tests/tools/test_sprites_environment.py index 4b23c87cb2796..c276a2a17bf64 100644 --- a/tests/tools/test_sprites_environment.py +++ b/tests/tools/test_sprites_environment.py @@ -168,11 +168,14 @@ def test_persistent_uses_get_first(self, make_env): ) env._mock_client.get_sprite.assert_called_once_with("hermes-mine") env._mock_client.create_sprite.assert_not_called() + # The instance records the resolved name it will resume under. + assert env._sprite_name == "hermes-mine" def test_creates_when_not_found(self, make_env): env = make_env(task_id="fresh", persistent_filesystem=True) env._mock_client.get_sprite.assert_called_once_with("hermes-fresh") env._mock_client.create_sprite.assert_called_once_with("hermes-fresh") + assert env._sprite_name == "hermes-fresh" def test_no_size_kwargs_passed_to_create(self, make_env): """Compute sizing isn't honored yet — make sure we don't sneak it back in.""" @@ -256,6 +259,103 @@ def _boom(): ) assert sprites_mod._resolve_sprite_name("x") == "hermes-x" + +class TestDispatchWiring: + """Wiring pins: config → terminal_tool dispatch → SpritesEnvironment kwargs. + + The class-level tests above prove SpritesEnvironment honors + ``persistent_filesystem``; these prove the dispatch actually delivers it. + A backend missing from terminal_tool's container_config builder gets + ``container_config=None``, silently re-defaulting ``container_persistent: + false`` back to persistent — i.e. ephemeral mode can never engage. + """ + + def test_terminal_tool_builds_container_config_for_sprites(self, monkeypatch): + import tools.terminal_tool as tt + + captured = {} + + config = { + "env_type": "sprites", + "docker_image": "unused", + "singularity_image": "unused", + "modal_image": "unused", + "daytona_image": "unused", + "cwd": "/root", + "host_cwd": None, + "timeout": 180, + "lifetime_seconds": 300, + "container_cpu": 1, + "container_memory": 5120, + "container_disk": 51200, + "container_persistent": False, + "docker_volumes": [], + "docker_env": {}, + "docker_extra_args": [], + "docker_mount_cwd_to_workspace": False, + "docker_run_as_host_user": False, + "docker_forward_env": [], + "modal_mode": "auto", + } + + class _DummyEnv: + cwd = "/root" + + def execute(self, *a, **k): + return {"output": "", "exit_code": 0} + + def fake_create_environment(env_type, image, cwd, timeout, **kwargs): + captured["env_type"] = env_type + captured["container_config"] = kwargs.get("container_config") + return _DummyEnv() + + monkeypatch.setattr(tt, "_get_env_config", lambda: config) + monkeypatch.setattr(tt, "_start_cleanup_thread", lambda: None) + monkeypatch.setattr(tt, "_check_all_guards", lambda *a, **k: {"approved": True}) + monkeypatch.setattr(tt, "_create_environment", fake_create_environment) + monkeypatch.setattr(tt, "_active_environments", {}) + monkeypatch.setattr(tt, "_last_activity", {}) + + tt.terminal_tool(command="pwd") + + assert captured["env_type"] == "sprites" + cc = captured["container_config"] + assert cc is not None, ( + "sprites must be in terminal_tool's container_config builder set; " + "container_config=None silently discards container_persistent" + ) + assert cc["container_persistent"] is False + + def test_create_environment_passes_persistence_and_task_id(self, monkeypatch): + import tools.terminal_tool as tt + import tools.environments.sprites as sprites_mod + + captured = {} + + class _FakeSpritesEnv: + def __init__(self, cwd, timeout, persistent_filesystem, task_id): + captured.update( + cwd=cwd, + timeout=timeout, + persistent_filesystem=persistent_filesystem, + task_id=task_id, + ) + + monkeypatch.setattr(sprites_mod, "SpritesEnvironment", _FakeSpritesEnv) + + tt._create_environment( + env_type="sprites", + image="ignored", + cwd="/root", + timeout=60, + container_config={"container_persistent": False}, + task_id="tid-ephemeral", + ) + + assert captured["persistent_filesystem"] is False + assert captured["task_id"] == "tid-ephemeral" + assert captured["cwd"] == "/root" + def test_no_base_url_kwarg(self, make_env, sprites_sdk): """SpritesClient is constructed without a base_url override (endpoint is fixed).""" env = make_env(task_id="urlcheck") diff --git a/tools/environments/sprites.py b/tools/environments/sprites.py index 852443fd03a51..67eebf9b32807 100644 --- a/tools/environments/sprites.py +++ b/tools/environments/sprites.py @@ -237,7 +237,9 @@ def cleanup(self): self._sprite.delete() logger.info("Sprites: deleted sprite %s", self._sprite.name) except Exception as e: - logger.warning("Sprites: cleanup failed: %s", e) + logger.warning( + "Sprites: cleanup failed for %s: %s", self._sprite_name, e + ) finally: try: self._client.close() From dd39668ca860d63d98d69f3eb806c7251423fa51 Mon Sep 17 00:00:00 2001 From: Sprite Date: Tue, 18 Aug 2026 22:45:55 +0000 Subject: [PATCH 19/20] fix(terminal): register sprites in backend classification surface added since July Upstream grew new shared classification sites while this branch aged; sweep them so sprites keeps remote/container semantics everywhere: - tools/env_probe.py _REMOTE_BACKENDS (host Python-state probe line must not leak into a sprites session's prompt; explicitly kept in sync with prompt_builder._REMOTE_TERMINAL_BACKENDS) - tools/file_tools.py _CONTAINER_PATH_BACKENDS_FALLBACK + class-name sniff in _terminal_env_type_for_task - tools/terminal_tool.py container_backend env-var parse gate - agent/prompt_builder.py _probe_remote_backend container_config set - tools/credential_files.py cache-path translation (sprites homes are ~/.hermes like ssh/daytona/vercel, not host paths) Co-Authored-By: Claude Fable 5 --- agent/prompt_builder.py | 2 +- tools/credential_files.py | 2 +- tools/env_probe.py | 2 +- tools/file_tools.py | 4 +++- tools/terminal_tool.py | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 271b0d98e35bc..b3154a878d6ba 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1245,7 +1245,7 @@ def _probe_remote_backend(env_type: str) -> str | None: } container_config = None - if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}: + if env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox", "sprites"}: container_config = { "container_cpu": config.get("container_cpu", 1), "container_memory": config.get("container_memory", 5120), diff --git a/tools/credential_files.py b/tools/credential_files.py index b429d4efa7fea..871d0a6b2ef2b 100644 --- a/tools/credential_files.py +++ b/tools/credential_files.py @@ -549,7 +549,7 @@ def to_agent_visible_cache_path( backend = (os.environ.get("TERMINAL_ENV") or "local").strip().lower() if backend in ("docker", "modal"): pass # /root/.hermes default - elif backend in ("ssh", "daytona", "vercel_sandbox"): + elif backend in ("ssh", "daytona", "vercel_sandbox", "sprites"): container_base = "~/.hermes" else: return host_path # local, singularity, unknown: host path is correct diff --git a/tools/env_probe.py b/tools/env_probe.py index f656af3edcfd5..b82acf9d5932c 100644 --- a/tools/env_probe.py +++ b/tools/env_probe.py @@ -74,7 +74,7 @@ # imports nothing from tools). _REMOTE_BACKENDS = frozenset({ "docker", "singularity", "modal", "daytona", "ssh", "managed_modal", - "vercel_sandbox", + "vercel_sandbox", "sprites", }) diff --git a/tools/file_tools.py b/tools/file_tools.py index 2c1e5de196c88..c7933b54e10bb 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -169,7 +169,7 @@ def _resolve_path(filepath: str, task_id: str = "default") -> Path | PurePosixPa # (gateway/run.py); the file/terminal-tool layer must do likewise so CLI # sessions get the same protection. See references/worktree-cwd-discipline.md. _TERMINAL_CWD_SENTINELS = frozenset({"", ".", "./", "auto", "cwd"}) -_CONTAINER_PATH_BACKENDS_FALLBACK = frozenset({"docker", "singularity", "modal", "daytona", "vercel_sandbox"}) +_CONTAINER_PATH_BACKENDS_FALLBACK = frozenset({"docker", "singularity", "modal", "daytona", "vercel_sandbox", "sprites"}) def _terminal_env_type_for_task(task_id: str = "default") -> str: @@ -202,6 +202,8 @@ def _terminal_env_type_for_task(task_id: str = "default") -> str: return "modal" if "daytona" in name: return "daytona" + if "sprites" in name: + return "sprites" cfg = _get_env_config() return str(cfg.get("env_type") or os.getenv("TERMINAL_ENV") or "local").lower() except Exception: diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 7d450b1a71255..fb689de347fa7 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -1578,7 +1578,7 @@ def _get_env_config() -> Dict[str, Any]: env_type = os.getenv("TERMINAL_ENV", "local") mount_docker_cwd = os.getenv("TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE", "false").lower() in {"true", "1", "yes"} - container_backend = env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox"} + container_backend = env_type in {"docker", "singularity", "modal", "daytona", "vercel_sandbox", "sprites"} docker_backend = env_type == "docker" # Docker/container-only env vars may be bridged from config.yaml even when From a47d3e3989fd66eb22484ca6e26f5d10b8f2e37f Mon Sep 17 00:00:00 2001 From: Sprite Date: Tue, 18 Aug 2026 22:52:59 +0000 Subject: [PATCH 20/20] chore(deps): modernize sprites-py pin to >=0.5.0,<0.6 The rc37-era pin predates the SDK's stable series. Full live integration suite (8 e2e tests vs api.sprites.dev) verified against 0.5.0; client, sprite, filesystem, and exception surfaces are all compatible. Co-Authored-By: Claude Fable 5 --- pyproject.toml | 2 +- tools/lazy_deps.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index de955e9e09064..56a0f07df5de3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -180,7 +180,7 @@ edge-tts = ["edge-tts==7.2.7"] modal = ["modal==1.3.4"] daytona = ["daytona==0.155.0"] vercel = ["vercel==0.7.2"] -sprites = ["sprites-py>=0.0.1rc37,<0.2"] +sprites = ["sprites-py>=0.5.0,<0.6"] hindsight = ["hindsight-client==0.6.1"] dev = ["debugpy==1.8.20", "pytest==9.1.1", "pytest-asyncio==1.3.0", "mcp==2.0.0", "httpx2==2.7.0", "starlette==1.3.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==83.0.0"] # starlette: CVE-2026-48710; setuptools: 83 (torch >=2.13 requires setuptools 83) messaging = ["python-telegram-bot[webhooks]==22.8", "discord.py[voice]==2.7.1", "aiohttp==3.14.3", "brotlicffi==1.2.0.1", "slack-bolt==1.30.0", "slack-sdk==3.43.0", "qrcode==7.4.2"] # aiohttp 3.14.3: prior CVEs + GHSA-cq5v-8q36-5273/GHSA-mfx4-hv73-q22v/GHSA-mq44-7p77-q5h7 diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index cd2c3d27b936f..c6e13c459c655 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -256,7 +256,7 @@ "terminal.modal": ("modal==1.3.4",), "terminal.daytona": ("daytona==0.155.0",), "terminal.vercel": ("vercel==0.7.2",), - "terminal.sprites": ("sprites-py>=0.0.1rc37,<0.2",), + "terminal.sprites": ("sprites-py>=0.5.0,<0.6",), # ─── Skills ──────────────────────────────────────────────────────────── "skill.google_workspace": (