diff --git a/AGENTS.md b/AGENTS.md
index b8a22cf7eb94f..e14d12b577df2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -279,7 +279,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/README.md b/README.md
index c05112266746f..35e1e4ec5c897 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), OpenR
| A closed learning loop | Agent-curated memory with periodic nudges. Autonomous skill creation after complex tasks. Skills self-improve during use. FTS5 session search with LLM summarization for cross-session recall. Honcho dialectic user modeling. Compatible with the agentskills.io open standard. |
| Scheduled automations | Built-in cron scheduler with delivery to any platform. Daily reports, nightly backups, weekly audits — all in natural language, running unattended. |
| Delegates and parallelizes | Spawn 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 laptop | Seven 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 laptop | Eight 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-ready | Batch trajectory generation, trajectory compression for training the next generation of tool-calling models. |
diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py
index 998e5606a093a..b3154a878d6ba 100644
--- a/agent/prompt_builder.py
+++ b/agent/prompt_builder.py
@@ -1122,7 +1122,7 @@ def hud_surface_note(valid_tool_names: "set[str] | None" = None) -> str:
# misleading — the agent should only see the machine it can actually touch.
_REMOTE_TERMINAL_BACKENDS = frozenset({
"docker", "singularity", "modal", "daytona", "ssh",
- "vercel_sandbox", "managed_modal",
+ "vercel_sandbox", "managed_modal", "sprites",
})
@@ -1137,6 +1137,7 @@ def hud_surface_note(valid_tool_names: "set[str] | None" = None) -> str:
"managed_modal": "a managed Modal sandbox (Linux)",
"daytona": "a Daytona workspace (Linux)",
"vercel_sandbox": "a Vercel sandbox (Linux)",
+ "sprites": "a Sprite — a stateful cloud sandbox on Fly.io (Linux)",
"ssh": "a remote host reached over SSH (likely Linux)",
}
@@ -1244,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),
@@ -1335,7 +1336,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, vercel_sandbox): host info is **suppressed**
+ modal, daytona, ssh, vercel_sandbox, sprites): 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/cli-config.yaml.example b/cli-config.yaml.example
index d2228c76a74ba..c881f059ec0a4 100644
--- a/cli-config.yaml.example
+++ b/cli-config.yaml.example
@@ -427,8 +427,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
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 5e6fd1942ed5f..4cd1df915bd19 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -4623,6 +4623,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 a75c9c6c00f3c..94f4fd1c5a833 100644
--- a/hermes_cli/doctor.py
+++ b/hermes_cli/doctor.py
@@ -2076,6 +2076,35 @@ def run_doctor(args):
issues,
)
+ # 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)")
+
# Vercel Sandbox (if using vercel_sandbox backend)
if terminal_env == "vercel_sandbox":
runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24"
diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py
index 3c65981cbb06f..b30cb8d9521be 100644
--- a/hermes_cli/setup.py
+++ b/hermes_cli/setup.py
@@ -1340,11 +1340,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 - 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"}
- 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"
@@ -1592,6 +1593,68 @@ def setup_terminal_backend(config: dict):
_prompt_vercel_sandbox_settings(config)
+ elif selected_backend == "sprites":
+ print_success("Terminal backend: Sprites")
+ 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")
+
+ try:
+ __import__("sprites")
+ except ImportError:
+ print_info("Installing sprites-py SDK...")
+ import subprocess
+
+ # Managed uv first — same rationale as the Vercel branch above.
+ from hermes_cli.managed_uv import ensure_uv
+
+ uv_bin = ensure_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 ...`)")
+ 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)
+ print_success(" Configured")
+
+ # 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()
+ 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")
print_info("Run commands on a remote machine via SSH.")
diff --git a/hermes_cli/status.py b/hermes_cli/status.py
index 47ac61381d634..32af7bb497586 100644
--- a/hermes_cli/status.py
+++ b/hermes_cli/status.py
@@ -478,6 +478,13 @@ def _resolve_env(env_ref) -> str:
print(f" Auth detail: {line}")
print(f" Persistence: {'snapshot filesystem' if persist_enabled else 'ephemeral filesystem'}")
print(" Processes: live processes do not survive cleanup, snapshots, or sandbox recreation")
+ 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}")
sudo_password = os.getenv("SUDO_PASSWORD", "")
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index 1b72435eda3b7..bfa7a60144725 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -1045,7 +1045,7 @@ def _timezone_options() -> List[str]:
"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/pyproject.toml b/pyproject.toml
index e897064d278dc..56a0f07df5de3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -180,6 +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.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
@@ -344,7 +345,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/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py
index 38bc36efbaf2e..25251bddcfd6a 100644
--- a/tests/agent/test_prompt_builder.py
+++ b/tests/agent/test_prompt_builder.py
@@ -876,7 +876,7 @@ def test_environment_hint_from_env_var_is_appended(self, monkeypatch):
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", "vercel_sandbox"):
+ for backend in ("docker", "singularity", "modal", "daytona", "ssh", "vercel_sandbox", "sprites"):
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
new file mode 100644
index 0000000000000..66b44a9347a01
--- /dev/null
+++ b/tests/integration/test_sprites_terminal.py
@@ -0,0 +1,133 @@
+"""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", 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()
+ 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_container_cwd_sanitize.py b/tests/tools/test_container_cwd_sanitize.py
index 481b1d2f97044..60c95d085adda 100644
--- a/tests/tools/test_container_cwd_sanitize.py
+++ b/tests/tools/test_container_cwd_sanitize.py
@@ -33,7 +33,7 @@ def test_posix_home_host_path_rejected(self):
def test_container_backends_set(self):
assert tt._CONTAINER_BACKENDS == frozenset(
- {"docker", "singularity", "modal", "daytona", "vercel_sandbox"}
+ {"docker", "singularity", "modal", "daytona", "vercel_sandbox", "sprites"}
)
diff --git a/tests/tools/test_sprites_environment.py b/tests/tools/test_sprites_environment.py
new file mode 100644
index 0000000000000..c276a2a17bf64
--- /dev/null
+++ b/tests/tools/test_sprites_environment.py
@@ -0,0 +1,515 @@
+"""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)
+ # 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()
+
+ 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()
+ # 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."""
+ env = make_env(task_id="sizing")
+ args, kwargs = env._mock_client.create_sprite.call_args
+ 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"
+
+
+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")
+ 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"
diff --git a/tools/approval.py b/tools/approval.py
index e55e96ea35040..7e5facb36d697 100644
--- a/tools/approval.py
+++ b/tools/approval.py
@@ -3711,7 +3711,7 @@ def _should_skip_container_guards(env_type: str, has_host_access: bool = False)
"""
if env_type == "docker":
return not has_host_access
- return env_type in ("singularity", "modal", "daytona", "vercel_sandbox")
+ return env_type in ("singularity", "modal", "daytona", "vercel_sandbox", "sprites")
def check_dangerous_command(command: str, env_type: str,
diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py
index 4c6ec73b361a7..b3299d61c6fe8 100644
--- a/tools/code_execution_tool.py
+++ b/tools/code_execution_tool.py
@@ -833,7 +833,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/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/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/local.py b/tools/environments/local.py
index 8b7a479c59acc..eeafdf90f9471 100644
--- a/tools/environments/local.py
+++ b/tools/environments/local.py
@@ -321,6 +321,8 @@ def _build_provider_env_blocklist() -> frozenset:
"VERCEL_TOKEN",
"VERCEL_PROJECT_ID",
"VERCEL_TEAM_ID",
+ "SPRITES_TOKEN",
+ "SPRITE_TOKEN",
})
# CLAUDE_CODE_OAUTH_TOKEN is deliberately NOT stripped. It is set and
# owned by the user's Claude Code install (subscription OAuth), not a
diff --git a/tools/environments/sprites.py b/tools/environments/sprites.py
new file mode 100644
index 0000000000000..67eebf9b32807
--- /dev/null
+++ b/tools/environments/sprites.py
@@ -0,0 +1,248 @@
+"""Sprites execution environment.
+
+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, 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
+
+from tools.environments.base import (
+ BaseEnvironment,
+ _ThreadedProcessHandle,
+)
+from tools.environments.file_sync import (
+ FileSyncManager,
+ iter_sync_files,
+)
+
+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.
+
+ 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,
+ persistent_filesystem: bool = True,
+ task_id: str = "default",
+ ):
+ 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
+
+ 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."
+ )
+ self._client = SpritesClient(
+ token=token,
+ timeout=max(30.0, float(timeout)),
+ )
+ self._persistent = persistent_filesystem
+ self._task_id = task_id
+ self._lock = threading.Lock()
+ self._sprite = 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 = _resolve_sprite_name(task_id)
+ self._sprite_name = sprite_name
+ 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)
+ 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
+
+ # 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:
+ 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 for %s: %s", self._sprite_name, 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 aab5c4ebfaf8d..f82ce2ca4cae3 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/file_tools.py b/tools/file_tools.py
index 2270fb57f6bd6..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:
@@ -1524,7 +1526,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/lazy_deps.py b/tools/lazy_deps.py
index 3887d3a2575c0..c6e13c459c655 100644
--- a/tools/lazy_deps.py
+++ b/tools/lazy_deps.py
@@ -256,6 +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.5.0,<0.6",),
# ─── Skills ────────────────────────────────────────────────────────────
"skill.google_workspace": (
diff --git a/tools/skills_tool.py b/tools/skills_tool.py
index 5bfa6edba3de3..af638df050b4e 100644
--- a/tools/skills_tool.py
+++ b/tools/skills_tool.py
@@ -172,7 +172,7 @@ def _skills_dir() -> Path:
}
_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
diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py
index 0d90fe559226a..fb689de347fa7 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 — stateful cloud sandboxes on Fly.io, with checkpoint & restore
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
@@ -1491,7 +1492,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", "vercel_sandbox"})
+_CONTAINER_BACKENDS = frozenset({"docker", "singularity", "modal", "daytona", "vercel_sandbox", "sprites"})
def _is_unusable_container_cwd(cwd: str) -> bool:
@@ -1577,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
@@ -1763,7 +1764,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
@@ -1926,6 +1927,14 @@ 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,
+ persistent_filesystem=persistent, task_id=task_id,
+ )
+
+
elif env_type == "ssh":
if not ssh_config or not ssh_config.get("host") or not ssh_config.get("user"):
raise ValueError("SSH environment requires ssh_host and ssh_user to be configured")
@@ -1941,7 +1950,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'"
)
@@ -3797,10 +3806,23 @@ def check_terminal_requirements() -> bool:
from agent.secret_scope import get_secret
return get_secret("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
diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md
index 9e36f98012477..6281a1ec25547 100644
--- a/website/docs/reference/environment-variables.md
+++ b/website/docs/reference/environment-variables.md
@@ -189,6 +189,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 cloud sandboxes on Fly.io ([sprites.dev](https://sprites.dev/)) |
### Skill API Keys
diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md
index 0eccf2d5aa519..5f4c266e0b24d 100644
--- a/website/docs/user-guide/configuration.md
+++ b/website/docs/user-guide/configuration.md
@@ -135,11 +135,11 @@ Before that stash step, Hermes also restores tracked `package-lock.json` diffs l
## 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 Sprite (Fly.io cloud 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)
font_family: "" # Desktop terminal font; e.g. "MesloLGS NF"
timeout: 180 # Per-command timeout in seconds
@@ -152,7 +152,7 @@ terminal:
`terminal.font_family` controls the embedded terminal in Hermes Desktop. It accepts either one locally installed family name (for example, `MesloLGS NF`) or a CSS font stack. Hermes appends its bundled JetBrains Mono stack as a fallback, and an empty value keeps the default. You can edit the same profile-scoped setting in **Settings → Appearance → Terminal Font**; no Google Fonts download or system-font permission is required.
-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
@@ -164,6 +164,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** | 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
@@ -446,6 +447,42 @@ 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 [Sprite](https://sprites.dev) — a stateful cloud sandbox on Fly.io, with checkpoint & restore. Sprites persist between sessions by default and are reused by identity: Hermes names them `hermes-{profile}-{task_id}` (just `hermes-{task_id}` on the default profile) and on each session start either resumes the existing Sprite or creates a fresh one. Scoping the name by Hermes profile keeps independent profiles from sharing one live Sprite.
+
+```yaml
+terminal:
+ backend: sprites
+ cwd: /home/sprite # Sprite default home; "/root" / "~" auto-rewrite
+ container_persistent: true # Leave the Sprite alive on cleanup (delete if false)
+```
+
+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:
+
+```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).
+
+#### 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-work-mytask`, and every other `hermes-…` 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-…` 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.
+
+**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 identity. 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
Runs commands in a [Singularity/Apptainer](https://apptainer.org) container. Designed for HPC clusters and shared machines where Docker isn't available.
@@ -476,6 +513,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.
@@ -484,6 +522,8 @@ When in doubt, set `terminal.backend` back to `local` and verify that commands r
For the **SSH**, **Modal**, and **Daytona** backends, Hermes pushes your `~/.hermes/` state (credential files, skills, cache) into the remote sandbox during the session, and on teardown **syncs changed state files back** to their original host locations. Files that differ from what was originally pushed (compared by content hash) are applied back in place; new remote files under a synced directory (e.g. a skill the agent created remotely) are mapped back to the corresponding host path. Upload-only credential files are never overwritten on the host.
+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.
+
- The sync-back retries up to 3 times with backoff and refuses to extract remote archives larger than 2 GiB.
- Docker and Singularity use bind mounts (live host filesystem view) and don't need this.
- This covers Hermes state (`~/.hermes/`), **not** arbitrary working-tree files inside the sandbox — have the agent copy important artifacts out explicitly (e.g. `scp`, `modal volume put`) before the sandbox is destroyed.
diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md
index 849e13783482c..769dc7df41f06 100644
--- a/website/docs/user-guide/features/tools.md
+++ b/website/docs/user-guide/features/tools.md
@@ -73,13 +73,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
```
@@ -184,6 +185,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-{profile}-{task_id}` (just `hermes-{task_id}` on the default profile) and on every session start either resumes the existing Sprite or creates a fresh one. Scoping the name by profile keeps independent Hermes profiles from sharing one live Sprite.
+
+**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 e63199af0b5fc..825775306451b 100644
--- a/website/docs/user-guide/security.md
+++ b/website/docs/user-guide/security.md
@@ -190,7 +190,7 @@ The following patterns trigger approval prompts (defined in `tools/approval.py`)
| `podman --remote`/`-r`/`--url`/`--connection`/`--identity`, `CONTAINER_HOST=` | Podman remote daemon redirect |
:::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)
@@ -495,7 +495,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
@@ -513,6 +513,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}