Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f77cc93
feat(tools): add Tenki cloud sandbox terminal backend
Jul 13, 2026
1dd2ceb
chore(release): map @hashbender in AUTHOR_MAP
Jul 14, 2026
4773552
fix(tools): capture sandbox ref under lock before exec to avoid cance…
Jul 14, 2026
e7e95e2
fix(tools): harden tenki cleanup close + unify probe container config
Jul 14, 2026
c9dd9a6
Merge upstream main into tenki-sandbox-backend
Jul 15, 2026
f2fb683
fix(tools): capture one sandbox for tenki upload flows
Jul 27, 2026
fc92659
refactor(tools): drop the dead tenki project surface
Jul 27, 2026
a40ba72
feat(tools): bump tenki SDK to 0.5.1 with bounded range
Jul 27, 2026
33e0858
Merge remote-tracking branch 'origin/main' into tenki-sandbox-backend
Jul 27, 2026
e1f032e
fix(tools): harden Tenki sandbox lifecycle
Jul 28, 2026
d6f2599
fix(tools): keep live environment authoritative in path detection
Jul 28, 2026
2565a14
refactor(tools): unify sudo command-word rewriting
Jul 28, 2026
5df47ee
refactor(tools): restructure Tenki recovery-state layer
Jul 28, 2026
5069810
refactor(tools): quality pass over the Tenki backend
Jul 29, 2026
f22964b
Merge origin/main into tenki-sandbox-backend
Jul 29, 2026
be45408
Merge origin/main into tenki-sandbox-backend
Jul 30, 2026
293d1d3
Merge remote-tracking branch 'origin/main' into tenki-sandbox-backend
Aug 4, 2026
5a48519
Merge remote-tracking branch 'origin/main' into tenki-sandbox-backend
Aug 11, 2026
1d7b5ab
Merge remote-tracking branch 'origin/main' into tenki-sandbox-backend
Aug 14, 2026
819340f
Merge remote-tracking branch 'origin/main' into tenki-sandbox-backend
Aug 25, 2026
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 @@ -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, 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 @@ -258,7 +258,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>Seven terminal backends β€” local, Docker, SSH, Singularity, Modal, Daytona, and Vercel Sandbox. Daytona and Modal offer serverless persistence β€” your agent's environment hibernates when idle and wakes on demand, costing nearly nothing between sessions. Run it on a $5 VPS or a GPU cluster.</td></tr>
<tr><td><b>Runs anywhere, not just your laptop</b></td><td>Eight terminal backends β€” local, Docker, SSH, Singularity, Modal, Daytona, Vercel Sandbox, and Tenki. 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, 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
79 changes: 52 additions & 27 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -1221,7 +1221,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", "tenki", "managed_modal",
})


Expand Down Expand Up @@ -1265,6 +1265,7 @@ def _plugin_backend_description(backend: str) -> str | None:
"managed_modal": "a managed Modal sandbox (Linux)",
"daytona": "a Daytona workspace (Linux)",
"vercel_sandbox": "a Vercel sandbox (Linux)",
"tenki": "a Tenki sandbox (Linux)",
"ssh": "a remote host reached over SSH (likely Linux)",
}

Expand All @@ -1274,7 +1275,7 @@ def _plugin_backend_description(backend: str) -> str | None:
# a mid-process backend switch rebuilds the string. Kept in-module (not on
# disk) because the probe captures live backend state that may change
# across Hermes restarts.
_BACKEND_PROBE_CACHE: dict[tuple[str, str], str] = {}
_BACKEND_PROBE_CACHE: dict[tuple[str, str, str], str] = {}


def _windows_marketing_version() -> str:
Expand Down Expand Up @@ -1330,15 +1331,36 @@ def _probe_remote_backend(env_type: str) -> str | None:
operate on a different machine than the host Hermes runs on.
"""
cwd_hint = os.getenv("TERMINAL_CWD", "")
cache_key = (env_type, cwd_hint)
try:
from hermes_constants import get_hermes_home

profile_key = str(get_hermes_home())
except Exception:
profile_key = ""
cache_key = (env_type, cwd_hint, profile_key)
cached = _BACKEND_PROBE_CACHE.get(cache_key)
if cached is not None:
return cached or None

# Tenki environments are billable cloud resources and the prompt builder
# has no registry ownership or side-effect-free teardown seam for them.
# Creating a one-off sandbox here could forward configured credentials,
# sync profile files, or persist resources before the first tool call.
# Use the existing static fallback; the agent can probe its real sandbox
# with a terminal call once it actually needs one.
if env_type == "tenki":
_BACKEND_PROBE_CACHE[cache_key] = ""
return 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_config_from_config,
_create_environment,
_get_env_config,
_is_container_backend,
)
except Exception as e:
logger.debug("Backend probe unavailable (import failed): %s", e)
_BACKEND_PROBE_CACHE[cache_key] = ""
Expand Down Expand Up @@ -1372,26 +1394,8 @@ def _probe_remote_backend(env_type: str) -> str | None:
}

container_config = None
from tools.terminal_tool import _is_container_backend as _is_container

if _is_container(env_type):
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_shm_size": config.get("docker_shm_size", "1g"),
"docker_persist_across_processes": config.get("docker_persist_across_processes", True),
"docker_shared_container_key": config.get("docker_shared_container_key", ""),
"docker_orphan_reaper": config.get("docker_orphan_reaper", True),
}
if _is_container_backend(env_type):
container_config = _container_config_from_config(config)

env = _create_environment(
env_type=env_type,
Expand Down Expand Up @@ -1466,10 +1470,11 @@ 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, tenki): 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.
matters. A live probe inside most backends reports its OS, user, $HOME,
and cwd, with a static fallback if the probe fails. Tenki always uses
the static summary so prompt construction never creates a cloud sandbox.

The WSL environment hint is appended unchanged when running under WSL.
"""
Expand All @@ -1479,6 +1484,15 @@ def build_environment_hints() -> str:
hints: list[str] = []

backend = (os.getenv("TERMINAL_ENV") or "local").strip().lower()
try:
from agent.secret_scope import current_secret_scope, is_multiplex_active

if is_multiplex_active() and current_secret_scope() is not None:
from tools.terminal_tool import _get_env_config

backend = str(_get_env_config().get("env_type") or backend).strip().lower()
except Exception:
logger.debug("Could not resolve profile-scoped terminal backend", exc_info=True)
is_remote_backend = backend in _REMOTE_TERMINAL_BACKENDS or _plugin_backend_is_remote(backend)

if not is_remote_backend:
Expand Down Expand Up @@ -1525,6 +1539,17 @@ def build_environment_hints() -> str:
f"of the Hermes process are irrelevant; only the following "
f"backend state matters:\n{probe}"
)
elif backend == "tenki":
hints.append(
"Terminal backend: tenki. Your `terminal`, `read_file`, "
"`write_file`, `patch`, and `search_files` tools all operate "
"inside a Tenki sandbox (Linux) β€” NOT on the machine where "
"Hermes itself runs. Prompt construction intentionally "
"defers sandbox creation until the first tool call that needs "
"the backend, "
"so the sandbox's current user, $HOME, and working directory "
"are not known yet."
)
else:
description = _BACKEND_FALLBACK_DESCRIPTIONS.get(
backend,
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/app/settings/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,8 +243,8 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
// shadow that and hide user-installed/pip providers (#49513).
// Terminal execution backends β€” kept in sync with the dispatch ladder in
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).
'terminal.backend': ['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh'],
// modal/daytona/tenki/ssh). Remote backends need extra env (image, tokens, host).
'terminal.backend': ['local', 'docker', 'singularity', 'modal', 'daytona', 'tenki', 'ssh'],
'stt.elevenlabs.model_id': ['scribe_v2', 'scribe_v1'],
'stt.local.model': ['tiny', 'base', 'small', 'medium', 'large-v3'],
// Speech-to-text backends β€” kept in sync with the stt block in
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/app/settings/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ describe('settings helpers', () => {

it('renders a dropdown for the terminal execution backend', () => {
const opts = enumOptionsFor('terminal.backend', 'local', config)
expect(opts).toEqual(['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh'])
expect(opts).toContain('tenki')
expect(new Set(opts).size).toBe(opts?.length)
})

it('narrows OpenAI TTS voice suggestions to what the selected model supports', () => {
Expand Down
29 changes: 27 additions & 2 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -427,8 +427,33 @@ 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, 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 registry image reference; blank uses Tenki default
# tenki_api_endpoint: "https://api.tenki.cloud"
# tenki_workspace_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 = Tenki default)
# 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
37 changes: 35 additions & 2 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,20 @@ 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_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
"docker_shared_container_key": "",
Expand Down Expand Up @@ -550,6 +564,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 @@ -609,7 +624,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 @@ -622,7 +637,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 @@ -632,6 +651,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 Down Expand Up @@ -660,12 +682,23 @@ def load_cli_config() -> Dict[str, Any]:
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"vercel_runtime": "TERMINAL_VERCEL_RUNTIME",
"tenki_image": "TERMINAL_TENKI_IMAGE",
"tenki_api_endpoint": "TERMINAL_TENKI_API_ENDPOINT",
"tenki_workspace_id": "TERMINAL_TENKI_WORKSPACE_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, vercel_sandbox -- ignored for local/ssh)
# Container resource config (docker, singularity, modal, daytona, vercel_sandbox, 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
Loading