Skip to content
Open
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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,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, singularity, tenki)
├── gateway/ # Messaging gateway — run.py + session.py + platforms/
│ ├── platforms/ # Adapter per platform (telegram, discord, slack, whatsapp,
│ │ # homeassistant, signal, matrix, mattermost, email, sms,
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ hermes-agent/
│ ├── skill_tools.py # Skill search, load, manage
│ └── environments/ # Terminal execution backends
│ ├── base.py # BaseEnvironment ABC
│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py
│ ├── local.py, docker.py, ssh.py, singularity.py, modal.py, daytona.py, tenki.py
├── gateway/ # Messaging gateway
│ ├── run.py # GatewayRunner — platform lifecycle, message routing, cron
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), OpenR
<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, Daytona, and Tenki. Cloud backends let your agent run isolated compute away from your host. Run it on a $5 VPS, a GPU cluster, or on-demand cloud sandboxes.</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
32 changes: 13 additions & 19 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,7 @@ def format_steer_marker(steer_text: 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", "tenki", "ssh",
"managed_modal",
})

Expand All @@ -904,6 +904,7 @@ def format_steer_marker(steer_text: str) -> str:
"modal": "a Modal sandbox (Linux)",
"managed_modal": "a managed Modal sandbox (Linux)",
"daytona": "a Daytona workspace (Linux)",
"tenki": "a Tenki sandbox (Linux)",
"ssh": "a remote host reached over SSH (likely Linux)",
}

Expand Down Expand Up @@ -944,7 +945,12 @@ def _probe_remote_backend(env_type: str) -> str | None:
try:
# Import locally: tools/ imports are heavy and only relevant when a
# non-local backend is actually configured.
from tools.terminal_tool import _create_environment, _get_env_config # type: ignore
from tools.terminal_tool import ( # type: ignore
_CONTAINER_BACKENDS,
_container_config_from_env_config,
_create_environment,
_get_env_config,
)
except Exception as e:
logger.debug("Backend probe unavailable (import failed): %s", e)
_BACKEND_PROBE_CACHE[cache_key] = ""
Expand All @@ -964,6 +970,8 @@ def _probe_remote_backend(env_type: str) -> str | None:
image = config.get("modal_image", "")
elif env_type == "daytona":
image = config.get("daytona_image", "")
elif env_type == "tenki":
image = config.get("tenki_image", "")
else:
image = ""

Expand All @@ -978,22 +986,8 @@ def _probe_remote_backend(env_type: str) -> str | None:
}

container_config = None
if env_type in {"docker", "singularity", "modal", "daytona"}:
container_config = {
"container_cpu": config.get("container_cpu", 1),
"container_memory": config.get("container_memory", 5120),
"container_disk": config.get("container_disk", 51200),
"container_persistent": config.get("container_persistent", True),
"modal_mode": config.get("modal_mode", "auto"),
"docker_volumes": config.get("docker_volumes", []),
"docker_mount_cwd_to_workspace": config.get("docker_mount_cwd_to_workspace", False),
"docker_forward_env": config.get("docker_forward_env", []),
"docker_env": config.get("docker_env", {}),
"docker_run_as_host_user": config.get("docker_run_as_host_user", False),
"docker_extra_args": config.get("docker_extra_args", []),
"docker_persist_across_processes": config.get("docker_persist_across_processes", True),
"docker_orphan_reaper": config.get("docker_orphan_reaper", True),
}
if env_type in _CONTAINER_BACKENDS:
container_config = _container_config_from_env_config(config)

env = _create_environment(
env_type=env_type,
Expand Down Expand Up @@ -1068,7 +1062,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, tenki, 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
30 changes: 28 additions & 2 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -306,8 +306,34 @@ terminal:
# daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20"
# container_disk: 10240 # Daytona max is 10GB per sandbox

#
# --- Container resource limits (docker, singularity, modal, daytona -- ignored for local/ssh) ---
# -----------------------------------------------------------------------------
# OPTION 7: Tenki cloud execution
# Commands run in Tenki cloud sandboxes, created on demand
# Great for: On-demand cloud compute, isolated ephemeral sandboxes
# Requires: pip install tenki-sandbox, plus `tenki login` or the
# TENKI_AUTH_TOKEN / TENKI_API_KEY env var
# -----------------------------------------------------------------------------
# terminal:
# backend: "tenki"
# cwd: "/home/tenki" # Path INSIDE the sandbox
# timeout: 180
# lifetime_seconds: 300
# container_persistent: false # Tenki default: terminate sandboxes on cleanup
# tenki_image: "" # Optional image/template; blank uses Tenki default
# tenki_api_endpoint: "https://api.tenki.cloud"
# tenki_workspace_id: "" # Blank falls back to Tenki CLI config
# tenki_project_id: "" # Blank falls back to Tenki CLI config
# tenki_name_prefix: "hermes"
# tenki_allow_inbound: false
# tenki_allow_outbound: true
# tenki_max_duration: 3600 # Max sandbox lifetime in seconds
# tenki_idle_timeout: 0 # Auto-pause after idle seconds (0 = disabled)
# tenki_pause_retention: 0 # Retention for paused sandboxes (0 = disabled)
# tenki_sync_hermes_home: false # Opt-in sync of selected ~/.hermes files
# tenki_forward_env: [] # Env vars to forward (e.g. GITHUB_TOKEN)

#
# --- Container resource limits (docker, singularity, modal, daytona, tenki -- 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
39 changes: 37 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,21 @@ def load_cli_config() -> Dict[str, Any]:
"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",
"tenki_image": "",
# Blank so the TENKI_API_ENDPOINT / TENKI_API_URL and Tenki CLI
# fallbacks in resolve_tenki_api_endpoint() stay reachable; a
# non-blank default here is bridged as explicit and would mask them.
"tenki_api_endpoint": "",
"tenki_workspace_id": "",
"tenki_project_id": "",
"tenki_name_prefix": "hermes",
"tenki_allow_inbound": False,
"tenki_allow_outbound": True,
"tenki_max_duration": 3600,
"tenki_idle_timeout": 0,
"tenki_pause_retention": 0,
"tenki_sync_hermes_home": False,
"tenki_forward_env": [],
"docker_volumes": [], # host:container volume mounts for Docker backend
"docker_mount_cwd_to_workspace": False, # explicit opt-in only; default off for sandbox isolation
},
Expand Down Expand Up @@ -509,6 +524,7 @@ def load_cli_config() -> Dict[str, Any]:
# overwrite env vars that were already set by .env -- only a user's config
# file should be authoritative.
_file_has_terminal_config = False
file_config: dict[str, Any] = {}

# Load from file if exists
if config_path.exists():
Expand Down Expand Up @@ -568,7 +584,7 @@ def load_cli_config() -> Dict[str, Any]:
logger.warning("Failed to load cli-config.yaml: %s", e)

# Expand ${ENV_VAR} references in config values before bridging to env vars.
from hermes_cli.config import _expand_env_vars
from hermes_cli.config import _deep_merge, _expand_env_vars, _normalize_terminal_backend_defaults
defaults = _expand_env_vars(defaults)

# Managed scope: overlay administrator-pinned values LAST so they win over
Expand All @@ -581,7 +597,11 @@ def load_cli_config() -> Dict[str, Any]:
# normalization, leaf-merge) and is fail-open.
from hermes_cli import managed_scope

managed_config = managed_scope.load_managed_config()
defaults = managed_scope.apply_managed_overlay(defaults)
raw_terminal_defaults_source = file_config
if managed_config:
raw_terminal_defaults_source = _deep_merge(raw_terminal_defaults_source, managed_config)

# Apply terminal config to environment variables (so terminal_tool picks them up)
terminal_config = defaults.get("terminal", {})
Expand All @@ -591,6 +611,9 @@ def load_cli_config() -> Dict[str, Any]:
# Accept both, with "backend" taking precedence (it's the documented key).
if "backend" in terminal_config:
terminal_config["env_type"] = terminal_config["backend"]

defaults = _normalize_terminal_backend_defaults(defaults, raw_terminal_defaults_source)
terminal_config = defaults.get("terminal", {})

# CWD resolution for CLI/TUI. The gateway has its own config bridge in
# gateway/run.py but may lazily import cli.py (triggering this code).
Expand All @@ -617,12 +640,24 @@ def load_cli_config() -> Dict[str, Any]:
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"tenki_image": "TERMINAL_TENKI_IMAGE",
"tenki_api_endpoint": "TERMINAL_TENKI_API_ENDPOINT",
"tenki_workspace_id": "TERMINAL_TENKI_WORKSPACE_ID",
"tenki_project_id": "TERMINAL_TENKI_PROJECT_ID",
"tenki_name_prefix": "TERMINAL_TENKI_NAME_PREFIX",
"tenki_allow_inbound": "TERMINAL_TENKI_ALLOW_INBOUND",
"tenki_allow_outbound": "TERMINAL_TENKI_ALLOW_OUTBOUND",
"tenki_max_duration": "TERMINAL_TENKI_MAX_DURATION",
"tenki_idle_timeout": "TERMINAL_TENKI_IDLE_TIMEOUT",
"tenki_pause_retention": "TERMINAL_TENKI_PAUSE_RETENTION",
"tenki_sync_hermes_home": "TERMINAL_TENKI_SYNC_HERMES_HOME",
"tenki_forward_env": "TERMINAL_TENKI_FORWARD_ENV",
# SSH config
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
# Container resource config (docker, singularity, modal, daytona -- ignored for local/ssh)
# Container resource config (docker, singularity, modal, daytona, tenki -- ignored for local/ssh)
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
Expand Down
2 changes: 1 addition & 1 deletion docs/security/network-egress-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ docker compose exec gateway \
*container's* network. If you use the default local terminal backend, tool
commands execute inside the same container. For stronger isolation, combine
network segmentation with a sandboxed terminal backend (Docker, Modal,
Daytona).
Daytona, Tenki).

- **Platform adapters need egress:** The gateway service needs outbound access
to reach messaging platform APIs. If you add new platform adapters, add their
Expand Down
15 changes: 14 additions & 1 deletion gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1484,7 +1484,7 @@ def _profile_runtime_scope(profile_home: "Path"):
with open(_config_path, encoding="utf-8") as _f:
_cfg = _yaml.safe_load(_f) or {}
# Expand ${ENV_VAR} references before bridging to env vars.
from hermes_cli.config import _expand_env_vars
from hermes_cli.config import _expand_env_vars, _normalize_terminal_backend_defaults
_cfg = _expand_env_vars(_cfg)
# Managed scope: overlay administrator-pinned values BEFORE bridging to
# env vars, so a managed timezone / redact_secrets / max_turns / terminal
Expand All @@ -1497,6 +1497,7 @@ def _profile_runtime_scope(profile_home: "Path"):
_cfg = managed_scope.apply_managed_overlay(_cfg)
except Exception:
pass
_cfg = _normalize_terminal_backend_defaults(_cfg, _cfg)
# Top-level simple values (fallback only — don't override .env)
for _key, _val in _cfg.items():
if isinstance(_val, (str, int, float, bool)) and _key not in os.environ:
Expand All @@ -1519,6 +1520,18 @@ def _profile_runtime_scope(profile_home: "Path"):
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"tenki_image": "TERMINAL_TENKI_IMAGE",
"tenki_api_endpoint": "TERMINAL_TENKI_API_ENDPOINT",
"tenki_workspace_id": "TERMINAL_TENKI_WORKSPACE_ID",
"tenki_project_id": "TERMINAL_TENKI_PROJECT_ID",
"tenki_name_prefix": "TERMINAL_TENKI_NAME_PREFIX",
"tenki_allow_inbound": "TERMINAL_TENKI_ALLOW_INBOUND",
"tenki_allow_outbound": "TERMINAL_TENKI_ALLOW_OUTBOUND",
"tenki_max_duration": "TERMINAL_TENKI_MAX_DURATION",
"tenki_idle_timeout": "TERMINAL_TENKI_IDLE_TIMEOUT",
"tenki_pause_retention": "TERMINAL_TENKI_PAUSE_RETENTION",
"tenki_sync_hermes_home": "TERMINAL_TENKI_SYNC_HERMES_HOME",
"tenki_forward_env": "TERMINAL_TENKI_FORWARD_ENV",
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
Expand Down
Loading
Loading