Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


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

Expand Down
5 changes: 3 additions & 2 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ def _strip_yaml_frontmatter(content: str) -> str:
# runs. For these backends, host info (Windows/Linux/macOS, $HOME, cwd) is
# misleading — the agent should only see the machine it can actually touch.
_REMOTE_TERMINAL_BACKENDS = frozenset({
"docker", "singularity", "modal", "daytona", "ssh",
"docker", "singularity", "modal", "daytona", "e2b", "ssh",
"managed_modal",
})

Expand All @@ -659,6 +659,7 @@ def _strip_yaml_frontmatter(content: str) -> str:
"modal": "a Modal sandbox (Linux)",
"managed_modal": "a managed Modal sandbox (Linux)",
"daytona": "a Daytona workspace (Linux)",
"e2b": "an E2B sandbox (Linux)",
"ssh": "a remote host reached over SSH (likely Linux)",
}

Expand Down Expand Up @@ -772,7 +773,7 @@ def build_environment_hints() -> str:
and a Windows-only note that `terminal` shells out to bash, not
PowerShell).
- For **remote / sandbox** terminal backends (docker, singularity,
modal, daytona, ssh): host info is **suppressed**
modal, daytona, e2b, ssh): host info is **suppressed**
because the agent's tools can't touch the host — only the backend
matters. A live probe inside the backend reports its OS, user, $HOME,
and cwd. Falls back to a static summary if the probe fails.
Expand Down
15 changes: 14 additions & 1 deletion cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,21 @@ terminal:
# daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20"
# container_disk: 10240 # Daytona max is 10GB per sandbox

# -----------------------------------------------------------------------------
# OPTION 7: E2B cloud execution
# Commands run in secure E2B cloud sandboxes
# Great for: Cloud code execution, isolated internet-enabled sandboxes
# Requires: pip install e2b, E2B_API_KEY env var
# -----------------------------------------------------------------------------
# terminal:
# backend: "e2b"
# cwd: "~"
# timeout: 180
# lifetime_seconds: 300
# e2b_template: "base"

#
# --- Container resource limits (docker, singularity, modal, daytona -- ignored for local/ssh) ---
# --- Container resource limits (docker, singularity, modal, daytona, e2b -- ignored for local/ssh) ---
# 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
Expand Down
8 changes: 7 additions & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -778,7 +778,8 @@ def _ensure_hermes_home_managed(home: Path):
"singularity_image": "docker://nikolaik/python-nodejs:python3.11-nodejs20",
"modal_image": "nikolaik/python-nodejs:python3.11-nodejs20",
"daytona_image": "nikolaik/python-nodejs:python3.11-nodejs20",
# Container resource limits (docker, singularity, modal, daytona — ignored for local/ssh)
"e2b_template": "base",
# Container resource limits (docker, singularity, modal, daytona, e2b — ignored for local/ssh)
"container_cpu": 1,
"container_memory": 5120, # MB (default 5GB)
"container_disk": 51200, # MB (default 50GB)
Expand Down Expand Up @@ -5381,6 +5382,10 @@ def show_config():
print(f" Daytona image: {terminal.get('daytona_image', 'nikolaik/python-nodejs:python3.11-nodejs20')}")
daytona_key = get_env_value('DAYTONA_API_KEY')
print(f" API key: {'configured' if daytona_key else '(not set)'}")
elif terminal.get('backend') == 'e2b':
print(f" E2B template: {terminal.get('e2b_template', 'base')}")
e2b_key = get_env_value('E2B_API_KEY')
print(f" API key: {'configured' if e2b_key else '(not set)'}")
elif terminal.get('backend') == 'ssh':
ssh_host = get_env_value('TERMINAL_SSH_HOST')
ssh_user = get_env_value('TERMINAL_SSH_USER')
Expand Down Expand Up @@ -5577,6 +5582,7 @@ def set_config_value(key: str, value: str):
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
"terminal.e2b_template": "TERMINAL_E2B_TEMPLATE",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
Expand Down
23 changes: 23 additions & 0 deletions hermes_cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1257,6 +1257,29 @@ def run_doctor(args):
issues,
)

# E2B (if using e2b backend)
if terminal_env == "e2b":
e2b_key = os.getenv("E2B_API_KEY")
if e2b_key:
check_ok("E2B API key", "(configured)")
else:
_fail_and_issue(
"E2B_API_KEY not set",
"(required for TERMINAL_ENV=e2b)",
"Set E2B_API_KEY environment variable",
issues,
)
try:
from e2b import Sandbox # noqa: F401 — SDK presence check
check_ok("e2b SDK", "(installed)")
except ImportError:
_fail_and_issue(
"e2b SDK not installed",
"(pip install e2b)",
"Install e2b SDK: pip install e2b",
issues,
)

# Node.js + agent-browser (for browser automation tools)
if _safe_which("node"):
check_ok("Node.js")
Expand Down
62 changes: 58 additions & 4 deletions hermes_cli/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,7 +636,7 @@ def _print_setup_summary(config: dict, hermes_home):


def _prompt_container_resources(config: dict):
"""Prompt for container resource settings (Docker, Singularity, Modal, Daytona)."""
"""Prompt for container resource settings (Docker, Singularity, Modal, Daytona, E2B)."""
terminal = config.setdefault("terminal", {})

print()
Expand Down Expand Up @@ -1306,13 +1306,14 @@ def setup_terminal_backend(config: dict):
"Local - run directly on this machine (default)",
"Docker - isolated container with configurable resources",
"Modal - serverless cloud sandbox",
"E2B - secure cloud sandbox",
"SSH - run on a remote machine",
"Daytona - persistent cloud development environment",
]
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "ssh", 4: "daytona"}
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4}
idx_to_backend = {0: "local", 1: "docker", 2: "modal", 3: "e2b", 4: "ssh", 5: "daytona"}
backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "e2b": 3, "ssh": 4, "daytona": 5}

next_idx = 5
next_idx = 6
if is_linux:
terminal_choices.append("Singularity/Apptainer - HPC-friendly container")
idx_to_backend[next_idx] = "singularity"
Expand Down Expand Up @@ -1558,6 +1559,59 @@ def setup_terminal_backend(config: dict):

_prompt_container_resources(config)

elif selected_backend == "e2b":
print_success("Terminal backend: E2B")
print_info("Secure cloud sandboxes with pause/resume persistence.")
print_info("Sign up at: https://e2b.dev")

try:
__import__("e2b")
except ImportError:
print_info("Installing E2B SDK...")
import subprocess

uv_bin = shutil.which("uv")
if uv_bin:
result = subprocess.run(
[uv_bin, "pip", "install", "--python", sys.executable, "e2b"],
capture_output=True,
text=True,
)
else:
result = subprocess.run(
[sys.executable, "-m", "pip", "install", "e2b"],
capture_output=True,
text=True,
)
if result.returncode == 0:
print_success("E2B SDK installed")
else:
print_warning("Install failed — run manually: pip install e2b")
if result.stderr:
print_info(f" Error: {result.stderr.strip().splitlines()[-1]}")

print()
existing_key = get_env_value("E2B_API_KEY")
if existing_key:
print_info(" E2B API key: already configured")
if prompt_yes_no(" Update API key?", False):
api_key = prompt(" E2B API key", password=True)
if api_key:
save_env_value("E2B_API_KEY", api_key)
print_success(" Updated")
else:
api_key = prompt(" E2B API key", password=True)
if api_key:
save_env_value("E2B_API_KEY", api_key)
print_success(" Configured")

current_template = cfg_get(config, "terminal", "e2b_template", default="base")
template = prompt(" Sandbox template", current_template)
config["terminal"]["e2b_template"] = template or "base"
save_env_value("TERMINAL_E2B_TEMPLATE", config["terminal"]["e2b_template"])

_prompt_container_resources(config)

elif selected_backend == "ssh":
print_success("Terminal backend: SSH")
print_info("Run commands on a remote machine via SSH.")
Expand Down
3 changes: 3 additions & 0 deletions hermes_cli/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,9 @@ def _resolve_env(env_ref) -> str:
elif terminal_env == "daytona":
daytona_image = os.getenv("TERMINAL_DAYTONA_IMAGE", "nikolaik/python-nodejs:python3.11-nodejs20")
print(f" Daytona Image: {daytona_image}")
elif terminal_env == "e2b":
e2b_template = os.getenv("TERMINAL_E2B_TEMPLATE", "base")
print(f" E2B Template: {e2b_template}")

sudo_password = os.getenv("SUDO_PASSWORD", "")
print(f" Sudo: {check_mark(bool(sudo_password))} {'enabled' if sudo_password else 'disabled'}")
Expand Down
3 changes: 1 addition & 2 deletions hermes_cli/tips.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@
"mixture_of_agents routes hard problems through 4 frontier LLMs collaboratively.",
"Terminal commands support background mode with notify_on_complete for long-running tasks.",
"Terminal background processes support watch_patterns to alert on specific output lines.",
"The terminal tool supports 6 backends: local, Docker, SSH, Modal, Daytona, and Singularity.",
"The terminal tool supports 7 backends: local, Docker, SSH, Modal, E2B, Daytona, and Singularity.",

# --- Profiles ---
"Each profile gets its own config, API keys, memory, sessions, skills, and cron jobs.",
Expand Down Expand Up @@ -485,4 +485,3 @@ def get_random_tip(exclude_recent: int = 0) -> str:
"""
return random.choice(TIPS)


2 changes: 1 addition & 1 deletion hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ async def auth_middleware(request: Request, call_next):
"terminal.backend": {
"type": "select",
"description": "Terminal execution backend",
"options": ["local", "docker", "ssh", "modal", "daytona", "singularity"],
"options": ["local", "docker", "ssh", "modal", "e2b", "daytona", "singularity"],
},
"terminal.modal_mode": {
"type": "select",
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ fal = ["fal-client==0.13.1"]
edge-tts = ["edge-tts==7.2.7"]
modal = ["modal==1.3.4"]
daytona = ["daytona==0.155.0"]
e2b = ["e2b==2.25.0"]
hindsight = ["hindsight-client==0.6.1"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "ty==0.0.21", "ruff==0.15.10"]
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.3", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"]
Expand Down
3 changes: 1 addition & 2 deletions tests/agent/test_prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,7 @@ def test_build_environment_hints_uses_live_probe_when_available(self, monkeypatc
def test_remote_backend_list_covers_known_sandboxes(self):
"""Regression guard: if someone adds a remote backend, they must list it here."""
import agent.prompt_builder as _pb
for backend in ("docker", "singularity", "modal", "daytona", "ssh"):
for backend in ("docker", "singularity", "modal", "daytona", "e2b", "ssh"):
assert backend in _pb._REMOTE_TERMINAL_BACKENDS, (
f"{backend!r} must be in _REMOTE_TERMINAL_BACKENDS so its host "
f"info is suppressed in the system prompt"
Expand Down Expand Up @@ -1193,4 +1193,3 @@ def test_guidance_is_string(self):
# =========================================================================



33 changes: 33 additions & 0 deletions tests/hermes_cli/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,39 @@ def fake_prompt_choice(question, choices, default=0):
assert config["terminal"]["modal_mode"] == "direct"


def test_e2b_setup_saves_api_key_and_template(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("E2B_API_KEY", raising=False)
config = load_config()

def fake_prompt_choice(question, choices, default=0):
if question == "Select terminal backend:":
assert any(choice.startswith("E2B -") for choice in choices)
return 3
raise AssertionError(f"Unexpected prompt_choice call: {question}")

prompt_values = iter(["e2b_test_key", "base"])
saved = {}

monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice)
monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: next(prompt_values))
monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False)
monkeypatch.setattr("hermes_cli.setup._prompt_container_resources", lambda config: None)
monkeypatch.setattr("hermes_cli.setup.get_env_value", lambda key: "")
monkeypatch.setattr("hermes_cli.setup.save_env_value", lambda key, value: saved.update({key: value}))
monkeypatch.setitem(sys.modules, "e2b", types.SimpleNamespace(Sandbox=object()))

from hermes_cli.setup import setup_terminal_backend

setup_terminal_backend(config)

assert config["terminal"]["backend"] == "e2b"
assert config["terminal"]["e2b_template"] == "base"
assert saved["E2B_API_KEY"] == "e2b_test_key"
assert saved["TERMINAL_E2B_TEMPLATE"] == "base"
assert saved["TERMINAL_ENV"] == "e2b"


def test_setup_slack_saves_home_channel(monkeypatch):
"""_setup_slack() saves SLACK_HOME_CHANNEL when the user provides one."""
saved = {}
Expand Down
2 changes: 1 addition & 1 deletion tests/test_project_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ def test_lazy_installable_extras_excluded_from_all():
"fal",
"edge-tts", "tts-premium",
"voice", # faster-whisper / sounddevice / numpy
"modal", "daytona",
"modal", "daytona", "e2b",
"messaging", "slack", "matrix", "dingtalk", "feishu",
"honcho", "hindsight",
}
Expand Down
4 changes: 4 additions & 0 deletions tests/tools/test_command_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ def test_daytona_skips_both(self):
result = check_all_command_guards("rm -rf /", "daytona")
assert result["approved"] is True

def test_e2b_skips_both(self):
result = check_all_command_guards("rm -rf /", "e2b")
assert result["approved"] is True


# ---------------------------------------------------------------------------
# tirith allow + safe command
Expand Down
4 changes: 2 additions & 2 deletions tests/tools/test_hardline_blocklist.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ def test_container_backends_still_bypass(clean_session):

Hardline only protects environments with real host impact (local, ssh).
"""
for env in ("docker", "singularity", "modal", "daytona"):
for env in ("docker", "singularity", "modal", "daytona", "e2b"):
r1 = check_dangerous_command("rm -rf /", env)
assert r1["approved"] is True, f"container {env} should still bypass"
r2 = check_all_command_guards("rm -rf /", env)
Expand Down Expand Up @@ -370,7 +370,7 @@ def test_sudo_stdin_guard_not_blocked_by_yolo(clean_session, monkeypatch):

def test_sudo_stdin_guard_container_bypass(clean_session):
"""Containerized backends still bypass — they can't touch the host."""
for env in ("docker", "singularity", "modal", "daytona"):
for env in ("docker", "singularity", "modal", "daytona", "e2b"):
for cmd in _SUDO_STDIN_BLOCK:
result = check_all_command_guards(cmd, env)
assert result["approved"] is True, f"container {env} should bypass sudo guard on {cmd!r}"
26 changes: 26 additions & 0 deletions tests/tools/test_terminal_requirements.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import importlib
import logging
import sys
import types


terminal_tool_module = importlib.import_module("tools.terminal_tool")
Expand All @@ -22,6 +24,7 @@ def _clear_terminal_env(monkeypatch):
"TERMINAL_TIMEOUT",
"MODAL_TOKEN_ID",
"MODAL_TOKEN_SECRET",
"E2B_API_KEY",
"HOME",
"USERPROFILE",
]
Expand Down Expand Up @@ -74,6 +77,29 @@ def test_ssh_backend_without_host_or_user_logs_and_returns_false(monkeypatch, ca
)


def test_e2b_backend_without_api_key_logs_and_returns_false(monkeypatch, caplog):
_clear_terminal_env(monkeypatch)
monkeypatch.setenv("TERMINAL_ENV", "e2b")

with caplog.at_level(logging.ERROR):
ok = terminal_tool_module.check_terminal_requirements()

assert ok is False
assert any(
"E2B backend selected but E2B_API_KEY is not set" in record.getMessage()
for record in caplog.records
)


def test_e2b_backend_with_api_key_checks_sdk(monkeypatch):
_clear_terminal_env(monkeypatch)
monkeypatch.setenv("TERMINAL_ENV", "e2b")
monkeypatch.setenv("E2B_API_KEY", "e2b_test")
monkeypatch.setitem(sys.modules, "e2b", types.SimpleNamespace(Sandbox=object()))

assert terminal_tool_module.check_terminal_requirements() is True


def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch, caplog, tmp_path):
_clear_terminal_env(monkeypatch)
monkeypatch.setenv("TERMINAL_ENV", "modal")
Expand Down
Loading