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 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, 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. 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}