diff --git a/AGENTS.md b/AGENTS.md index a3d5e5be8413..6737b92a98d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46581d820037..d2a6c43979cb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/README.md b/README.md index ba1322a38920..3c6dddd8f6a5 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 laptopSix 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. +Runs anywhere, not just your laptopSeven 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. 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 b5b2b58c3621..3d3638f5b02c 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -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", }) @@ -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)", } @@ -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] = "" @@ -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 = "" @@ -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, @@ -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. diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 69acc6868057..fd426419a69b 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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 diff --git a/cli.py b/cli.py index 9887bb029780..fe5c0910a741 100644 --- a/cli.py +++ b/cli.py @@ -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 }, @@ -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(): @@ -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 @@ -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", {}) @@ -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). @@ -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", diff --git a/docs/security/network-egress-isolation.md b/docs/security/network-egress-isolation.md index 46cde2fd7471..0c356d32de2e 100644 --- a/docs/security/network-egress-isolation.md +++ b/docs/security/network-egress-isolation.md @@ -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 diff --git a/gateway/run.py b/gateway/run.py index d5b3fbff4743..c0779e074079 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -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 @@ -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: @@ -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", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 30a8ae4643c3..b747196bffb1 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1219,7 +1219,23 @@ 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) + "tenki_image": "", + # Blank so the documented TENKI_API_ENDPOINT / TENKI_API_URL and Tenki + # CLI-config fallbacks in resolve_tenki_api_endpoint() are reachable. A + # non-blank default here is bridged as an explicit value and would mask + # them. Blank resolves to https://api.tenki.cloud downstream. + "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": [], + # Container resource limits (docker, singularity, modal, daytona, tenki — ignored for local/ssh) "container_cpu": 1, "container_memory": 5120, # MB (default 5GB) "container_disk": 51200, # MB (default 50GB) @@ -6608,6 +6624,31 @@ def _normalize_max_turns_config(config: Dict[str, Any]) -> Dict[str, Any]: return config +def _normalize_terminal_backend_defaults( + config: Dict[str, Any], + raw_config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Apply backend-specific terminal defaults without writing them to config.yaml.""" + terminal = config.get("terminal") + if not isinstance(terminal, dict): + return config + + backend = str(terminal.get("backend") or terminal.get("env_type") or "").lower() + if backend != "tenki": + return config + + raw_terminal = raw_config.get("terminal") if isinstance(raw_config, dict) else {} + explicit_persistence = isinstance(raw_terminal, dict) and "container_persistent" in raw_terminal + if explicit_persistence: + return config + + config = dict(config) + terminal = dict(terminal) + terminal["container_persistent"] = False + config["terminal"] = terminal + return config + + def cfg_get(cfg: Optional[Dict[str, Any]], *keys: str, default: Any = None) -> Any: """Traverse nested dict keys safely, returning ``default`` on any miss. @@ -6822,6 +6863,18 @@ def write_platform_config_field( "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", @@ -6877,10 +6930,20 @@ def apply_terminal_config_to_env( target = os.environ if env is None else env raw_config = read_raw_config() + raw_terminal_defaults_source: Dict[str, Any] = raw_config + try: + from hermes_cli import managed_scope + + managed_config = managed_scope.load_managed_config() + if managed_config: + raw_terminal_defaults_source = _deep_merge(raw_terminal_defaults_source, managed_config) + except Exception: + pass file_has_terminal_config = isinstance(raw_config.get("terminal"), dict) should_override = file_has_terminal_config if override is None else override cfg = config if config is not None else load_config_readonly() + cfg = _normalize_terminal_backend_defaults(cfg, raw_terminal_defaults_source) terminal_cfg = cfg.get("terminal", {}) if isinstance(cfg, dict) else {} if not isinstance(terminal_cfg, dict): return target @@ -6951,6 +7014,7 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]: return copy.deepcopy(cached[4]) if want_deepcopy else cached[4] config = copy.deepcopy(DEFAULT_CONFIG) + user_config: Dict[str, Any] = {} if user_sig is not None: try: @@ -7014,9 +7078,12 @@ def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]: # This deliberately inverts the usual env-over-config precedence for the # keys the managed layer pins — see docs/design/managed-scope.md §4.1. managed_config = managed_scope.load_managed_config() + raw_terminal_defaults_source: Dict[str, Any] = user_config if managed_config: managed_expanded = _expand_env_vars(managed_config) expanded = _deep_merge(expanded, managed_expanded) + raw_terminal_defaults_source = _deep_merge(raw_terminal_defaults_source, managed_config) + expanded = _normalize_terminal_backend_defaults(expanded, raw_terminal_defaults_source) _LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded) if cache_sig is not None: # Cache stores a separate deepcopy so subsequent ``load_config()`` @@ -7967,6 +8034,12 @@ 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') == 'tenki': + print(f" Tenki image: {terminal.get('tenki_image') or '(Tenki default)'}") + print(f" Endpoint: {terminal.get('tenki_api_endpoint') or 'https://api.tenki.cloud'}") + print(f" Workspace: {terminal.get('tenki_workspace_id') or '(from Tenki CLI)'}") + print(f" Project: {terminal.get('tenki_project_id') or '(from Tenki CLI)'}") + print(f" Sync .hermes: {'enabled' if terminal.get('tenki_sync_hermes_home') else 'disabled'}") 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 4122e7af295d..2f7cacc2cb70 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -8,6 +8,7 @@ import sys import subprocess import shutil +import importlib.util from pathlib import Path from hermes_cli.config import get_project_root, get_hermes_home, get_env_path @@ -1549,6 +1550,58 @@ def run_doctor(args): issues, ) + # Tenki (if using tenki backend) + if terminal_env == "tenki": + try: + from hermes_cli.config import load_config_readonly + from tools.tenki_config import ( + has_tenki_auth, + resolve_tenki_project_id, + resolve_tenki_workspace_id, + ) + except Exception: + load_config_readonly = lambda: {} # noqa: E731 + has_tenki_auth = lambda: False # noqa: E731 + resolve_tenki_project_id = lambda _explicit="": "" # noqa: E731 + resolve_tenki_workspace_id = lambda _explicit="": "" # noqa: E731 + terminal_cfg = load_config_readonly().get("terminal", {}) + if not isinstance(terminal_cfg, dict): + terminal_cfg = {} + + if has_tenki_auth(): + check_ok("Tenki auth", "(configured)") + else: + _fail_and_issue( + "Tenki auth not found", + "(required for TERMINAL_ENV=tenki)", + "Run tenki login or set TENKI_AUTH_TOKEN/TENKI_API_KEY", + issues, + ) + + workspace_id = resolve_tenki_workspace_id( + os.getenv("TERMINAL_TENKI_WORKSPACE_ID") or terminal_cfg.get("tenki_workspace_id", "") + ) + project_id = resolve_tenki_project_id( + os.getenv("TERMINAL_TENKI_PROJECT_ID") or terminal_cfg.get("tenki_project_id", "") + ) + if workspace_id and project_id: + check_ok("Tenki workspace/project", "(configured)") + else: + check_warn( + "Tenki workspace/project not configured", + "(optional for sessions; required for volume-backed workflows)", + ) + + if importlib.util.find_spec("tenki_sandbox") is not None: + check_ok("tenki-sandbox SDK", "(installed)") + else: + _fail_and_issue( + "tenki-sandbox SDK not installed", + "(pip install tenki-sandbox==0.1.1)", + "Install Tenki SDK: pip install tenki-sandbox==0.1.1", + issues, + ) + # Node.js + agent-browser (for browser automation tools) if _safe_which("node"): check_ok("Node.js") diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 54f4e5f676d5..fd2177af9b8d 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -673,7 +673,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, Tenki).""" terminal = config.setdefault("terminal", {}) print() @@ -1196,11 +1196,12 @@ def setup_terminal_backend(config: dict): "Modal - serverless cloud sandbox", "SSH - run on a remote machine", "Daytona - persistent cloud development environment", + "Tenki Agent - Tenki cloud sandbox", ] - 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: "ssh", 4: "daytona", 5: "tenki"} + backend_to_idx = {"local": 0, "docker": 1, "modal": 2, "ssh": 3, "daytona": 4, "tenki": 5} - next_idx = 5 + next_idx = 6 if is_linux: terminal_choices.append("Singularity/Apptainer - HPC-friendly container") idx_to_backend[next_idx] = "singularity" @@ -1386,6 +1387,82 @@ def setup_terminal_backend(config: dict): "daytona_image", "nikolaik/python-nodejs:python3.11-nodejs20" ) + elif selected_backend == "tenki": + print_success("Terminal backend: Tenki Agent") + print_info("Cloud sandboxes are created on demand and terminated by default.") + print_info("Requires Tenki CLI login or TENKI_AUTH_TOKEN/TENKI_API_KEY.") + + try: + __import__("tenki_sandbox") + except ImportError: + print_info("Installing Tenki SDK...") + import subprocess + + uv_bin = shutil.which("uv") + package = "tenki-sandbox==0.1.1" + if uv_bin: + result = subprocess.run( + [uv_bin, "pip", "install", "--python", sys.executable, package], + capture_output=True, + text=True, + ) + else: + result = subprocess.run( + [sys.executable, "-m", "pip", "install", package], + capture_output=True, + text=True, + ) + if result.returncode == 0: + print_success("Tenki SDK installed") + else: + print_warning("Install failed — run manually: pip install tenki-sandbox==0.1.1") + if result.stderr: + print_info(f" Error: {result.stderr.strip().splitlines()[-1]}") + + from tools.tenki_config import ( + has_tenki_auth, + resolve_tenki_api_endpoint, + resolve_tenki_project_id, + resolve_tenki_workspace_id, + ) + + terminal = config.setdefault("terminal", {}) + endpoint = resolve_tenki_api_endpoint(terminal.get("tenki_api_endpoint", "")) + workspace_id = resolve_tenki_workspace_id(terminal.get("tenki_workspace_id", "")) + project_id = resolve_tenki_project_id(terminal.get("tenki_project_id", "")) + + terminal["tenki_api_endpoint"] = endpoint + if workspace_id: + terminal["tenki_workspace_id"] = workspace_id + if project_id: + terminal["tenki_project_id"] = project_id + terminal.setdefault("tenki_image", "") + terminal.setdefault("tenki_name_prefix", "hermes") + terminal.setdefault("tenki_allow_inbound", False) + terminal.setdefault("tenki_allow_outbound", True) + terminal.setdefault("tenki_max_duration", 3600) + terminal.setdefault("tenki_idle_timeout", 0) + terminal.setdefault("tenki_pause_retention", 0) + terminal.setdefault("tenki_sync_hermes_home", False) + if current_backend == "tenki": + terminal.setdefault("container_persistent", False) + terminal.setdefault("cwd", "/home/tenki") + else: + terminal["container_persistent"] = False + terminal["cwd"] = "/home/tenki" + + print_info(f" Endpoint: {endpoint}") + print_info(f" Workspace: {workspace_id or '(not found; run tenki login)'}") + print_info(f" Project: {project_id or '(not found; run tenki login)'}") + if has_tenki_auth(): + print_info(" Tenki auth: already configured") + else: + print_warning(" Tenki auth not found") + token = prompt(" Tenki token/API key (optional; leave blank to run tenki login)", password=True) + if token: + save_env_value("TENKI_API_KEY", token) + print_success(" Configured") + 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 088460fb63fe..43c7a18fcaf7 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -425,6 +425,33 @@ 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 == "tenki": + from tools.tenki_config import ( + resolve_tenki_api_endpoint, + resolve_tenki_project_id, + resolve_tenki_workspace_id, + ) + + tenki_image = os.getenv("TERMINAL_TENKI_IMAGE") or terminal_cfg.get("tenki_image", "") + tenki_endpoint = resolve_tenki_api_endpoint( + os.getenv("TERMINAL_TENKI_API_ENDPOINT") or terminal_cfg.get("tenki_api_endpoint", "") + ) + tenki_workspace = resolve_tenki_workspace_id( + os.getenv("TERMINAL_TENKI_WORKSPACE_ID") or terminal_cfg.get("tenki_workspace_id", "") + ) + tenki_project = resolve_tenki_project_id( + os.getenv("TERMINAL_TENKI_PROJECT_ID") or terminal_cfg.get("tenki_project_id", "") + ) + tenki_sync_value = os.getenv("TERMINAL_TENKI_SYNC_HERMES_HOME") + if tenki_sync_value is None: + tenki_sync = bool(terminal_cfg.get("tenki_sync_hermes_home", False)) + else: + tenki_sync = tenki_sync_value.lower() in {"true", "1", "yes"} + print(f" Tenki Image: {tenki_image or '(Tenki default)'}") + print(f" Endpoint: {tenki_endpoint}") + print(f" Workspace: {tenki_workspace or '(not found)'}") + print(f" Project: {tenki_project or '(not found)'}") + print(f" Sync .hermes: {check_mark(tenki_sync)} {'enabled' if tenki_sync else 'disabled'}") 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/tips.py b/hermes_cli/tips.py index 6f365771a1b3..9b8a8466dd4a 100644 --- a/hermes_cli/tips.py +++ b/hermes_cli/tips.py @@ -147,7 +147,7 @@ "/moa routes one hard prompt through your configured Mixture of Agents model set.", "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, Daytona, Tenki, and Singularity.", # --- Profiles --- "Each profile gets its own config, API keys, memory, sessions, skills, and cron jobs.", diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 7365d241c09f..55692d36b2c9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -624,7 +624,7 @@ async def _token_auth_seam(request: Request, call_next): "terminal.backend": { "type": "select", "description": "Terminal execution backend", - "options": ["local", "docker", "ssh", "modal", "daytona", "singularity"], + "options": ["local", "docker", "ssh", "modal", "daytona", "tenki", "singularity"], }, "terminal.modal_mode": { "type": "select", diff --git a/nix/packages.nix b/nix/packages.nix index d11a21d2cb71..95ab693f8957 100644 --- a/nix/packages.nix +++ b/nix/packages.nix @@ -35,6 +35,7 @@ "messaging" "modal" "parallel-web" + "tenki" "tts-premium" "voice" ] diff --git a/pyproject.toml b/pyproject.toml index 851473b13b3f..58fb521425d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -156,6 +156,7 @@ fal = ["fal-client==0.13.1"] edge-tts = ["edge-tts==7.2.7"] modal = ["modal==1.3.4"] daytona = ["daytona==0.155.0"] +tenki = ["tenki-sandbox==0.1.1"] hindsight = ["hindsight-client==0.6.1"] dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==81.0.0"] # starlette: CVE-2026-48710; setuptools: latest <82 (torch >=2.11 caps setuptools<82) messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.14.1", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] # aiohttp 3.14.1: CVE-2026-34513/34518/34519/34520/34525 + 34993(RCE)/47265 @@ -282,7 +283,7 @@ all = [ # # Removed from [all] on 2026-05-12 (covered by lazy-install): # anthropic, exa, firecrawl, parallel-web, fal, edge-tts, - # modal, daytona, messaging (telegram/discord/slack), + # modal, daytona, tenki, messaging (telegram/discord/slack), # matrix, slack, honcho, voice (faster-whisper), # dingtalk, feishu, bedrock, tts-premium (elevenlabs) # diff --git a/scripts/release.py b/scripts/release.py index 84a39b54c377..675159235eaa 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -75,6 +75,7 @@ "AndreasHiltner@users.noreply.github.com": "AndreasHiltner", # PR #56854 salvage (gateway: route multiplex profile responses through the profile's own adapter — 53-site _adapter_for_source sweep) "AlexFucuson9@users.noreply.github.com": "AlexFucuson9", # PR #61347 salvage (agent: reapply provider headers after model switch; #61099) "allenliang2022@users.noreply.github.com": "allenliang2022", # PR #56932 test coverage folded into #56909 salvage (408 → retryable timeout) + "nick@luxor.tech": "hashbender", # PR #64190 (tenki cloud sandbox terminal backend) "m888.braun@hotmail.com": "ManniBr", # PR #57417 partial salvage (gateway: fail-closed adapter resolution for unregistered secondary profiles) "poowis2011@hotmail.com": "Umi4Life", # PR #47377 salvage (agent: emit one-shot fallback switch notice on successful fallback so gateway users see model/provider change; #35419) "austin@openvm067.space": "austinlaw076", # PR #57563 partial salvage (auth: lazy per-profile Anthropic OAuth file; gateway: whatsapp_cloud/line added to port-binding platform set) diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 858c880ec8fb..6ecddf57f343 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -1296,10 +1296,46 @@ def _fake_create_environment(*, env_type, **kwargs): assert "Linux 6.8.0" in line assert "root" in line + def test_probe_container_config_uses_shared_builder(self, monkeypatch): + """The probe must pass the canonical container config from + ``_container_config_from_env_config`` — a stale inline copy omitted + ``tenki_sync_hermes_home`` / ``tenki_forward_env`` (and + ``docker_network``), so probe environments were built with different + settings than real ones.""" + import agent.prompt_builder as _pb + import tools.terminal_tool as _tt + + monkeypatch.setenv("TERMINAL_ENV", "tenki") + _pb._clear_backend_probe_cache() + + class _FakeEnv: + def execute(self, cmd, timeout=None): + return { + "returncode": 0, + "output": ( + "os=Linux\nkernel=6.8.0\nhome=/home/tenki\n" + "cwd=/home/tenki\nuser=tenki\n" + ), + } + + created = {} + + def _fake_create_environment(*, env_type, **kwargs): + created["container_config"] = kwargs.get("container_config") + return _FakeEnv() + + monkeypatch.setattr(_tt, "_create_environment", _fake_create_environment) + + assert _pb._probe_remote_backend("tenki") is not None + container_config = created["container_config"] + assert container_config is not None + for key in ("tenki_sync_hermes_home", "tenki_forward_env", "docker_network"): + assert key in container_config + 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", "tenki", "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" @@ -1645,4 +1681,3 @@ def test_not_duplicated_in_google_guidance(self): # Budget warning history stripping # ========================================================================= - diff --git a/tests/gateway/test_config_cwd_bridge.py b/tests/gateway/test_config_cwd_bridge.py index ca449b94b62b..e865dced77fb 100644 --- a/tests/gateway/test_config_cwd_bridge.py +++ b/tests/gateway/test_config_cwd_bridge.py @@ -22,6 +22,9 @@ def _simulate_config_bridge(cfg: dict, initial_env: dict | None = None): Returns the resulting env dict (only TERMINAL_* and MESSAGING_CWD keys). """ env = dict(initial_env or {}) + from hermes_cli.config import _normalize_terminal_backend_defaults + + cfg = _normalize_terminal_backend_defaults(cfg, cfg) # --- Replicate lines 54-56: generic top-level bridge (for context) --- for key, val in cfg.items(): @@ -111,6 +114,12 @@ def test_top_level_backend_sets_terminal_env(self): result = _simulate_config_bridge(cfg) assert result["TERMINAL_ENV"] == "docker" + def test_tenki_backend_defaults_to_terminate_only_over_stale_env(self): + cfg = {"terminal": {"backend": "tenki"}} + result = _simulate_config_bridge(cfg, {"TERMINAL_CONTAINER_PERSISTENT": "true"}) + assert result["TERMINAL_ENV"] == "tenki" + assert result["TERMINAL_CONTAINER_PERSISTENT"] == "False" + def test_top_level_cwd_and_backend(self): cfg = {"backend": "local", "cwd": "/home/hermes/projects"} result = _simulate_config_bridge(cfg) diff --git a/tests/hermes_cli/test_config_env_expansion.py b/tests/hermes_cli/test_config_env_expansion.py index 75ef62592d1a..4738c180dc27 100644 --- a/tests/hermes_cli/test_config_env_expansion.py +++ b/tests/hermes_cli/test_config_env_expansion.py @@ -1,5 +1,7 @@ """Tests for ${ENV_VAR} substitution in config.yaml values.""" +import os + import pytest from hermes_cli.config import _expand_env_vars, load_config @@ -196,3 +198,19 @@ def test_cli_config_unresolved_kept_verbatim(self, tmp_path, monkeypatch): config = load_cli_config() assert config["auxiliary"]["vision"]["api_key"] == "${UNSET_CLI_VAR_ABC}" + + def test_cli_tenki_backend_overrides_stale_persistent_env(self, tmp_path, monkeypatch): + config_yaml = "terminal:\n backend: tenki\n" + config_file = tmp_path / "config.yaml" + config_file.write_text(config_yaml) + + monkeypatch.setenv("TERMINAL_CONTAINER_PERSISTENT", "true") + monkeypatch.setattr("cli._hermes_home", tmp_path) + + from cli import load_cli_config + config = load_cli_config() + + assert config["terminal"]["container_persistent"] is False + assert config["terminal"]["env_type"] == "tenki" + assert config["terminal"]["backend"] == "tenki" + assert os.environ["TERMINAL_CONTAINER_PERSISTENT"] == "False" diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 9cf2f737eb22..a84f74d6069f 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -491,6 +491,37 @@ def fake_prompt_choice(question, choices, default=0): assert config["terminal"]["modal_mode"] == "direct" +def test_tenki_setup_switch_resets_inherited_persistent_container(monkeypatch): + config = { + "terminal": { + "backend": "docker", + "container_persistent": True, + "cwd": "/workspace", + } + } + + def fake_prompt_choice(question, choices, default=0): + if question == "Select terminal backend:": + assert "Tenki Agent - Tenki cloud sandbox" in choices + return choices.index("Tenki Agent - Tenki cloud sandbox") + raise AssertionError(f"Unexpected prompt_choice call: {question}") + + monkeypatch.setitem(sys.modules, "tenki_sandbox", types.ModuleType("tenki_sandbox")) + monkeypatch.setenv("TENKI_AUTH_TOKEN", "tok") + monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") + monkeypatch.setattr("hermes_cli.setup.save_config", lambda _config: None) + monkeypatch.setattr("hermes_cli.setup.save_env_value", lambda *_args, **_kwargs: None) + + from hermes_cli.setup import setup_terminal_backend + + setup_terminal_backend(config) + + assert config["terminal"]["backend"] == "tenki" + assert config["terminal"]["container_persistent"] is False + assert config["terminal"]["cwd"] == "/home/tenki" + + # test_setup_slack_* moved to tests/gateway/test_slack_plugin_setup.py — the # _setup_slack wizard migrated to the slack plugin's interactive_setup (#41112). @@ -539,4 +570,3 @@ def _interrupt(*_a, **_k): with pytest.raises(SystemExit): setup_mod.prompt_yes_no("Install it now?", True) - diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py index f2f4887d6091..7bc84858be2d 100644 --- a/tests/test_project_metadata.py +++ b/tests/test_project_metadata.py @@ -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", "tenki", "messaging", "slack", "matrix", "dingtalk", "feishu", "honcho", "hindsight", "supermemory", "mem0", diff --git a/tests/tools/test_browser_ssrf_local.py b/tests/tools/test_browser_ssrf_local.py index 9536e09891de..a9c24a5dec9b 100644 --- a/tests/tools/test_browser_ssrf_local.py +++ b/tests/tools/test_browser_ssrf_local.py @@ -190,7 +190,7 @@ def test_cloud_provider_is_not_local(self, monkeypatch): assert browser_tool._is_local_backend() is False - @pytest.mark.parametrize("backend", ["docker", "modal", "daytona", "ssh", "singularity"]) + @pytest.mark.parametrize("backend", ["docker", "modal", "daytona", "tenki", "ssh", "singularity"]) def test_container_terminal_backend_is_not_local(self, monkeypatch, backend): """Terminal running in a container → NOT local (browser on host can access internal networks).""" monkeypatch.setattr(browser_tool, "_is_camofox_mode", lambda: False) diff --git a/tests/tools/test_command_guards.py b/tests/tools/test_command_guards.py index 9b8a93c30bf8..85bb2febce4e 100644 --- a/tests/tools/test_command_guards.py +++ b/tests/tools/test_command_guards.py @@ -74,6 +74,10 @@ def test_daytona_skips_both(self): result = check_all_command_guards("rm -rf /", "daytona") assert result["approved"] is True + def test_tenki_skips_both(self): + result = check_all_command_guards("rm -rf /", "tenki") + assert result["approved"] is True + # --------------------------------------------------------------------------- # tirith allow + safe command diff --git a/tests/tools/test_container_cwd_sanitize.py b/tests/tools/test_container_cwd_sanitize.py index 00a155e8bb82..2f06b60ae290 100644 --- a/tests/tools/test_container_cwd_sanitize.py +++ b/tests/tools/test_container_cwd_sanitize.py @@ -62,10 +62,35 @@ def test_host_prefixes_include_windows_and_posix(self): def test_container_backends_set(self): assert tt._CONTAINER_BACKENDS == frozenset( - {"docker", "singularity", "modal", "daytona"} + {"docker", "singularity", "modal", "daytona", "tenki"} ) +class TestBackendGuestSubpath: + """The Tenki guest home /home/tenki (and its subtree) is a real sandbox + path, so it must be exempt from the host-path guard even though it shares + the /home/ prefix that other backends reject.""" + + def test_tenki_guest_home_root_is_subpath(self): + assert tt._is_backend_guest_subpath("tenki", "/home/tenki") is True + + def test_tenki_guest_home_child_is_subpath(self): + assert tt._is_backend_guest_subpath("tenki", "/home/tenki/project") is True + + def test_tenki_unrelated_home_is_not_subpath(self): + # A different user's home is still a host path even on tenki. + assert tt._is_backend_guest_subpath("tenki", "/home/someoneelse") is False + + def test_docker_has_no_guest_home_exemption(self): + # Docker's default cwd is /root, so /home/... stays a rejected host path. + assert tt._is_backend_guest_subpath("docker", "/home/tenki/project") is False + + def test_tenki_guest_subpath_survives_full_guard(self): + # The combined check the call sites use: unusable-by-prefix but exempt. + assert tt._is_unusable_container_cwd("/home/tenki/project") is True + assert tt._is_backend_guest_subpath("tenki", "/home/tenki/project") is True + + class TestOverrideCwdSanitizedAtCallSite: """E2E pin: a per-task cwd OVERRIDE that is a host path must NOT reach the container builder. This is the actual reported bug — the gateway/TUI @@ -145,6 +170,62 @@ def test_valid_container_override_is_preserved(self, monkeypatch): cwd = self._run_and_capture_cwd(monkeypatch, "/workspace/task42") assert cwd == "/workspace/task42" + def _run_tenki_and_capture_cwd(self, monkeypatch, override_cwd): + """Drive terminal_tool() on the tenki backend with a cwd override and + return the cwd that reached _create_environment.""" + captured = {} + config = { + "env_type": "tenki", + "tenki_image": "", + "cwd": "/home/tenki", + "host_cwd": None, + "timeout": 180, + "lifetime_seconds": 300, + "container_cpu": 1, + "container_memory": 5120, + "container_disk": 51200, + "container_persistent": False, + "modal_mode": "auto", + } + + class _DummyEnv: + cwd = "/home/tenki" + + def execute(self, *a, **k): + return {"output": "", "exit_code": 0} + + def fake_create_environment(env_type, image, cwd, timeout, **kwargs): + captured["cwd"] = cwd + 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", {}) + + task_id = "sess-tenki-cwd" + tt.register_task_env_overrides(task_id, {"cwd": override_cwd}) + try: + tt.terminal_tool(command="pwd", task_id=task_id) + finally: + tt.clear_task_env_overrides(task_id) + tt._active_environments.pop(task_id, None) + tt._active_environments.pop("default", None) + return captured.get("cwd") + + def test_tenki_guest_subpath_override_is_preserved(self, monkeypatch): + # A real Tenki guest path registered as a per-task override must NOT be + # collapsed to /home/tenki (the #9b override-path fix). + cwd = self._run_tenki_and_capture_cwd(monkeypatch, "/home/tenki/project") + assert cwd == "/home/tenki/project" + + def test_tenki_foreign_host_override_still_sanitized(self, monkeypatch): + # A genuine host path is still discarded on tenki. + cwd = self._run_tenki_and_capture_cwd(monkeypatch, "/home/someoneelse/x") + assert cwd == "/home/tenki" + class TestFileOpsCwdSanitizedAtCallSite: """E2E pin: file tools (_get_file_ops) must sanitize a host/relative cwd @@ -171,6 +252,18 @@ def _run_and_capture_cwd(self, monkeypatch, override_cwd, env_type="docker", "singularity_image": "docker://pytorch/pytorch:latest", "modal_image": "pytorch/pytorch:latest", "daytona_image": "pytorch/pytorch:latest", + "tenki_image": "", + "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": [], "cwd": config_cwd, "host_cwd": None, "timeout": 180, @@ -255,3 +348,15 @@ def test_host_override_sanitized_on_modal(self, monkeypatch): cwd = self._run_and_capture_cwd( monkeypatch, "/Users/me/workspace", env_type="modal") assert cwd == "/workspace" + + def test_tenki_guest_subpath_override_is_preserved(self, monkeypatch): + # File tools must honor the same Tenki guest-home exemption as the + # terminal tool: /home/tenki/project is a real sandbox path. + cwd = self._run_and_capture_cwd( + monkeypatch, "/home/tenki/project", env_type="tenki", config_cwd="/home/tenki") + assert cwd == "/home/tenki/project" + + def test_tenki_foreign_host_override_still_sanitized(self, monkeypatch): + cwd = self._run_and_capture_cwd( + monkeypatch, "/home/someoneelse/x", env_type="tenki", config_cwd="/home/tenki") + assert cwd == "/home/tenki" diff --git a/tests/tools/test_docker_network_config.py b/tests/tools/test_docker_network_config.py index 776f5ef6d7c7..ced0c8c00a80 100644 --- a/tests/tools/test_docker_network_config.py +++ b/tests/tools/test_docker_network_config.py @@ -85,10 +85,15 @@ def test_docker_network_config_is_bridged_everywhere(): def test_sibling_container_config_sites_carry_docker_network(): - """Every container_config dict that carries docker_run_as_host_user must - also carry docker_network — otherwise that code path silently falls back - to networked containers while the terminal path honors the lockdown - (the probe/exec asymmetry reported on issue #46358). + """Every container_config construction site must carry docker_network — + otherwise that code path silently falls back to networked containers + while the terminal path honors the lockdown (the probe/exec asymmetry + reported on issue #46358). + + The sibling tool modules build their container_config through the shared + _container_config_from_env_config() helper, so a site is either an inline + dict (which must carry docker_network alongside docker_run_as_host_user) + or a call to that helper (whose output is asserted below). """ import ast import inspect @@ -96,20 +101,32 @@ def test_sibling_container_config_sites_carry_docker_network(): import tools.code_execution_tool as code_execution_tool import tools.file_tools as file_tools + assert terminal_tool._container_config_from_env_config({})["docker_network"] is True + assert ( + terminal_tool._container_config_from_env_config({"docker_network": False})[ + "docker_network" + ] + is False + ) + for module in (terminal_tool, file_tools, code_execution_tool): tree = ast.parse(inspect.getsource(module)) sites = 0 for node in ast.walk(tree): - if not isinstance(node, ast.Dict): - continue - keys = {k.value for k in node.keys if isinstance(k, ast.Constant)} - if "docker_run_as_host_user" in keys: - sites += 1 - assert "docker_network" in keys, ( - f"{module.__name__} builds a container_config with " - f"docker_run_as_host_user but without docker_network " - f"(line {node.lineno})" - ) + if isinstance(node, ast.Dict): + keys = {k.value for k in node.keys if isinstance(k, ast.Constant)} + if "docker_run_as_host_user" in keys: + sites += 1 + assert "docker_network" in keys, ( + f"{module.__name__} builds a container_config with " + f"docker_run_as_host_user but without docker_network " + f"(line {node.lineno})" + ) + elif isinstance(node, ast.Call): + func = node.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "") + if name == "_container_config_from_env_config": + sites += 1 assert sites >= 1, f"expected at least one container_config site in {module.__name__}" diff --git a/tests/tools/test_file_tools_container_config.py b/tests/tools/test_file_tools_container_config.py index f8a79a37e4ed..3a010504a51b 100644 --- a/tests/tools/test_file_tools_container_config.py +++ b/tests/tools/test_file_tools_container_config.py @@ -1,6 +1,8 @@ """Tests for docker container_config key propagation in file_tools.""" +import threading from unittest.mock import patch, MagicMock +import tools.code_execution_tool as code_execution_tool import tools.file_tools as file_tools @@ -21,6 +23,21 @@ def _make_env_config(**overrides): "docker_volumes": [], "docker_mount_cwd_to_workspace": True, "docker_forward_env": ["MY_SECRET", "API_KEY"], + "docker_env": {"A": "B"}, + "docker_extra_args": ["--shm-size=1g"], + "docker_persist_across_processes": False, + "docker_orphan_reaper": False, + "tenki_api_endpoint": "https://api.tenki.test", + "tenki_workspace_id": "ws-123", + "tenki_project_id": "prj-456", + "tenki_name_prefix": "agent", + "tenki_allow_inbound": True, + "tenki_allow_outbound": False, + "tenki_max_duration": 7200, + "tenki_idle_timeout": 600, + "tenki_pause_retention": 3600, + "tenki_sync_hermes_home": True, + "tenki_forward_env": ["GITHUB_TOKEN"], } base.update(overrides) return base @@ -73,6 +90,26 @@ def test_docker_forward_env_defaults_to_empty_list(self): cc = self._run(cfg, "t4").get("container_config", {}) assert cc.get("docker_forward_env") == [] + def test_shared_container_config_fields_are_forwarded(self): + """File tools use the same container-config builder as terminal execution.""" + cc = self._run(_make_env_config(), "t5").get("container_config", {}) + + assert cc.get("docker_env") == {"A": "B"} + assert cc.get("docker_extra_args") == ["--shm-size=1g"] + assert cc.get("docker_persist_across_processes") is False + assert cc.get("docker_orphan_reaper") is False + assert cc.get("tenki_name_prefix") == "agent" + assert cc.get("tenki_api_endpoint") == "https://api.tenki.test" + assert cc.get("tenki_workspace_id") == "ws-123" + assert cc.get("tenki_project_id") == "prj-456" + assert cc.get("tenki_allow_inbound") is True + assert cc.get("tenki_allow_outbound") is False + assert cc.get("tenki_max_duration") == 7200 + assert cc.get("tenki_idle_timeout") == 600 + assert cc.get("tenki_pause_retention") == 3600 + assert cc.get("tenki_sync_hermes_home") is True + assert cc.get("tenki_forward_env") == ["GITHUB_TOKEN"] + def test_cwd_only_raw_task_override_reaches_file_environment(self): """CWD-only task overrides collapse to default but must keep their cwd.""" captured = self._run( @@ -83,3 +120,49 @@ def test_cwd_only_raw_task_override_reaches_file_environment(self): assert captured["task_id"] == "default" assert captured["cwd"] == "/workspace/session" + + +class TestExecuteCodeContainerConfig: + def test_execute_code_uses_shared_container_config_for_tenki(self): + captured = {} + mock_env = MagicMock() + env_config = _make_env_config( + env_type="tenki", + tenki_image="tenki-image", + cwd="/home/tenki", + ) + + def fake_create_env(**kwargs): + captured.update(kwargs) + return mock_env + + with patch("tools.terminal_tool._get_env_config", return_value=env_config), \ + patch("tools.terminal_tool._task_env_overrides", {}), \ + patch("tools.terminal_tool._active_environments", {}), \ + patch("tools.terminal_tool._last_activity", {}), \ + patch("tools.terminal_tool._env_lock", threading.Lock()), \ + patch("tools.terminal_tool._creation_locks", {}), \ + patch("tools.terminal_tool._creation_locks_lock", threading.Lock()), \ + patch("tools.terminal_tool._create_environment", side_effect=fake_create_env), \ + patch("tools.terminal_tool._start_cleanup_thread"): + env, env_type = code_execution_tool._get_or_create_env("exec-tenki") + + assert env is mock_env + assert env_type == "tenki" + assert captured["env_type"] == "tenki" + assert captured["image"] == "tenki-image" + assert captured["cwd"] == "/home/tenki" + assert captured["task_id"] == "default" + cc = captured["container_config"] + assert cc["container_persistent"] is False + assert cc["tenki_api_endpoint"] == "https://api.tenki.test" + assert cc["tenki_workspace_id"] == "ws-123" + assert cc["tenki_project_id"] == "prj-456" + assert cc["tenki_name_prefix"] == "agent" + assert cc["tenki_allow_inbound"] is True + assert cc["tenki_allow_outbound"] is False + assert cc["tenki_max_duration"] == 7200 + assert cc["tenki_idle_timeout"] == 600 + assert cc["tenki_pause_retention"] == 3600 + assert cc["tenki_sync_hermes_home"] is True + assert cc["tenki_forward_env"] == ["GITHUB_TOKEN"] diff --git a/tests/tools/test_hardline_blocklist.py b/tests/tools/test_hardline_blocklist.py index 38f9d4d7a842..2c8cee7b4ab7 100644 --- a/tests/tools/test_hardline_blocklist.py +++ b/tests/tools/test_hardline_blocklist.py @@ -513,7 +513,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", "tenki"): 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) @@ -644,7 +644,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", "tenki"): 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}" diff --git a/tests/tools/test_local_env_blocklist.py b/tests/tools/test_local_env_blocklist.py index 2e8332470ae8..890bb04a2845 100644 --- a/tests/tools/test_local_env_blocklist.py +++ b/tests/tools/test_local_env_blocklist.py @@ -210,6 +210,8 @@ def test_tool_and_gateway_vars_are_stripped(self): "MODAL_TOKEN_ID": "modal-id", "MODAL_TOKEN_SECRET": "modal-secret", "DAYTONA_API_KEY": "daytona-key", + "TENKI_AUTH_TOKEN": "tenki-token", + "TENKI_API_KEY": "tenki-key", } result_env = _run_with_env(extra_os_env=leaked_vars) @@ -459,6 +461,8 @@ def test_gateway_runtime_vars_are_in_blocklist(self): "MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET", "DAYTONA_API_KEY", + "TENKI_AUTH_TOKEN", + "TENKI_API_KEY", } assert extras.issubset(_HERMES_PROVIDER_ENV_BLOCKLIST) diff --git a/tests/tools/test_modal_sandbox_fixes.py b/tests/tools/test_modal_sandbox_fixes.py index dddfe134edb6..d8ac891b2b2a 100644 --- a/tests/tools/test_modal_sandbox_fixes.py +++ b/tests/tools/test_modal_sandbox_fixes.py @@ -112,6 +112,12 @@ def test_default_cwd_is_root_for_container_backends(self, backend, monkeypatch): f"Backend {backend}: expected /root default, got {config['cwd']}" ) + def test_default_cwd_is_tenki_home_for_tenki(self, monkeypatch): + monkeypatch.setenv("TERMINAL_ENV", "tenki") + monkeypatch.delenv("TERMINAL_CWD", raising=False) + config = _tt_mod._get_env_config() + assert config["cwd"] == "/home/tenki" + def test_docker_default_cwd_maps_current_directory_when_enabled(self, monkeypatch): """Docker should use /workspace when cwd mounting is explicitly enabled.""" monkeypatch.setattr("tools.terminal_tool.os.getcwd", lambda: "/home/user/project") @@ -345,6 +351,7 @@ def test_should_skip_container_guards(self): assert A._should_skip_container_guards("modal", has_host_access=True) is True assert A._should_skip_container_guards("singularity") is True assert A._should_skip_container_guards("daytona") is True + assert A._should_skip_container_guards("tenki") is True assert A._should_skip_container_guards("local") is False def test_isolated_docker_keeps_fast_path(self, monkeypatch): diff --git a/tests/tools/test_parse_env_var.py b/tests/tools/test_parse_env_var.py index 8cbbce698582..e495217a3efb 100644 --- a/tests/tools/test_parse_env_var.py +++ b/tests/tools/test_parse_env_var.py @@ -39,6 +39,45 @@ def test_get_env_config_parses_docker_forward_env_json(self): config = _tt_mod._get_env_config() assert config["docker_forward_env"] == ["GITHUB_TOKEN", "NPM_TOKEN"] + def test_get_env_config_parses_tenki_forward_env_json(self): + with patch.dict("os.environ", { + "TERMINAL_ENV": "tenki", + "TERMINAL_TENKI_FORWARD_ENV": '["GITHUB_TOKEN", "GH_TOKEN"]', + }, clear=False): + config = _tt_mod._get_env_config() + assert config["tenki_forward_env"] == ["GITHUB_TOKEN", "GH_TOKEN"] + + def test_get_env_config_parses_tenki_numeric_settings(self): + with patch.dict("os.environ", { + "TERMINAL_ENV": "tenki", + "TERMINAL_TENKI_MAX_DURATION": "7200", + "TERMINAL_TENKI_IDLE_TIMEOUT": "600", + "TERMINAL_TENKI_PAUSE_RETENTION": "300", + }, clear=False): + config = _tt_mod._get_env_config() + assert config["tenki_max_duration"] == 7200 + assert config["tenki_idle_timeout"] == 600 + assert config["tenki_pause_retention"] == 300 + + def test_stale_invalid_tenki_value_does_not_break_local_backend(self): + # A stale/invalid tenki value bridged from config.yaml must not abort + # _get_env_config() for a local session that never uses tenki. + with patch.dict("os.environ", { + "TERMINAL_ENV": "local", + "TERMINAL_TENKI_MAX_DURATION": "not-a-number", + }, clear=False): + config = _tt_mod._get_env_config() + assert config["env_type"] == "local" + assert config["tenki_max_duration"] == 3600 + + def test_invalid_tenki_value_raises_when_tenki_backend_active(self): + with patch.dict("os.environ", { + "TERMINAL_ENV": "tenki", + "TERMINAL_TENKI_MAX_DURATION": "not-a-number", + }, clear=False): + with pytest.raises(ValueError, match="TERMINAL_TENKI_MAX_DURATION"): + _tt_mod._get_env_config() + def test_create_environment_passes_docker_forward_env(self): fake_env = object() with patch.object(_tt_mod, "_DockerEnvironment", return_value=fake_env) as mock_docker: @@ -53,6 +92,22 @@ def test_create_environment_passes_docker_forward_env(self): assert result is fake_env assert mock_docker.call_args.kwargs["forward_env"] == ["GITHUB_TOKEN"] + def test_create_environment_passes_tenki_forward_env(self): + import tools.environments.tenki as tenki_module + + fake_env = object() + with patch.object(tenki_module, "TenkiEnvironment", return_value=fake_env) as mock_tenki: + result = _tt_mod._create_environment( + "tenki", + image="", + cwd="/home/tenki", + timeout=180, + container_config={"tenki_forward_env": ["GITHUB_TOKEN", "GH_TOKEN"]}, + ) + + assert result is fake_env + assert mock_tenki.call_args.kwargs["forward_env"] == ["GITHUB_TOKEN", "GH_TOKEN"] + def test_falls_back_to_default(self): with patch.dict("os.environ", {}, clear=False): # Remove the var if it exists, rely on default diff --git a/tests/tools/test_skills_tool.py b/tests/tools/test_skills_tool.py index a7445207c711..731f89e28010 100644 --- a/tests/tools/test_skills_tool.py +++ b/tests/tools/test_skills_tool.py @@ -978,7 +978,7 @@ def test_local_env_missing_keeps_setup_needed(self, tmp_path, monkeypatch): @pytest.mark.parametrize( "backend", - ["ssh", "daytona", "docker", "singularity", "modal"], + ["ssh", "daytona", "tenki", "docker", "singularity", "modal"], ) def test_remote_backend_becomes_available_after_local_secret_capture( self, tmp_path, monkeypatch, backend diff --git a/tests/tools/test_tenki_environment.py b/tests/tools/test_tenki_environment.py new file mode 100644 index 000000000000..6f998a2af879 --- /dev/null +++ b/tests/tools/test_tenki_environment.py @@ -0,0 +1,1338 @@ +from __future__ import annotations + +import sys +import threading +import time +import types +from types import SimpleNamespace + +import pytest + + +class _FakeFS: + def __init__(self): + self.mkdir_calls: list[tuple[tuple, dict]] = [] + self.upload_calls: list[tuple[str, str]] = [] + self.download_calls: list[tuple[str, str]] = [] + + @staticmethod + def _assert_remote_path(path: str) -> None: + if path.startswith("/") and path != "/home/tenki" and not path.startswith("/home/tenki/"): + raise AssertionError(f"Tenki fs path must be under /home/tenki, got {path!r}") + + def mkdir(self, path, **kwargs): + self._assert_remote_path(str(path)) + self.mkdir_calls.append(((path,), kwargs)) + + def upload(self, local_path, remote_path, **_kwargs): + self._assert_remote_path(str(remote_path)) + self.upload_calls.append((str(local_path), str(remote_path))) + + def download(self, remote_path, local_path, **_kwargs): + self._assert_remote_path(str(remote_path)) + self.download_calls.append((str(remote_path), str(local_path))) + + +class _FakeResult: + def __init__(self, stdout: str = "", stderr: str = "", exit_code: int = 0): + self.stdout_text = stdout + self.stderr_text = stderr + self.exit_code = exit_code + + +class _FakeProcess: + def __init__( + self, + result: _FakeResult, + *, + stdin_data: str | None = None, + block_until_killed: bool = False, + ): + self._result = result + self.stdin_data = stdin_data + self.closed_stdin = False + self.killed = False + self._block_until_killed = block_until_killed + self._done = threading.Event() + + def close_stdin(self): + self.closed_stdin = True + + def kill(self): + self.killed = True + self._done.set() + + def wait(self, *_args, **_kwargs): + if self._block_until_killed: + self._done.wait(timeout=5) + return _FakeResult(stdout="", exit_code=143) + return self._result + + +class _FakeSandbox: + def __init__( + self, + *, + name: str = "sb-test", + state: str = "RUNNING", + metadata: dict | None = None, + ): + self.exec_calls: list[tuple[tuple, dict]] = [] + self.start_calls: list[tuple[tuple, dict]] = [] + self.last_process: _FakeProcess | None = None + self.snapshots: list[tuple[str | None, bool]] = [] + self.terminated = False + self.paused = False + self.resumed = False + self.waited = False + self.refreshed = False + self.id = "sb-test" + self.name = name + self.state = state + self.info = SimpleNamespace(name=name, metadata=metadata or {}) + self.fs = _FakeFS() + + @staticmethod + def _result_for_command(args): + command = args[-1] if args else "" + if "echo \"$HOME\"" in command: + return _FakeResult(stdout="/home/tenki\n") + return _FakeResult(stdout="ran\n", exit_code=0) + + def exec(self, *args, **kwargs): + self.exec_calls.append((args, kwargs)) + return self._result_for_command(args) + + def start(self, *args, **kwargs): + self.start_calls.append((args, kwargs)) + command = args[-1] if args else "" + self.last_process = _FakeProcess( + self._result_for_command(args), + stdin_data=kwargs.get("stdin"), + block_until_killed="sleep infinity" in command, + ) + return self.last_process + + def refresh(self): + self.refreshed = True + return self.info + + def terminate(self): + self.terminated = True + self.state = "TERMINATED" + + def pause(self): + self.paused = True + self.state = "PAUSED" + + def resume(self): + self.resumed = True + self.state = "RUNNING" + + def wait_ready(self, *_args, **_kwargs): + self.waited = True + + def snapshot(self, *, name=None, wait=True): + self.snapshots.append((name, wait)) + return SimpleNamespace(id=f"snap-{self.name}") + + +def _last_started_command(sandbox: _FakeSandbox) -> str: + return sandbox.start_calls[-1][0][-1] + + +class _FakeSnapshotNotFoundError(Exception): + """Mirrors tenki_sandbox.SnapshotNotFoundError for the fake SDK.""" + + +class _FakeRegistryArtifactNotFoundError(Exception): + """Mirrors tenki_sandbox.RegistryArtifactNotFoundError for the fake SDK.""" + + +class _FakeSnapshotNotDurableError(Exception): + """Mirrors tenki_sandbox.SnapshotNotDurableError for the fake SDK.""" + + +class _FakeInvalidStateError(Exception): + """Mirrors tenki_sandbox.InvalidStateError for the fake SDK.""" + + +class _FakeSandboxFactory: + created_kwargs: list[dict] = [] + failed_kwargs: list[dict] = [] + sandboxes: list[_FakeSandbox] = [] + fail_snapshot_ids: set[str] = set() + # When a snapshot id is in fail_snapshot_ids, raise this exception type with + # this message. Defaults to the confirmed-not-found error; tests set them to + # a transient error / generic message to prove the pointer is preserved, or + # to a snapshot-specific InvalidStateError to prove base-image fallback. + snapshot_error: type[Exception] = _FakeSnapshotNotFoundError + snapshot_error_msg: str = "restore failed" + + @classmethod + def create(cls, **kwargs): + if kwargs.get("snapshot_id") in cls.fail_snapshot_ids: + cls.failed_kwargs.append(kwargs) + raise cls.snapshot_error(cls.snapshot_error_msg) + sandbox = _FakeSandbox( + name=kwargs.get("name", "sb-test"), + metadata=kwargs.get("metadata", {}), + ) + cls.created_kwargs.append(kwargs) + cls.sandboxes.append(sandbox) + return sandbox + + +class _FakeClient: + listed_sandboxes: list[_FakeSandbox] = [] + closed_count = 0 + + def __init__(self, **kwargs): + self.kwargs = kwargs + self.snapshots = SimpleNamespace(wait_durable=lambda *_args, **_kwargs: None) + + def create(self, **kwargs): + return _FakeSandboxFactory.create(**kwargs) + + def list(self, **_kwargs): + return list(self.listed_sandboxes) + + def list_project(self, *_args, **_kwargs): + return list(self.listed_sandboxes) + + def list_workspace(self, *_args, **_kwargs): + return list(self.listed_sandboxes) + + def close(self): + type(self).closed_count += 1 + + +def _install_fake_tenki(monkeypatch): + module = types.ModuleType("tenki_sandbox") + _FakeSandboxFactory.created_kwargs = [] + _FakeSandboxFactory.failed_kwargs = [] + _FakeSandboxFactory.sandboxes = [] + _FakeSandboxFactory.fail_snapshot_ids = set() + _FakeSandboxFactory.snapshot_error = _FakeSnapshotNotFoundError + _FakeSandboxFactory.snapshot_error_msg = "restore failed" + _FakeClient.listed_sandboxes = [] + _FakeClient.closed_count = 0 + module.Client = _FakeClient + module.Sandbox = _FakeSandboxFactory + module.SnapshotNotFoundError = _FakeSnapshotNotFoundError + module.RegistryArtifactNotFoundError = _FakeRegistryArtifactNotFoundError + module.SnapshotNotDurableError = _FakeSnapshotNotDurableError + module.InvalidStateError = _FakeInvalidStateError + monkeypatch.setitem(sys.modules, "tenki_sandbox", module) + + +def _clear_tenki_auth_env(monkeypatch): + monkeypatch.delenv("TENKI_AUTH_TOKEN", raising=False) + monkeypatch.delenv("TENKI_API_KEY", raising=False) + + +def _clear_env_passthrough_cache(): + try: + import tools.env_passthrough as env_passthrough + + env_passthrough.clear_env_passthrough() + env_passthrough._config_passthrough = None + except Exception: + pass + + +def test_tenki_cli_auth_token_is_normalized_for_sdk_cookie_auth(monkeypatch, tmp_path): + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: cli-cookie\n", encoding="utf-8") + + from tools.tenki_config import resolve_tenki_auth_token + + assert resolve_tenki_auth_token() == "cookie:cli-cookie" + + +def test_tenki_cli_auth_token_preserves_sdk_prefixes(monkeypatch, tmp_path): + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + + from tools.tenki_config import resolve_tenki_auth_token + + for token in ("cookie:cli-cookie", "ory_st_session", "sk-api-key"): + (tmp_path / "config.yaml").write_text(f"auth_token: {token}\n", encoding="utf-8") + assert resolve_tenki_auth_token() == token + + +def test_tenki_cli_api_key_is_not_treated_as_cookie(monkeypatch, tmp_path): + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("api_key: provider-key\n", encoding="utf-8") + + from tools.tenki_config import resolve_tenki_auth_token + + assert resolve_tenki_auth_token() == "provider-key" + + +def test_tenki_environment_uses_cli_config_and_terminates_by_default(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text( + "\n".join( + [ + "api_endpoint: https://api.tenki.test", + "current_workspace_id: ws-123", + "current_project_id: prj-456", + "auth_token: tok-secret", + ] + ), + encoding="utf-8", + ) + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + + env = TenkiEnvironment( + image="", + task_id="session 1", + persistent_filesystem=False, + allow_inbound=False, + allow_outbound=True, + ) + + kwargs = _FakeSandboxFactory.created_kwargs[0] + # The control-plane credential is used host-side to create the sandbox... + assert kwargs["base_url"] == "https://api.tenki.test" + assert kwargs["workspace_id"] == "ws-123" + assert kwargs["project_id"] == "prj-456" + assert kwargs["auth_token"] == "cookie:tok-secret" + # ...but is NEVER injected into the model-controlled guest environment + # (an empty env is omitted from the create kwargs entirely). + guest_env = kwargs.get("env", {}) + assert "TENKI_AUTH_TOKEN" not in guest_env + assert "TENKI_API_KEY" not in guest_env + assert "TENKI_API_ENDPOINT" not in guest_env + assert "TENKI_WORKSPACE_ID" not in guest_env + assert "TENKI_PROJECT_ID" not in guest_env + assert kwargs["allow_inbound"] is False + assert kwargs["allow_outbound"] is True + assert kwargs["cpu_cores"] == 1 + assert "idle_timeout" not in kwargs + assert "idle_timeout_minutes" not in kwargs + assert "pause_retention" not in kwargs + assert kwargs["metadata"]["hermes_backend"] == "tenki" + assert kwargs["metadata"]["hermes_profile"] + assert kwargs["name"].startswith("hermes-") + assert kwargs["name"].endswith("session-1") + + output, exit_code = env._exec_raw("echo ok", timeout=5) + assert output == "ran\n" + assert exit_code == 0 + + sandbox = _FakeSandboxFactory.sandboxes[0] + assert "TENKI_AUTH_TOKEN" not in sandbox.exec_calls[-1][1]["env"] + env.cleanup() + assert sandbox.terminated is True + assert sandbox.paused is False + + +def test_tenki_environment_does_not_inject_control_plane_token_by_default(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + _clear_env_passthrough_cache() + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "missing.yaml")) + monkeypatch.setenv("TENKI_API_KEY", "sk-test-key") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="api-key") + + kwargs = _FakeSandboxFactory.created_kwargs[0] + # Host-side create still authenticates with the credential... + assert kwargs["auth_token"] == "sk-test-key" + # ...but the guest never receives it unless explicitly forwarded. + guest_env = kwargs.get("env", {}) + assert "TENKI_AUTH_TOKEN" not in guest_env + assert "TENKI_API_KEY" not in guest_env + env.cleanup() + _clear_env_passthrough_cache() + + +def test_tenki_environment_forwards_control_plane_token_only_when_opted_in(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + _clear_env_passthrough_cache() + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "missing.yaml")) + monkeypatch.setenv("TENKI_API_KEY", "sk-test-key") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + # Nested-sandbox support: the operator explicitly forwards the credential. + env = TenkiEnvironment(task_id="api-key", forward_env=["TENKI_API_KEY"]) + + kwargs = _FakeSandboxFactory.created_kwargs[0] + assert kwargs["env"]["TENKI_API_KEY"] == "sk-test-key" + env.cleanup() + _clear_env_passthrough_cache() + + +def test_tenki_environment_honors_tenki_forward_env_from_process_env(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + _clear_env_passthrough_cache() + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + monkeypatch.setenv("GH_TOKEN", "gho-process") + (tmp_path / "config.yaml").write_text( + "auth_token: tok-secret\n", + encoding="utf-8", + ) + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="gh-token", forward_env=["GH_TOKEN"]) + + assert _FakeSandboxFactory.created_kwargs[0]["env"]["GH_TOKEN"] == "gho-process" + env.execute("echo ok", timeout=5) + assert env._sandbox.start_calls[-1][1]["env"]["GH_TOKEN"] == "gho-process" + env.cleanup() + _clear_env_passthrough_cache() + + +def test_tenki_environment_honors_tenki_forward_env_from_hermes_dotenv(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + _clear_env_passthrough_cache() + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + (tmp_path / ".env").write_text("GITHUB_TOKEN=ghp-dotenv\n", encoding="utf-8") + (tmp_path / "config.yaml").write_text( + "auth_token: tok-secret\n", + encoding="utf-8", + ) + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="github-token", forward_env=["GITHUB_TOKEN"]) + + assert _FakeSandboxFactory.created_kwargs[0]["env"]["GITHUB_TOKEN"] == "ghp-dotenv" + env.execute("echo ok", timeout=5) + assert env._sandbox.start_calls[-1][1]["env"]["GITHUB_TOKEN"] == "ghp-dotenv" + env.cleanup() + _clear_env_passthrough_cache() + + +def test_tenki_forwarded_env_prefers_profile_scope_over_process_env(monkeypatch, tmp_path): + """Under a multiplexed profile scope, a forwarded credential must resolve + to the active profile's value, never another profile's raw os.environ.""" + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + _clear_env_passthrough_cache() + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + # Another profile's value leaking through the process environment... + monkeypatch.setenv("GH_TOKEN", "gho-other-profile") + + from agent import secret_scope + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + token = secret_scope.set_secret_scope({"GH_TOKEN": "gho-this-profile"}) + try: + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="scoped", forward_env=["GH_TOKEN"]) + # ...must be overridden by the active profile scope. + assert _FakeSandboxFactory.created_kwargs[0]["env"]["GH_TOKEN"] == "gho-this-profile" + env.cleanup() + finally: + secret_scope.reset_secret_scope(token) + _clear_env_passthrough_cache() + + +def test_tenki_auth_token_prefers_profile_scope_over_process_env(monkeypatch, tmp_path): + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "missing.yaml")) + monkeypatch.setenv("TENKI_AUTH_TOKEN", "tok-other-profile") + + from agent import secret_scope + from tools.tenki_config import resolve_tenki_auth_token + + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + tok = secret_scope.set_secret_scope({"TENKI_AUTH_TOKEN": "tok-this-profile"}) + try: + assert resolve_tenki_auth_token() == "tok-this-profile" + finally: + secret_scope.reset_secret_scope(tok) + + +def test_tenki_auth_token_fails_closed_when_multiplex_active_and_unscoped(monkeypatch, tmp_path): + """Multiplex on + no scope installed: an os.environ token must NOT leak + through (fail closed), rather than serving another profile's value.""" + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "missing.yaml")) + monkeypatch.setenv("TENKI_AUTH_TOKEN", "tok-leaked-from-process-env") + + from agent import secret_scope + from tools.tenki_config import resolve_tenki_auth_token + + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + # No set_secret_scope() — this is the fail-closed branch. + assert secret_scope.current_secret_scope() is None + assert resolve_tenki_auth_token() == "" + + +def test_tenki_forwarded_env_fails_closed_when_multiplex_active_and_unscoped(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + _clear_env_passthrough_cache() + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + monkeypatch.setenv("GH_TOKEN", "gho-leaked-from-process-env") + + from agent import secret_scope + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(secret_scope, "_MULTIPLEX_ACTIVE", True) + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="scoped", forward_env=["GH_TOKEN"]) + + # No scope installed while multiplexing → the process-env value is not leaked. + assert "GH_TOKEN" not in _FakeSandboxFactory.created_kwargs[0].get("env", {}) + env.cleanup() + _clear_env_passthrough_cache() + + +def test_tenki_control_plane_token_forwarded_from_cli_config_when_opted_in(monkeypatch, tmp_path): + """The opt-in must work even when auth came from `tenki login` (CLI config), + whose secret never lands in os.environ.""" + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + _clear_env_passthrough_cache() + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + # Credential lives ONLY in the Tenki CLI config, not the environment. + (tmp_path / "config.yaml").write_text("auth_token: tok-cli-login\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="nested", forward_env=["TENKI_AUTH_TOKEN"]) + + guest_env = _FakeSandboxFactory.created_kwargs[0].get("env", {}) + assert guest_env["TENKI_AUTH_TOKEN"] == "cookie:tok-cli-login" + env.cleanup() + _clear_env_passthrough_cache() + + +def test_tenki_environment_honors_safe_env_passthrough(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + _clear_env_passthrough_cache() + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + monkeypatch.setenv("CUSTOM_TASK_ENV", "task-value") + (tmp_path / "config.yaml").write_text( + "auth_token: tok-secret\n" + "terminal:\n" + " env_passthrough:\n" + " - CUSTOM_TASK_ENV\n", + encoding="utf-8", + ) + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="safe-passthrough") + + assert _FakeSandboxFactory.created_kwargs[0]["env"]["CUSTOM_TASK_ENV"] == "task-value" + env.execute("echo ok", timeout=5) + assert env._sandbox.start_calls[-1][1]["env"]["CUSTOM_TASK_ENV"] == "task-value" + env.cleanup() + _clear_env_passthrough_cache() + + +def test_tenki_environment_snapshots_when_persistent(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="persist", persistent_filesystem=True) + + sandbox = _FakeSandboxFactory.sandboxes[0] + env.cleanup() + assert len(sandbox.snapshots) == 1 + snap_name, snap_wait = sandbox.snapshots[0] + assert snap_name.endswith("persist") and snap_wait is True + assert sandbox.paused is False + assert sandbox.terminated is True + + env = TenkiEnvironment(task_id="persist", image="base-image", persistent_filesystem=True) + assert _FakeSandboxFactory.created_kwargs[-1]["snapshot_id"].endswith("persist") + assert "image" not in _FakeSandboxFactory.created_kwargs[-1] + env.cleanup() + + +def test_tenki_environment_falls_back_when_persistent_snapshot_is_stale(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments import tenki as tenki_module + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + tenki_module._store_snapshot("persist", "snap-stale") + _FakeSandboxFactory.fail_snapshot_ids = {"snap-stale"} + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + + env = TenkiEnvironment(task_id="persist", image="base-image", persistent_filesystem=True) + + assert _FakeSandboxFactory.failed_kwargs[0]["snapshot_id"] == "snap-stale" + assert _FakeSandboxFactory.created_kwargs[0]["image"] == "base-image" + assert tenki_module._get_snapshot_restore_candidate("persist") == (None, False) + env.cleanup() + + +def test_tenki_environment_preserves_snapshot_on_transient_restore_error(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments import tenki as tenki_module + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + tenki_module._store_snapshot("persist", "snap-transient") + _FakeSandboxFactory.fail_snapshot_ids = {"snap-transient"} + # A transient failure (not a confirmed not-found) must NOT boot a blank + # base image or drop the recovery pointer. + _FakeSandboxFactory.snapshot_error = RuntimeError + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + + with pytest.raises(RuntimeError): + TenkiEnvironment(task_id="persist", image="base-image", persistent_filesystem=True) + + # No base-image fallback happened, and the snapshot pointer is retained. + assert _FakeSandboxFactory.created_kwargs == [] + assert tenki_module._get_snapshot_restore_candidate("persist") == ("snap-transient", False) + + +def test_tenki_environment_skips_snapshot_when_not_durable(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments import tenki as tenki_module + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + def _fail_durable(*_args, **_kwargs): + raise RuntimeError("not durable yet") + + def _init(self, **kw): + self.kwargs = kw + self.snapshots = SimpleNamespace(wait_durable=_fail_durable) + + monkeypatch.setattr(_FakeClient, "__init__", _init) + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="persist", persistent_filesystem=True) + sandbox = _FakeSandboxFactory.sandboxes[0] + + env.cleanup() + + # Durability failed → do NOT record the snapshot and do NOT terminate the + # live sandbox; pause it so state is preserved for recovery. + assert sandbox.paused is True + assert sandbox.terminated is False + assert tenki_module._get_snapshot_restore_candidate("persist") == (None, False) + + +def test_tenki_environment_resumes_existing_persistent_sandbox(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments import tenki as tenki_module + from tools.environments.tenki import TenkiEnvironment + + token = tenki_module._profile_token() + existing = _FakeSandbox( + name=f"hermes-{token}-persist", + state="PAUSED", + metadata={"hermes_task_id": "persist", "hermes_profile": token}, + ) + _FakeClient.listed_sandboxes = [existing] + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="persist", persistent_filesystem=True) + + assert env._sandbox is existing + assert existing.resumed is True + assert existing.waited is True + assert _FakeSandboxFactory.created_kwargs == [] + # The resumed guest never receives the control-plane credential either. + assert "TENKI_AUTH_TOKEN" not in existing.exec_calls[-1][1]["env"] + env.cleanup() + + +def test_tenki_environment_does_not_reuse_other_profiles_sandbox(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + # A live sandbox on the same Tenki account belonging to a DIFFERENT profile + # (foreign token) with the same task id must never be resumed. + foreign = _FakeSandbox( + name="hermes-deadbeef00-persist", + state="PAUSED", + metadata={"hermes_task_id": "persist", "hermes_profile": "deadbeef00"}, + ) + _FakeClient.listed_sandboxes = [foreign] + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="persist", persistent_filesystem=True) + + assert env._sandbox is not foreign + assert foreign.resumed is False + assert _FakeSandboxFactory.created_kwargs, "should create its own sandbox" + env.cleanup() + + +def test_tenki_reuse_rejects_name_match_with_foreign_profile_metadata(monkeypatch, tmp_path): + """Defense-in-depth: even if a candidate's NAME matches, a differing + hermes_profile in metadata must block reuse.""" + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments import tenki as tenki_module + from tools.environments.tenki import TenkiEnvironment + + token = tenki_module._profile_token() + # Same name (as if a token collision), but metadata says a different profile. + collider = _FakeSandbox( + name=f"hermes-{token}-persist", + state="PAUSED", + metadata={"hermes_task_id": "persist", "hermes_profile": "foreign-token"}, + ) + _FakeClient.listed_sandboxes = [collider] + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="persist", persistent_filesystem=True) + + assert env._sandbox is not collider + assert collider.resumed is False + env.cleanup() + + +def test_tenki_restore_falls_back_on_nondurable_snapshot(monkeypatch, tmp_path): + """A snapshot that EXISTS but is permanently unusable (non-durable) must + drop the pointer and boot the base image, not wedge the task forever.""" + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments import tenki as tenki_module + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + tenki_module._store_snapshot("persist", "snap-nondurable") + _FakeSandboxFactory.fail_snapshot_ids = {"snap-nondurable"} + _FakeSandboxFactory.snapshot_error = _FakeSnapshotNotDurableError + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + + env = TenkiEnvironment(task_id="persist", image="base-image", persistent_filesystem=True) + + assert _FakeSandboxFactory.failed_kwargs[0]["snapshot_id"] == "snap-nondurable" + assert _FakeSandboxFactory.created_kwargs[0]["image"] == "base-image" + assert tenki_module._get_snapshot_restore_candidate("persist") == (None, False) + env.cleanup() + + +def test_tenki_restore_preserves_pointer_on_invalid_state_error(monkeypatch, tmp_path): + """InvalidStateError is a generic precondition failure, NOT snapshot-gone, + so it must be treated as transient: preserve the pointer, do not base-boot.""" + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments import tenki as tenki_module + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + tenki_module._store_snapshot("persist", "snap-invalidstate") + _FakeSandboxFactory.fail_snapshot_ids = {"snap-invalidstate"} + _FakeSandboxFactory.snapshot_error = _FakeInvalidStateError + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + + with pytest.raises(_FakeInvalidStateError): + TenkiEnvironment(task_id="persist", image="base-image", persistent_filesystem=True) + + assert _FakeSandboxFactory.created_kwargs == [] + assert tenki_module._get_snapshot_restore_candidate("persist") == ("snap-invalidstate", False) + + +def test_tenki_restore_falls_back_on_snapshot_specific_invalid_state(monkeypatch, tmp_path): + """A generic InvalidStateError whose message identifies the snapshot (the + SDK's collapsed representation of a bad/non-durable snapshot on restore) + IS unrecoverable → drop the pointer and boot the base image.""" + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments import tenki as tenki_module + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + tenki_module._store_snapshot("persist", "snap-badstate") + _FakeSandboxFactory.fail_snapshot_ids = {"snap-badstate"} + _FakeSandboxFactory.snapshot_error = _FakeInvalidStateError + _FakeSandboxFactory.snapshot_error_msg = "snapshot is not durable" + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + + env = TenkiEnvironment(task_id="persist", image="base-image", persistent_filesystem=True) + + assert _FakeSandboxFactory.created_kwargs[0]["image"] == "base-image" + assert tenki_module._get_snapshot_restore_candidate("persist") == (None, False) + env.cleanup() + + +def test_tenki_snapshot_store_bound_to_construction_profile(monkeypatch, tmp_path): + """Cleanup (which may run in a background thread without the per-turn + HERMES_HOME contextvar) must write the snapshot pointer to the profile that + was active at construction, not whatever home is ambient at cleanup time.""" + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments import tenki as tenki_module + from tools.environments.tenki import TenkiEnvironment + + home_a = tmp_path / "profiles" / "a" + home_b = tmp_path / "profiles" / "b" + home_a.mkdir(parents=True) + home_b.mkdir(parents=True) + + monkeypatch.setenv("HERMES_HOME", str(home_a)) + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="persist", persistent_filesystem=True) + + # Simulate a background cleanup running under the WRONG ambient home. + monkeypatch.setenv("HERMES_HOME", str(home_b)) + env.cleanup() + + # Pointer landed in profile A's store (construction-time), not B's. + assert (home_a / "tenki_snapshots.json").exists() + assert not (home_b / "tenki_snapshots.json").exists() + + +def test_tenki_persistent_not_terminated_when_snapshot_and_pause_both_fail(monkeypatch, tmp_path): + """Durability failed AND pause failed: the sandbox must be left live (not + terminated), so the only copy of un-snapshotted state is preserved.""" + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + def _fail_durable(*_args, **_kwargs): + raise RuntimeError("not durable") + + def _init(self, **kw): + self.kwargs = kw + self.snapshots = SimpleNamespace(wait_durable=_fail_durable) + + monkeypatch.setattr(_FakeClient, "__init__", _init) + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="persist", persistent_filesystem=True) + sandbox = _FakeSandboxFactory.sandboxes[0] + + def _fail_pause(): + raise RuntimeError("pause unavailable") + + sandbox.pause = _fail_pause + + env.cleanup() + + # Neither snapshot durable nor pause succeeded → sandbox left live. + assert sandbox.terminated is False + + +def test_tenki_environment_resumes_paused_cached_sandbox_before_execute(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="paused-cache") + sandbox = env._sandbox + sandbox.state = "PAUSED" + + env.execute("echo ok", timeout=5) + + assert sandbox.refreshed is True + assert sandbox.resumed is True + assert sandbox.waited is True + assert env._sandbox is sandbox + env.cleanup() + + +def test_tenki_environment_recreates_terminated_cached_sandbox(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="terminated-cache") + first = env._sandbox + first.state = "TERMINATED" + + env.execute("echo ok", timeout=5) + + assert len(_FakeSandboxFactory.sandboxes) == 2 + assert env._sandbox is _FakeSandboxFactory.sandboxes[1] + assert env._sandbox is not first + env.cleanup() + + +def test_tenki_environment_ignores_mismatched_persistent_sandbox(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + _FakeClient.listed_sandboxes = [ + _FakeSandbox( + name="hermes-other", + state="PAUSED", + metadata={"hermes_task_id": "other"}, + ) + ] + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="persist", persistent_filesystem=True) + + assert _FakeSandboxFactory.created_kwargs + assert _FakeSandboxFactory.created_kwargs[0]["name"].endswith("persist") + env.cleanup() + + +def test_tenki_environment_converts_idle_timeout_to_sdk_minutes(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="idle", cpu=1.2, idle_timeout=61) + + kwargs = _FakeSandboxFactory.created_kwargs[0] + assert kwargs["cpu_cores"] == 2 + assert kwargs["idle_timeout_minutes"] == 2 + env.cleanup() + + +def test_tenki_environment_omits_non_positive_pause_retention(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + + env = TenkiEnvironment(task_id="pause-default", pause_retention=0) + kwargs = _FakeSandboxFactory.created_kwargs[0] + assert "pause_retention" not in kwargs + env.cleanup() + + env = TenkiEnvironment(task_id="pause-negative", pause_retention=-1) + kwargs = _FakeSandboxFactory.created_kwargs[1] + assert "pause_retention" not in kwargs + env.cleanup() + + +def test_tenki_environment_passes_positive_pause_retention(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="pause-positive", pause_retention=3600) + + kwargs = _FakeSandboxFactory.created_kwargs[0] + assert kwargs["pause_retention"] == 3600 + env.cleanup() + + +def test_tenki_sync_hermes_home_is_opt_in(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments import tenki as tenki_module + from tools.environments.tenki import TenkiEnvironment + + calls = [] + + class FakeSyncManager: + def __init__(self, **kwargs): + calls.append(("init", kwargs)) + + def sync(self, *, force=False): + calls.append(("sync", force)) + + def sync_back(self): + calls.append(("sync_back", None)) + + monkeypatch.setattr(tenki_module, "FileSyncManager", FakeSyncManager) + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + + env = TenkiEnvironment(task_id="no-sync", sync_hermes_home=False) + assert calls == [] + env.cleanup() + + env = TenkiEnvironment(task_id="sync", sync_hermes_home=True) + assert calls[0][0] == "init" + assert calls[1] == ("sync", True) + env.cleanup() + assert ("sync_back", None) in calls + + +def test_tenki_bulk_sync_stages_tar_under_home_not_tmp(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="bulk-sync") + host_file = tmp_path / "skill.md" + host_file.write_text("content", encoding="utf-8") + + env._tenki_bulk_upload([(str(host_file), "/home/tenki/.hermes/skills/skill.md")]) + + remote_tar = env._sandbox.fs.upload_calls[-1][1] + assert remote_tar.startswith("/home/tenki/.hermes_tenki_sync.") + assert not remote_tar.startswith("/tmp/") + env.cleanup() + + +def test_tenki_bulk_sync_uses_documented_fs_root_when_home_differs(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="root-home") + env._remote_home = "/root" + + assert env._remote_transfer_path(".hermes_tenki_sync").startswith("/home/tenki/") + env.cleanup() + + +def test_tenki_cleanup_sync_back_uses_original_sandbox(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="cleanup-sync") + original = env._sandbox + created_before = len(_FakeSandboxFactory.created_kwargs) + + class FakeSyncManager: + def sync_back(self): + env._tenki_bulk_download(tmp_path / "sync-back.tar") + + env._sync_manager = FakeSyncManager() + env.cleanup() + + assert len(_FakeSandboxFactory.created_kwargs) == created_before + assert original.fs.download_calls + remote_tar = original.fs.download_calls[-1][0] + assert remote_tar.startswith("/home/tenki/.hermes_tenki_sync_back.") + assert original.terminated is True + + +def test_tenki_cleanup_blocks_public_execution_while_syncing(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="cleanup-guard") + created_before = len(_FakeSandboxFactory.created_kwargs) + + with env._lock: + env._cleanup_in_progress = True + env._cleanup_sandbox = env._sandbox + try: + try: + env.execute("echo should-not-run", timeout=5) + except RuntimeError as exc: + assert "cleanup" in str(exc) + else: + raise AssertionError("execute should fail while cleanup is in progress") + assert len(_FakeSandboxFactory.created_kwargs) == created_before + finally: + with env._lock: + env._cleanup_in_progress = False + env._cleanup_sandbox = None + env.cleanup() + + +def test_tenki_execute_passes_stdin_natively_not_as_heredoc(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="stdin") + large_stdin = "x" * 200_000 + + env.execute("cat > /home/tenki/out.txt", stdin_data=large_stdin, timeout=5) + + sandbox = env._sandbox + command = _last_started_command(sandbox) + assert large_stdin not in command + assert "HERMES_STDIN_" not in command + assert sandbox.start_calls[-1][1]["stdin"] == large_stdin + assert "TENKI_AUTH_TOKEN" not in sandbox.start_calls[-1][1]["env"] + env.cleanup() + + +def test_tenki_cancel_kills_process_without_tearing_down_sandbox(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="cancel-process") + sandbox = env._sandbox + handle = env._run_bash("sleep infinity", timeout=30) + for _ in range(100): + if sandbox.last_process is not None: + break + time.sleep(0.01) + + handle.kill() + handle.wait(timeout=1) + + assert sandbox.last_process is not None + assert sandbox.last_process.killed is True + assert sandbox.terminated is False + assert sandbox.paused is False + env.cleanup() + + +def test_tenki_non_sudo_command_does_not_probe_sudo(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + + def fail_probe(self): + raise AssertionError("sudo should not be probed for commands without sudo") + + monkeypatch.setattr(TenkiEnvironment, "_sudo_nopasswd_works", fail_probe) + + env = TenkiEnvironment(task_id="no-sudo") + env.execute("echo ok", timeout=5) + env.cleanup() + + +def test_tenki_passwordless_sudo_does_not_prompt_or_rewrite(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.delenv("SUDO_PASSWORD", raising=False) + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + monkeypatch.setattr(TenkiEnvironment, "_sudo_nopasswd_works", lambda self: True) + + def fail_prompt(*_args, **_kwargs): + raise AssertionError("Tenki sudo should not prompt for a host password") + + monkeypatch.setattr("tools.terminal_tool._prompt_for_sudo_password", fail_prompt) + + env = TenkiEnvironment(task_id="sudo-nopasswd") + env.execute("sudo whoami", timeout=5) + + command = _last_started_command(_FakeSandboxFactory.sandboxes[0]) + assert "sudo whoami" in command + assert "sudo -S" not in command + assert "sudo -n whoami" not in command + env.cleanup() + + +def test_tenki_sudo_without_nopasswd_fails_fast_without_host_password(monkeypatch, tmp_path): + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setenv("SUDO_PASSWORD", "host-secret") + monkeypatch.setenv("HERMES_INTERACTIVE", "1") + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "config.yaml")) + (tmp_path / "config.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + monkeypatch.setattr(TenkiEnvironment, "_sudo_nopasswd_works", lambda self: False) + + def fail_prompt(*_args, **_kwargs): + raise AssertionError("Tenki sudo should not prompt for a host password") + + monkeypatch.setattr("tools.terminal_tool._prompt_for_sudo_password", fail_prompt) + + env = TenkiEnvironment(task_id="sudo-no-nopasswd") + env.execute("sudo whoami", timeout=5) + + command = _last_started_command(_FakeSandboxFactory.sandboxes[0]) + assert "sudo -n whoami" in command + assert "sudo -S" not in command + assert "host-secret" not in command + env.cleanup() + +def test_exec_survives_cancel_clearing_sandbox_mid_operation(monkeypatch, tmp_path): + """cancel() nulling self._sandbox between _ensure_sandbox() and the exec + call must not crash the in-flight command: operations run against the + reference captured by _require_sandbox(), and a genuinely torn-down + sandbox surfaces a clean RuntimeError instead of an AttributeError.""" + _install_fake_tenki(monkeypatch) + _clear_tenki_auth_env(monkeypatch) + monkeypatch.setattr("tools.lazy_deps.ensure", lambda *_args, **_kwargs: None) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "missing.yaml")) + monkeypatch.setenv("TENKI_API_KEY", "sk-test-key") + + from tools.environments.tenki import TenkiEnvironment + + monkeypatch.setattr(TenkiEnvironment, "init_session", lambda self: None) + env = TenkiEnvironment(task_id="cancel-race") + + sandbox = _FakeSandboxFactory.sandboxes[0] + orig_exec = sandbox.exec + + def exec_and_teardown(*args, **kwargs): + env._sandbox = None # what cancel() does concurrently + return orig_exec(*args, **kwargs) + + monkeypatch.setattr(sandbox, "exec", exec_and_teardown) + output, exit_code = env._exec_raw("echo ok", timeout=5) + assert exit_code == 0 + + # After the teardown a fruitless ensure must fail loud and typed. + monkeypatch.setattr(env, "_ensure_sandbox", lambda: None) + env._sandbox = None + with pytest.raises(RuntimeError, match="torn down"): + env._exec_raw("echo ok", timeout=5) diff --git a/tests/tools/test_terminal_config_env_sync.py b/tests/tools/test_terminal_config_env_sync.py index 5f6668fd62a0..94bb361709fb 100644 --- a/tests/tools/test_terminal_config_env_sync.py +++ b/tests/tools/test_terminal_config_env_sync.py @@ -254,6 +254,59 @@ def test_docker_extra_args_is_bridged_everywhere(): assert "TERMINAL_DOCKER_EXTRA_ARGS" in _terminal_tool_env_var_names() +def test_tenki_config_backend_defaults_to_terminate_only(monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + from hermes_cli import config as hc_config + + hc_config._LOAD_CONFIG_CACHE.clear() + (tmp_path / "config.yaml").write_text("terminal:\n backend: tenki\n", encoding="utf-8") + + cfg = hc_config.load_config() + assert cfg["terminal"]["container_persistent"] is False + + env = {} + hc_config.apply_terminal_config_to_env(env=env, config=cfg) + assert env["TERMINAL_CONTAINER_PERSISTENT"] == "False" + + hc_config._LOAD_CONFIG_CACHE.clear() + (tmp_path / "config.yaml").write_text( + "terminal:\n backend: tenki\n container_persistent: true\n", + encoding="utf-8", + ) + + cfg = hc_config.load_config() + assert cfg["terminal"]["container_persistent"] is True + + +def test_tenki_managed_persistence_survives_env_bridge(monkeypatch, tmp_path): + home = tmp_path / "home" + managed = tmp_path / "managed" + home.mkdir() + managed.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setenv("HERMES_MANAGED_DIR", str(managed)) + + from hermes_cli import config as hc_config + from hermes_cli import managed_scope + + hc_config._LOAD_CONFIG_CACHE.clear() + hc_config._RAW_CONFIG_CACHE.clear() + managed_scope.invalidate_managed_cache() + (home / "config.yaml").write_text("terminal:\n backend: tenki\n", encoding="utf-8") + (managed / "config.yaml").write_text( + "terminal:\n container_persistent: true\n", + encoding="utf-8", + ) + + cfg = hc_config.load_config() + assert cfg["terminal"]["container_persistent"] is True + + env = {"TERMINAL_CONTAINER_PERSISTENT": "false"} + hc_config.apply_terminal_config_to_env(env=env, config=cfg, override=True) + assert env["TERMINAL_CONTAINER_PERSISTENT"] == "True" + + def test_docker_persist_across_processes_is_bridged_everywhere(): """Regression pin for the cross-process container reuse toggle. @@ -322,3 +375,26 @@ def test_docker_forward_env_is_bridged_everywhere(): assert "docker_forward_env" in _gateway_env_map_keys() assert "docker_forward_env" in _save_config_env_sync_keys() assert "TERMINAL_DOCKER_FORWARD_ENV" in _terminal_tool_env_var_names() + + +def test_tenki_config_is_bridged_everywhere(): + """Tenki backend config must reach every Hermes entry point.""" + tenki_keys = { + "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", + } + for key, env_var in tenki_keys.items(): + assert key in _cli_env_map_keys() + assert key in _gateway_env_map_keys() + assert key in _save_config_env_sync_keys() + assert env_var in _terminal_tool_env_var_names() diff --git a/tests/tools/test_terminal_requirements.py b/tests/tools/test_terminal_requirements.py index a2c1f00e12f2..d37a0b42a3f7 100644 --- a/tests/tools/test_terminal_requirements.py +++ b/tests/tools/test_terminal_requirements.py @@ -16,6 +16,12 @@ def _clear_terminal_env(monkeypatch): "TERMINAL_DOCKER_VOLUMES", "TERMINAL_LIFETIME_SECONDS", "TERMINAL_MODAL_MODE", + "TERMINAL_TENKI_API_ENDPOINT", + "TERMINAL_TENKI_PROJECT_ID", + "TERMINAL_TENKI_WORKSPACE_ID", + "TENKI_API_KEY", + "TENKI_AUTH_TOKEN", + "TENKI_CONFIG_PATH", "TERMINAL_SSH_HOST", "TERMINAL_SSH_PORT", "TERMINAL_SSH_USER", @@ -185,3 +191,38 @@ def test_modal_backend_managed_mode_without_feature_flag_logs_clear_error(monkey "Nous Tool Gateway access is not currently available" in record.getMessage() for record in caplog.records ) + + +def test_tenki_backend_with_sdk_and_cli_auth_returns_true(monkeypatch, tmp_path): + _clear_terminal_env(monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", "tenki") + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "tenki.yaml")) + (tmp_path / "tenki.yaml").write_text("auth_token: tok-secret\n", encoding="utf-8") + + monkeypatch.setattr( + terminal_tool_module.importlib.util, + "find_spec", + lambda name: object() if name == "tenki_sandbox" else None, + ) + + assert terminal_tool_module.check_terminal_requirements() is True + + +def test_tenki_backend_without_auth_logs_specific_error(monkeypatch, caplog, tmp_path): + _clear_terminal_env(monkeypatch) + monkeypatch.setenv("TERMINAL_ENV", "tenki") + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "missing.yaml")) + monkeypatch.setattr( + terminal_tool_module.importlib.util, + "find_spec", + lambda name: object() if name == "tenki_sandbox" else None, + ) + + with caplog.at_level(logging.ERROR): + ok = terminal_tool_module.check_terminal_requirements() + + assert ok is False + assert any( + "no Tenki auth was found" in record.getMessage() + for record in caplog.records + ) diff --git a/tests/tools/test_terminal_tool_requirements.py b/tests/tools/test_terminal_tool_requirements.py index 4608fe868aec..c1a56765069e 100644 --- a/tests/tools/test_terminal_tool_requirements.py +++ b/tests/tools/test_terminal_tool_requirements.py @@ -65,6 +65,35 @@ def test_terminal_and_execute_code_tools_resolve_for_managed_modal(self, monkeyp assert "terminal" in names assert "execute_code" in names + def test_tenki_requires_auth_only_for_plain_sessions(self, monkeypatch, tmp_path): + original_find_spec = terminal_tool_module.importlib.util.find_spec + + def fake_find_spec(name): + if name == "tenki_sandbox": + return object() + return original_find_spec(name) + + monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", fake_find_spec) + monkeypatch.setenv("TENKI_CONFIG_PATH", str(tmp_path / "missing.yaml")) + monkeypatch.delenv("TENKI_WORKSPACE_ID", raising=False) + monkeypatch.delenv("TENKI_WORKSPACE", raising=False) + monkeypatch.delenv("TENKI_PROJECT_ID", raising=False) + monkeypatch.delenv("TENKI_PROJECT", raising=False) + monkeypatch.setattr( + terminal_tool_module, + "_get_env_config", + lambda: { + "env_type": "tenki", + "tenki_workspace_id": "", + "tenki_project_id": "", + }, + ) + + assert terminal_tool_module.check_terminal_requirements() is False + + monkeypatch.setenv("TENKI_AUTH_TOKEN", "tok") + assert terminal_tool_module.check_terminal_requirements() is True + class TestCheckFnTransientFailureSuppression: """The check_fn TTL cache should absorb transient probe failures. diff --git a/tools/approval.py b/tools/approval.py index 7c58062ed58b..7545353fd01f 100644 --- a/tools/approval.py +++ b/tools/approval.py @@ -342,7 +342,7 @@ def _is_gateway_approval_context() -> bool: # # Hardline only applies to environments that can actually damage the host # (local, ssh, container-host cron). Containerized backends (docker, -# singularity, modal, daytona) already bypass the dangerous-command layer +# singularity, modal, daytona, tenki) already bypass the dangerous-command layer # because nothing they do can touch the host, so we leave that behavior # alone. # @@ -2326,7 +2326,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") + return env_type in ("singularity", "modal", "daytona", "tenki") def check_dangerous_command(command: str, env_type: str, diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 82c248a3f715..0ec35075db19 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -795,7 +795,7 @@ def _is_local_backend() -> bool: and network access on the same machine, so the check adds no security value. - However, when the terminal runs in a container (docker, modal, daytona, + However, when the terminal runs in a container (docker, modal, daytona, tenki, ssh, singularity), the browser on the host can access internal networks that the terminal cannot. In this case, SSRF protection should be enabled even though the browser is technically "local". diff --git a/tools/code_execution_tool.py b/tools/code_execution_tool.py index 54772d7d1a0a..2a1a97e65516 100644 --- a/tools/code_execution_tool.py +++ b/tools/code_execution_tool.py @@ -16,7 +16,7 @@ **Remote backends (file-based RPC):** 1. Parent generates `hermes_tools.py` with file-based RPC stubs 2. Parent ships both files to the remote environment - 3. Script runs inside the terminal backend (Docker/SSH/Modal/Daytona/etc.) + 3. Script runs inside the terminal backend (Docker/SSH/Modal/Daytona/Tenki/etc.) 4. Tool calls are written as request files; a polling thread on the parent reads them via env.execute(), dispatches, and writes response files 5. The script polls for response files and continues @@ -636,6 +636,8 @@ def _get_or_create_env(task_id: str): _get_env_config, _last_activity, _start_cleanup_thread, _creation_locks, _creation_locks_lock, _task_env_overrides, _resolve_container_task_id, + _CONTAINER_BACKENDS, + _container_config_from_env_config, ) effective_task_id = _resolve_container_task_id(task_id) @@ -670,22 +672,16 @@ def _get_or_create_env(task_id: str): image = overrides.get("modal_image") or config["modal_image"] elif env_type == "daytona": image = overrides.get("daytona_image") or config["daytona_image"] + elif env_type == "tenki": + image = overrides.get("tenki_image") or config["tenki_image"] else: image = "" cwd = overrides.get("cwd") or config["cwd"] 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), - "docker_volumes": config.get("docker_volumes", []), - "docker_run_as_host_user": config.get("docker_run_as_host_user", False), - "docker_network": config.get("docker_network", True), - } + if env_type in _CONTAINER_BACKENDS: + container_config = _container_config_from_env_config(config) ssh_config = None if env_type == "ssh": diff --git a/tools/env_probe.py b/tools/env_probe.py index 71a1c8116cf4..25b0fb851c2c 100644 --- a/tools/env_probe.py +++ b/tools/env_probe.py @@ -19,7 +19,7 @@ environment looks normal (python3+pip both present and matched, no PEP 668), it emits nothing — no token cost. -Remote terminal backends (docker, modal, ssh, …) are skipped: the +Remote terminal backends (docker, modal, tenki, ssh, …) are skipped: the host's Python state is irrelevant when tools run inside a sandbox. The sandbox has its own existing probe (``_probe_remote_backend``) in ``agent/prompt_builder.py``. @@ -49,7 +49,7 @@ # Duplicated rather than imported to avoid a circular import (prompt_builder # imports nothing from tools). _REMOTE_BACKENDS = frozenset({ - "docker", "singularity", "modal", "daytona", "ssh", "managed_modal", + "docker", "singularity", "modal", "daytona", "tenki", "ssh", "managed_modal", }) diff --git a/tools/environments/__init__.py b/tools/environments/__init__.py index 1eebcab42a08..664dea6134f0 100644 --- a/tools/environments/__init__.py +++ b/tools/environments/__init__.py @@ -2,7 +2,7 @@ Each backend provides the same interface (BaseEnvironment ABC) for running shell commands in a specific execution context: local, Docker, SSH, -Singularity, Modal, or Daytona. (Modal additionally has direct and +Singularity, Modal, Daytona, or Tenki. (Modal additionally has direct and Nous-managed modes, selected via terminal.modal_mode.) The terminal_tool.py factory (_create_environment) selects the backend diff --git a/tools/environments/base.py b/tools/environments/base.py index 9762902cb224..5d69683b7340 100644 --- a/tools/environments/base.py +++ b/tools/environments/base.py @@ -189,7 +189,7 @@ def _file_mtime_key(host_path: str) -> tuple[float, int] | None: class ProcessHandle(Protocol): """Duck type that every backend's _run_bash() must return. - subprocess.Popen satisfies this natively. SDK backends (Modal, Daytona) + subprocess.Popen satisfies this natively. SDK backends (Modal, Daytona, Tenki) return _ThreadedProcessHandle which adapts their blocking calls. """ @@ -205,7 +205,7 @@ def returncode(self) -> int | None: ... class _ThreadedProcessHandle: - """Adapter for SDK backends (Modal, Daytona) that have no real subprocess. + """Adapter for SDK backends (Modal, Daytona, Tenki) that have no real subprocess. Wraps a blocking ``exec_fn() -> (output_str, exit_code)`` in a background thread and exposes a ProcessHandle-compatible interface. An optional @@ -848,7 +848,7 @@ def _extract_cwd_from_output(self, result: dict): """Parse the __HERMES_CWD_{session}__ marker from stdout output. Updates self.cwd and strips the marker from result["output"]. - Used by remote backends (Docker, SSH, Modal, Daytona, Singularity). + Used by remote backends (Docker, SSH, Modal, Daytona, Tenki, Singularity). """ output = result.get("output", "") marker = self._cwd_marker @@ -885,7 +885,7 @@ def _extract_cwd_from_output(self, result: dict): def _before_execute(self) -> None: """Hook called before each command execution. - Remote backends (SSH, Modal, Daytona) override this to trigger + Remote backends (SSH, Modal, Daytona, Tenki) override this to trigger their FileSyncManager. Bind-mount backends (Docker, Singularity) and Local don't need file sync — the host filesystem is directly visible inside the container/process. diff --git a/tools/environments/local.py b/tools/environments/local.py index 105283705192..56dd1dde29dd 100644 --- a/tools/environments/local.py +++ b/tools/environments/local.py @@ -244,6 +244,8 @@ def _build_provider_env_blocklist() -> frozenset: "MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET", "DAYTONA_API_KEY", + "TENKI_AUTH_TOKEN", + "TENKI_API_KEY", "GATEWAY_RELAY_ID", "GATEWAY_RELAY_SECRET", "GATEWAY_RELAY_DELIVERY_KEY", @@ -465,6 +467,8 @@ def _sanitize_subprocess_env(base_env: dict | None, extra_env: dict | None = Non "MODAL_TOKEN_ID", "MODAL_TOKEN_SECRET", "DAYTONA_API_KEY", + "TENKI_AUTH_TOKEN", + "TENKI_API_KEY", }) diff --git a/tools/environments/tenki.py b/tools/environments/tenki.py new file mode 100644 index 000000000000..0b0000da1a25 --- /dev/null +++ b/tools/environments/tenki.py @@ -0,0 +1,1102 @@ +"""Tenki cloud sandbox execution environment.""" + +from __future__ import annotations + +import hashlib +import inspect +import logging +import math +import os +import re +import shlex +import tarfile +import tempfile +import threading +from pathlib import Path +from typing import Any + +from hermes_constants import get_hermes_home +from tools.environments.base import ( + BaseEnvironment, + _ThreadedProcessHandle, + _load_json_store, + _save_json_store, +) +from tools.environments.file_sync import ( + FileSyncManager, + iter_sync_files, + quoted_mkdir_command, + quoted_rm_command, + unique_parent_dirs, +) +from tools.tenki_config import ( + resolve_tenki_api_endpoint, + resolve_tenki_auth_token, + resolve_tenki_project_id, + resolve_tenki_workspace_id, +) + +logger = logging.getLogger(__name__) +_SNAPSHOT_NAMESPACE = "direct" +_ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def _snapshot_store_path() -> Path: + """Resolve the snapshot registry path for the *active* profile. + + Resolved per call (not frozen at import) so the multiplexing gateway, + which overrides ``HERMES_HOME`` per turn, writes each profile's snapshot + pointers into that profile's own home instead of whichever profile + happened to import this module first. + """ + return get_hermes_home() / "tenki_snapshots.json" + + +def _profile_token() -> str: + """Short, stable identifier for the active Hermes profile. + + Two profiles sharing one Tenki account must get distinct sandbox + names/metadata so they can never attach to or restore each other's + sandbox. Prefer the canonical ``HERMES_PROFILE`` id, which is stable across + machines and survives a home-directory move; only fall back to a + *normalized* ``HERMES_HOME`` path when no profile id is set (the default + profile). Resolving per call handles the multiplexing gateway's per-turn + ``HERMES_HOME`` override (same reason as :func:`_snapshot_store_path`). + """ + profile = os.getenv("HERMES_PROFILE", "").strip() + if profile: + basis = f"profile:{profile}" + else: + try: + basis = str(get_hermes_home().resolve()) + except Exception: + basis = str(get_hermes_home()) + return hashlib.sha1(basis.encode("utf-8")).hexdigest()[:10] + + +def _load_snapshots(store_path: Path | None = None) -> dict: + return _load_json_store(store_path or _snapshot_store_path()) + + +def _save_snapshots(data: dict, store_path: Path | None = None) -> None: + _save_json_store(store_path or _snapshot_store_path(), data) + + +def _snapshot_key(task_id: str) -> str: + return f"{_SNAPSHOT_NAMESPACE}:{task_id}" + + +def _get_snapshot_restore_candidate( + task_id: str, store_path: Path | None = None +) -> tuple[str | None, bool]: + snapshots = _load_snapshots(store_path) + namespaced_key = _snapshot_key(task_id) + snapshot_id = snapshots.get(namespaced_key) + if isinstance(snapshot_id, str) and snapshot_id: + return snapshot_id, False + legacy_snapshot_id = snapshots.get(task_id) + if isinstance(legacy_snapshot_id, str) and legacy_snapshot_id: + return legacy_snapshot_id, True + return None, False + + +def _store_snapshot(task_id: str, snapshot_id: str, store_path: Path | None = None) -> None: + snapshots = _load_snapshots(store_path) + snapshots[_snapshot_key(task_id)] = snapshot_id + snapshots.pop(task_id, None) + _save_snapshots(snapshots, store_path) + + +def _delete_snapshot( + task_id: str, snapshot_id: str | None = None, store_path: Path | None = None +) -> None: + snapshots = _load_snapshots(store_path) + updated = False + for key in (_snapshot_key(task_id), task_id): + value = snapshots.get(key) + if value is None: + continue + if snapshot_id is None or value == snapshot_id: + snapshots.pop(key, None) + updated = True + if updated: + _save_snapshots(snapshots, store_path) + + +def _normalize_forward_env_names(forward_env: list[str] | None) -> list[str]: + normalized: list[str] = [] + seen: set[str] = set() + for item in forward_env or []: + if not isinstance(item, str): + logger.warning("Ignoring non-string tenki_forward_env entry: %r", item) + continue + name = item.strip() + if not name: + continue + if not _ENV_NAME_RE.match(name): + logger.warning("Ignoring invalid tenki_forward_env entry: %r", item) + continue + if name not in seen: + normalized.append(name) + seen.add(name) + return normalized + + +def _safe_name(value: str, *, fallback: str = "default", max_len: int = 48) -> str: + safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", value or "").strip("-._") + return (safe or fallback)[:max_len] + + +def _supports_any_kwargs(sig: inspect.Signature | None) -> bool: + if sig is None: + return True + return any(param.kind == inspect.Parameter.VAR_KEYWORD for param in sig.parameters.values()) + + +def _add_supported( + kwargs: dict[str, Any], + sig: inspect.Signature | None, + names: tuple[str, ...], + value: Any, +) -> None: + if value in (None, "", [], {}): + return + if sig is not None: + for name in names: + if name in sig.parameters: + kwargs[name] = value + return + if _supports_any_kwargs(sig): + kwargs[names[0]] = value + + +def _result_attr(result: Any, names: tuple[str, ...]) -> Any: + for name in names: + if not hasattr(result, name): + continue + value = getattr(result, name) + if callable(value): + try: + value = value() + except TypeError: + pass + if value is not None: + return value + return None + + +def _text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value) + + +def _positive_float(value: Any) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if number > 0 else None + + +def _rewrite_sudo_noninteractive(command: str) -> tuple[str, int]: + """Add ``-n`` to real sudo invocations so Tenki never prompts.""" + from tools.terminal_tool import _looks_like_env_assignment, _read_shell_token + + out: list[str] = [] + i = 0 + n = len(command) + command_start = True + sudo_count = 0 + + while i < n: + ch = command[i] + + if ch.isspace(): + out.append(ch) + if ch == "\n": + command_start = True + i += 1 + continue + + if ch == "#" and command_start: + comment_end = command.find("\n", i) + if comment_end == -1: + out.append(command[i:]) + break + out.append(command[i:comment_end]) + i = comment_end + continue + + if command.startswith("&&", i) or command.startswith("||", i) or command.startswith(";;", i): + out.append(command[i:i + 2]) + i += 2 + command_start = True + continue + + if ch in ";|&(": + out.append(ch) + i += 1 + command_start = True + continue + + if ch == ")": + out.append(ch) + i += 1 + command_start = False + continue + + token, next_i = _read_shell_token(command, i) + if command_start and token == "sudo": + out.append("sudo -n") + sudo_count += 1 + else: + out.append(token) + + if command_start and _looks_like_env_assignment(token): + command_start = True + else: + command_start = False + i = next_i + + return "".join(out), sudo_count + + +class TenkiEnvironment(BaseEnvironment): + """Tenki sandbox backend. + + Tenki's SDK exposes process handles inside a remote sandbox, so this adapts + them to the normal Hermes ``ProcessHandle`` contract with + ``_ThreadedProcessHandle``. + """ + + _stdin_mode = "pipe" + _snapshot_timeout = 60 + _terminal_states = frozenset({"TERMINATING", "TERMINATED", "DELETED", "FAILED", "ERROR"}) + + def __init__( + self, + image: str = "", + cwd: str = "/home/tenki", + timeout: int = 60, + cpu: float = 1, + memory: int = 5120, + disk: int = 51200, + persistent_filesystem: bool = False, + task_id: str = "default", + api_endpoint: str = "", + workspace_id: str = "", + project_id: str = "", + name_prefix: str = "hermes", + allow_inbound: bool = False, + allow_outbound: bool = True, + max_duration: int = 3600, + idle_timeout: int = 0, + pause_retention: int = 0, + sync_hermes_home: bool = False, + forward_env: list[str] | None = None, + ): + super().__init__(cwd=cwd, timeout=timeout) + + try: + from tools.lazy_deps import ensure as _lazy_ensure + + _lazy_ensure("terminal.tenki", prompt=False) + except ImportError: + pass + except Exception as exc: + raise ImportError(str(exc)) + + from tenki_sandbox import Client, Sandbox + + self._Client = Client + self._Sandbox = Sandbox + self._client = None + self._sandbox = None + self._lock = threading.Lock() + self._persistent = persistent_filesystem + self._sync_hermes_home = sync_hermes_home + self._sync_manager: FileSyncManager | None = None + self._cleanup_in_progress = False + self._cleanup_sandbox = None + self._task_id = task_id + self._profile_token = _profile_token() + # Bind the profile's snapshot-store path at construction, while the + # correct HERMES_HOME context is active. Cleanup (and the idle-reaper + # snapshot save) can run in a background thread that does NOT inherit + # the per-turn HERMES_HOME contextvar, so re-resolving there would write + # the pointer into the wrong profile's home. + self._snapshot_store = _snapshot_store_path() + self._snapshot_restore_id: str | None = None + self._snapshot_restore_from_legacy_key = False + self._image = image + self._cpu = cpu + self._memory = memory + self._disk = disk + self._api_endpoint = resolve_tenki_api_endpoint(api_endpoint) + self._workspace_id = resolve_tenki_workspace_id(workspace_id) + self._project_id = resolve_tenki_project_id(project_id) + self._auth_token = resolve_tenki_auth_token() + self._name_prefix = _safe_name(name_prefix, fallback="hermes", max_len=28) + self._allow_inbound = allow_inbound + self._allow_outbound = allow_outbound + self._max_duration = max_duration + self._idle_timeout = idle_timeout + self._pause_retention = pause_retention + self._forward_env = _normalize_forward_env_names(forward_env) + self._remote_home = "/home/tenki" + if self._persistent: + self._snapshot_restore_id, self._snapshot_restore_from_legacy_key = ( + _get_snapshot_restore_candidate(self._task_id, self._snapshot_store) + ) + + self._ensure_sandbox() + self._resolve_remote_home() + if self._sync_hermes_home: + self._sync_manager = FileSyncManager( + get_files_fn=lambda: iter_sync_files(f"{self._remote_home}/.hermes"), + upload_fn=self._tenki_upload, + delete_fn=self._tenki_delete, + bulk_upload_fn=self._tenki_bulk_upload, + bulk_download_fn=self._tenki_bulk_download, + ) + self._sync_manager.sync(force=True) + self.init_session() + + def _sandbox_create_signature(self) -> inspect.Signature | None: + try: + return inspect.signature(self._Sandbox.create) + except (TypeError, ValueError): + return None + + def _create_kwargs(self) -> dict[str, Any]: + sig = self._sandbox_create_signature() + kwargs: dict[str, Any] = {} + sandbox_name = self._sandbox_name() + + _add_supported(kwargs, sig, ("name",), sandbox_name) + if self._snapshot_restore_id: + _add_supported(kwargs, sig, ("snapshot_id",), self._snapshot_restore_id) + else: + _add_supported(kwargs, sig, ("image", "template"), self._image) + cpu_cores = max(1, math.ceil(float(self._cpu))) if self._cpu else None + _add_supported(kwargs, sig, ("cpu_cores", "cpu"), cpu_cores) + _add_supported(kwargs, sig, ("memory_mb", "memory"), self._memory) + + if self._disk: + disk_gb = max(1, math.ceil(float(self._disk) / 1024)) + _add_supported(kwargs, sig, ("disk_size_gb", "disk_gb", "disk"), disk_gb) + + _add_supported(kwargs, sig, ("allow_inbound",), self._allow_inbound) + _add_supported(kwargs, sig, ("allow_outbound",), self._allow_outbound) + _add_supported(kwargs, sig, ("max_duration",), self._max_duration) + idle_timeout = _positive_float(self._idle_timeout) + if idle_timeout is not None: + idle_timeout_minutes = max(1, math.ceil(idle_timeout / 60)) + _add_supported(kwargs, sig, ("idle_timeout_minutes",), idle_timeout_minutes) + pause_retention = _positive_float(self._pause_retention) + if pause_retention is not None: + _add_supported(kwargs, sig, ("pause_retention",), pause_retention) + _add_supported(kwargs, sig, ("workspace_id",), self._workspace_id) + _add_supported(kwargs, sig, ("project_id",), self._project_id) + _add_supported(kwargs, sig, ("base_url", "api_endpoint"), self._api_endpoint) + _add_supported(kwargs, sig, ("auth_token", "api_key"), self._auth_token) + _add_supported(kwargs, sig, ("env",), self._sandbox_env()) + _add_supported( + kwargs, + sig, + ("metadata",), + { + "hermes_task_id": self._task_id, + "hermes_backend": "tenki", + "hermes_profile": self._profile_token, + }, + ) + _add_supported(kwargs, sig, ("tags",), ["hermes-agent"]) + _add_supported(kwargs, sig, ("wait",), True) + # Do NOT emit a create-time ``timeout`` here: the SDK's Sandbox.create + # pops ``timeout`` into the *Client* (HTTP) timeout, while Client.create + # treats ``timeout`` as the *wait-for-ready* budget — so the same value + # would mean two different things across the two create paths. The HTTP + # timeout is set explicitly in _create_client(); readiness uses the + # SDK's default wait budget. + return kwargs + + def _sandbox_env(self) -> dict[str, str]: + """Environment variables injected into Tenki sandbox processes. + + The supervisor's Tenki control-plane credential is used host-side to + create and manage the sandbox (see ``_create_kwargs`` / + ``_create_client``); it is deliberately NOT injected into the guest. + Guest code is model-controlled and can print, exfiltrate, or reuse + whatever is in its environment, and the sandbox is billed against the + supervisor's account — so a leaked ``TENKI_AUTH_TOKEN`` would let guest + code create, terminate, and bill account resources outside the + parent's configured limits. Nested-sandbox support is still available + as an explicit opt-in: list ``TENKI_AUTH_TOKEN`` (or ``TENKI_API_KEY``) + in ``terminal.tenki_forward_env``. + + ``terminal.tenki_forward_env`` is the explicit allowlist for + task-specific credentials such as GitHub tokens; the generic + ``terminal.env_passthrough`` allowlist is also honored for skill + variables that are not protected by Hermes' provider-secret blocklist. + """ + env: dict[str, str] = {} + env.update(self._resolve_forwarded_env(self._forward_env)) + env.update(self._passthrough_env()) + # If the operator explicitly opted into forwarding the control-plane + # credential (for nested-sandbox creation), supply the already-resolved + # token. Re-reading the env var here would miss a `tenki login` + # credential, which lives in the Tenki CLI config, not the environment — + # so the documented opt-in would silently forward nothing. + if self._auth_token: + for key in ("TENKI_AUTH_TOKEN", "TENKI_API_KEY"): + if key in self._forward_env and not env.get(key): + env[key] = self._auth_token + logger.warning( + "Tenki: forwarding the control-plane credential %s into the " + "sandbox as requested by terminal.tenki_forward_env. Guest code " + "can read it and create/terminate/bill account resources. Note " + "that forwarded credentials are NOT profile-isolated under the " + "multiplexing gateway's shared terminal cache.", + key, + ) + return env + + @staticmethod + def _resolve_forwarded_env(keys: list[str] | set[str] | tuple[str, ...]) -> dict[str, str]: + if not keys: + return {} + from tools.tenki_config import _global_credential_fallback_allowed, _scoped_env + + get_env_value = None + if _global_credential_fallback_allowed(): + try: + from hermes_cli.config import get_env_value + except Exception: + get_env_value = None + + env: dict[str, str] = {} + for key in keys: + # Scope-aware read first: under a multiplexed profile turn this + # resolves the active profile's value, never another profile's raw + # os.environ. The ~/.hermes/.env fallback is consulted only when no + # profile scope is authoritative. + value = _scoped_env(key) + if not value and get_env_value is not None: + try: + value = get_env_value(key) or "" + except Exception: + value = "" + if value: + env[key] = value + return env + + @staticmethod + def _passthrough_env() -> dict[str, str]: + try: + from tools.env_passthrough import get_all_passthrough + + keys = sorted(get_all_passthrough()) + except Exception: + keys = [] + return TenkiEnvironment._resolve_forwarded_env(keys) + + def _create_client(self): + if self._client is None: + self._client = self._Client( + auth_token=self._auth_token, + base_url=self._api_endpoint, + timeout=max(60, self.timeout), + ) + return self._client + + def _sandbox_name(self) -> str: + # The profile token namespaces the name so two profiles sharing one + # Tenki account never collide on a name or reuse each other's sandbox. + return f"{self._name_prefix}-{self._profile_token}-{_safe_name(self._task_id)}" + + @staticmethod + def _sandbox_state(sandbox: Any) -> str: + state = getattr(sandbox, "state", "") + if callable(state): + try: + state = state() + except TypeError: + state = "" + return str(state or "").upper() + + def _sandbox_matches_task(self, sandbox: Any) -> bool: + name = getattr(sandbox, "name", "") + info = getattr(sandbox, "info", None) + if not name and info is not None: + name = getattr(info, "name", "") + if name != self._sandbox_name(): + return False + metadata = getattr(info, "metadata", {}) if info is not None else {} + # Never reuse another profile's sandbox: if the candidate carries a + # profile token it must match ours (the name already encodes it, but + # metadata is the authoritative, defense-in-depth check). + if isinstance(metadata, dict) and metadata.get("hermes_profile"): + if metadata.get("hermes_profile") != self._profile_token: + return False + if isinstance(metadata, dict) and metadata.get("hermes_task_id"): + return metadata.get("hermes_task_id") == self._task_id + return True + + def _find_persistent_sandbox(self): + if not self._persistent: + return None + client = self._create_client() + try: + if self._project_id and hasattr(client, "list_project"): + candidates = client.list_project(self._project_id, tags=["hermes-agent"]) + elif self._workspace_id and hasattr(client, "list_workspace"): + candidates = client.list_workspace(self._workspace_id, tags=["hermes-agent"]) + else: + candidates = client.list(tags=["hermes-agent"]) + except Exception as exc: + logger.debug("Tenki: could not list persistent sandboxes: %s", exc) + return None + + usable = [] + for sandbox in candidates: + if not self._sandbox_matches_task(sandbox): + continue + state = self._sandbox_state(sandbox) + if state in self._terminal_states: + continue + usable.append((state, sandbox)) + if not usable: + return None + usable.sort(key=lambda item: 0 if item[0] == "RUNNING" else 1) + return usable[0][1] + + def _resume_persistent_sandbox(self): + sandbox = self._find_persistent_sandbox() + if sandbox is None: + return None + if not self._ensure_sandbox_ready(sandbox): + logger.info( + "Tenki: existing sandbox for task %s is no longer reusable; creating a fresh sandbox", + self._task_id, + ) + return None + sandbox_id = getattr(sandbox, "id", None) or getattr(sandbox, "sandbox_id", None) + logger.info("Tenki: resumed sandbox %s for task %s", sandbox_id or "", self._task_id) + return sandbox + + def _ensure_sandbox_ready(self, sandbox: Any) -> bool: + refresh = getattr(sandbox, "refresh", None) + if callable(refresh): + try: + refresh() + except Exception as exc: + logger.info("Tenki: sandbox refresh failed for task %s: %s", self._task_id, exc) + return False + + state = self._sandbox_state(sandbox) + if state in self._terminal_states: + return False + + try: + if state and state != "RUNNING": + resume = getattr(sandbox, "resume", None) + if callable(resume): + resume() + wait_ready = getattr(sandbox, "wait_ready", None) + if callable(wait_ready): + wait_ready(max(60, self.timeout)) + except Exception as exc: + logger.info("Tenki: could not make sandbox ready for task %s: %s", self._task_id, exc) + return False + + return self._sandbox_state(sandbox) not in self._terminal_states + + def _ensure_sandbox(self) -> None: + with self._lock: + if self._cleanup_in_progress: + raise RuntimeError("Tenki cleanup is in progress") + if self._sandbox is not None: + if self._ensure_sandbox_ready(self._sandbox): + return + self._sandbox = None + self._sandbox = self._resume_persistent_sandbox() + if self._sandbox is not None: + return + self._sandbox = self._create_sandbox_with_snapshot_fallback() + sandbox_id = getattr(self._sandbox, "id", None) or getattr(self._sandbox, "sandbox_id", None) + logger.info("Tenki: created sandbox %s for task %s", sandbox_id or "", self._task_id) + + def _require_sandbox(self) -> Any: + # Capture the reference under the lock: cancel() may null out + # self._sandbox between _ensure_sandbox() and the caller's use of it, + # and the operation must run against the sandbox that ensure produced. + self._ensure_sandbox() + with self._lock: + sandbox = self._sandbox + if sandbox is None: + raise RuntimeError("Tenki sandbox was torn down mid-operation") + return sandbox + + def _create_sandbox_from_kwargs(self, kwargs: dict[str, Any]): + if self._persistent: + client = self._create_client() + create_kwargs = dict(kwargs) + for key in ("auth_token", "api_key", "base_url", "api_endpoint"): + create_kwargs.pop(key, None) + return client.create(**create_kwargs) + return self._Sandbox.create(**kwargs) + + # Snapshot errors that mean the recorded snapshot can never restore, so + # dropping the pointer and booting the base image is the right recovery. + # A *transient* failure (network, rate-limit, ambiguous) is NOT in this set: + # it must propagate with the pointer intact so a later attempt can still + # recover the persistent state instead of silently booting an empty sandbox. + # Snapshot-specific error type names that mean the snapshot can never + # restore. Note InvalidStateError is intentionally NOT here: the restore RPC + # maps a generic FAILED_PRECONDITION (workspace/policy/etc.) to it, so it is + # only unrecoverable when its message points at the snapshot itself + # (handled by message inspection below); a bare InvalidStateError stays + # transient so an unrelated precondition can't destroy a valid pointer. + _UNRECOVERABLE_SNAPSHOT_ERRORS = frozenset({ + "SnapshotNotFoundError", # snapshot is gone + "RegistryArtifactNotFoundError", # backing artifact is gone + "SnapshotNotDurableError", # explicitly never reached durability + }) + + @classmethod + def _snapshot_unrecoverable(cls, exc: BaseException) -> bool: + """True when the error confirms the snapshot can never restore. + + Covers "gone" (not-found), "explicitly non-durable", and a generic + ``InvalidStateError`` whose message identifies the snapshot as the + failing precondition (the SDK's restore RPC collapses a bad/non-durable + snapshot into a generic FAILED_PRECONDITION → InvalidStateError). Only + these justify discarding the recovery pointer and booting a base image; + every other error (including a bare InvalidStateError, rate-limit, + quota, auth blip, or network failure) is transient and re-raised with + the pointer preserved. + """ + def _is_snapshot_specific_invalid_state(e: BaseException, invalid_state_cls) -> bool: + if invalid_state_cls is not None and not isinstance(e, invalid_state_cls): + return False + if invalid_state_cls is None and type(e).__name__ != "InvalidStateError": + return False + msg = str(e).lower() + return "snapshot" in msg or "durable" in msg + + try: + from tenki_sandbox import ( + InvalidStateError, + RegistryArtifactNotFoundError, + SnapshotNotDurableError, + SnapshotNotFoundError, + ) + + if isinstance( + exc, + (SnapshotNotFoundError, RegistryArtifactNotFoundError, SnapshotNotDurableError), + ): + return True + if _is_snapshot_specific_invalid_state(exc, InvalidStateError): + return True + return False + except Exception: + pass + # Name-based fallback for SDK builds that don't export every class. + for typ in type(exc).__mro__: + if typ.__name__ in cls._UNRECOVERABLE_SNAPSHOT_ERRORS: + return True + return _is_snapshot_specific_invalid_state(exc, None) + + def _create_sandbox_with_snapshot_fallback(self): + kwargs = self._create_kwargs() + try: + sandbox = self._create_sandbox_from_kwargs(kwargs) + except Exception as exc: + if not self._snapshot_restore_id: + raise + if not self._snapshot_unrecoverable(exc): + # Ambiguous/transient failure — keep the snapshot pointer so a + # later attempt can still recover, rather than deleting it and + # booting a blank base image (silent loss of persistent state). + logger.warning( + "Tenki: snapshot restore %s for task %s failed transiently (%s); " + "preserving it for retry", + self._snapshot_restore_id, + self._task_id, + exc, + ) + raise + logger.warning( + "Tenki: snapshot %s for task %s is unrecoverable; creating from base image: %s", + self._snapshot_restore_id, + self._task_id, + exc, + ) + _delete_snapshot(self._task_id, self._snapshot_restore_id, self._snapshot_store) + self._snapshot_restore_id = None + self._snapshot_restore_from_legacy_key = False + sandbox = self._create_sandbox_from_kwargs(self._create_kwargs()) + else: + if self._snapshot_restore_id and self._snapshot_restore_from_legacy_key: + _store_snapshot(self._task_id, self._snapshot_restore_id, self._snapshot_store) + return sandbox + + def _remote_transfer_path(self, prefix: str) -> str: + base = (self._remote_home or "/home/tenki").rstrip("/") or "/home/tenki" + if base != "/home/tenki" and not base.startswith("/home/tenki/"): + base = "/home/tenki" + return f"{base}/{prefix}.{os.getpid()}.{self._session_id}.tar" + + def _resolve_remote_home(self) -> None: + try: + result = self._exec_raw("echo \"$HOME\"", timeout=15) + home = result[0].strip() if result[1] == 0 else "" + if home: + self._remote_home = home + if self.cwd in {"~", "/home/tenki"}: + self.cwd = home + except Exception: + pass + + def _tenki_upload(self, host_path: str, remote_path: str) -> None: + self._ensure_sandbox() + parent = str(Path(remote_path).parent) + self._sandbox.fs.mkdir(parent, recursive=True) + self._sandbox.fs.upload(host_path, remote_path) + + def _tenki_bulk_upload(self, files: list[tuple[str, str]]) -> None: + if not files: + return + + self._ensure_sandbox() + parents = unique_parent_dirs(files) + if parents: + self._exec_raw(quoted_mkdir_command(parents), timeout=30) + + remote_tar = self._remote_transfer_path(".hermes_tenki_sync") + with tempfile.NamedTemporaryFile(suffix=".tar") as tmp: + with tarfile.open(fileobj=tmp, mode="w") as tar: + for host_path, remote_path in files: + tar.add(host_path, arcname=remote_path.lstrip("/")) + tmp.flush() + self._sandbox.fs.upload(tmp.name, remote_tar) + + try: + output, exit_code = self._exec_raw( + f"tar xf {shlex.quote(remote_tar)} -C /", + timeout=120, + ) + if exit_code != 0: + raise RuntimeError(f"Tenki bulk upload failed (exit {exit_code}): {output}") + finally: + try: + self._exec_raw(f"rm -f {shlex.quote(remote_tar)}", timeout=10) + except Exception: + pass + + def _tenki_bulk_download(self, dest: Path) -> None: + sandbox = self._transfer_sandbox() + remote_tar = self._remote_transfer_path(".hermes_tenki_sync_back") + rel_base = f"{self._remote_home}/.hermes".lstrip("/") + try: + output, exit_code = self._exec_raw_on_sandbox( + sandbox, + f"tar cf {shlex.quote(remote_tar)} -C / {shlex.quote(rel_base)}", + timeout=120, + ) + if exit_code != 0: + raise RuntimeError(f"Tenki bulk download failed (exit {exit_code}): {output}") + sandbox.fs.download(remote_tar, str(dest)) + finally: + try: + self._exec_raw_on_sandbox(sandbox, f"rm -f {shlex.quote(remote_tar)}", timeout=10) + except Exception: + pass + + def _transfer_sandbox(self): + if self._cleanup_in_progress and self._cleanup_sandbox is not None: + return self._cleanup_sandbox + return self._require_sandbox() + + def _tenki_delete(self, remote_paths: list[str]) -> None: + if not remote_paths: + return + self._exec_raw(quoted_rm_command(remote_paths), timeout=30) + + def _exec_raw(self, command: str, *, login: bool = False, timeout: int = 120) -> tuple[str, int]: + return self._exec_raw_on_sandbox(self._require_sandbox(), command, login=login, timeout=timeout) + + def _exec_raw_on_sandbox( + self, + sandbox: Any, + command: str, + *, + login: bool = False, + timeout: int = 120, + ) -> tuple[str, int]: + flag = "-lc" if login else "-c" + result = sandbox.exec("bash", flag, command, timeout=timeout, env=self._sandbox_env()) + return self._result_to_output(result) + + @staticmethod + def _result_to_output(result: Any) -> tuple[str, int]: + stdout = _text(_result_attr(result, ("stdout_text", "stdout", "output", "result", "text"))) + stderr = _text(_result_attr(result, ("stderr_text", "stderr"))) + exit_code = _result_attr(result, ("exit_code", "returncode", "status_code")) + if exit_code is None: + ok = _result_attr(result, ("ok", "success")) + exit_code = 0 if ok is True else 1 + if stdout and stderr and not stdout.endswith("\n"): + output = stdout + "\n" + stderr + else: + output = stdout + stderr + return output, int(exit_code) + + def _start_process( + self, + cmd_string: str, + *, + login: bool, + timeout: int, + stdin_data: str | None, + process_ref: dict[str, Any] | None = None, + ) -> tuple[str, int]: + sandbox = self._require_sandbox() + flag = "-lc" if login else "-c" + start = getattr(sandbox, "start", None) + if not callable(start): + kwargs: dict[str, Any] = {"timeout": timeout, "env": self._sandbox_env()} + if stdin_data is not None: + kwargs["input"] = stdin_data + result = sandbox.exec("bash", flag, cmd_string, **kwargs) + return self._result_to_output(result) + + process = start( + "bash", + flag, + cmd_string, + timeout=timeout, + stdin=stdin_data, + env=self._sandbox_env(), + ) + if process_ref is not None: + process_ref["process"] = process + if stdin_data is None: + close_stdin = getattr(process, "close_stdin", None) + if callable(close_stdin): + close_stdin() + result = process.wait(timeout=timeout + 5 if timeout is not None else None) + return self._result_to_output(result) + + def _sudo_nopasswd_works(self) -> bool: + try: + _output, exit_code = self._exec_raw("sudo -n true", timeout=10) + except Exception: + return False + return exit_code == 0 + + def _prepare_command(self, command: str | None) -> tuple[str | None, str | None]: + if command is None: + return None, None + + # Tenki sandboxes should rely on their own sudoers policy. Do not ask + # the user for a host sudo password, and do not send SUDO_PASSWORD to a + # remote cloud sandbox. The default Tenki image supports NOPASSWD sudo. + transformed, sudo_count = _rewrite_sudo_noninteractive(command) + if sudo_count == 0: + return command, None + if self._sudo_nopasswd_works(): + return command, None + return transformed, None + + def _before_execute(self) -> None: + self._ensure_sandbox() + if self._sync_manager: + self._sync_manager.sync() + + def _run_bash( + self, + cmd_string: str, + *, + login: bool = False, + timeout: int = 120, + stdin_data: str | None = None, + ): + process_ref: dict[str, Any] = {} + + def cancel() -> None: + process = process_ref.get("process") + kill = getattr(process, "kill", None) + if callable(kill): + try: + kill() + return + except Exception: + pass + with self._lock: + sandbox = self._sandbox + # Drop our reference so the next command resumes (persistent) or + # recreates (ephemeral) a sandbox instead of reusing a torn-down + # one. + self._sandbox = None + if sandbox is None: + return + # For a persistent sandbox, pause (preserve the filesystem) instead of + # terminating: an interrupted or timed-out command must not destroy + # state the user asked to keep. The paused sandbox is re-discovered and + # resumed on the next command via _resume_persistent_sandbox(). + if self._persistent: + pause = getattr(sandbox, "pause", None) + if callable(pause): + try: + pause() + return + except Exception: + pass # fall through to terminate if pause is unavailable + for method_name in ("terminate", "close"): + method = getattr(sandbox, method_name, None) + if callable(method): + try: + method() + except Exception: + pass + return + + def exec_fn() -> tuple[str, int]: + return self._start_process( + cmd_string, + login=login, + timeout=timeout, + stdin_data=stdin_data, + process_ref=process_ref, + ) + + return _ThreadedProcessHandle(exec_fn, cancel_fn=cancel) + + def cleanup(self): + with self._lock: + sandbox = self._sandbox + sync_manager = self._sync_manager + self._sync_manager = None + client = self._client + self._cleanup_in_progress = True + self._cleanup_sandbox = sandbox + if sandbox is None: + self._close_client(client) + with self._lock: + if self._client is client: + self._client = None + self._cleanup_in_progress = False + self._cleanup_sandbox = None + return + + try: + if sync_manager: + logger.info("Tenki: syncing files from sandbox...") + try: + sync_manager.sync_back() + except Exception as exc: + logger.warning("Tenki: sync_back failed: %s", exc) + + snapshot_saved = False + if self._persistent: + snapshot_saved = self._save_persistent_snapshot(sandbox) + + if self._persistent and not snapshot_saved: + # Persistent state was NOT durably snapshotted. Terminating now + # would destroy the only copy, so prefer pause; and if pause + # fails, still do NOT terminate — leave the sandbox live for a + # later recovery attempt (the max-duration / idle reaper bounds + # the cost). Terminating here would break the preservation + # guarantee that the durability gate exists to uphold. + pause = getattr(sandbox, "pause", None) + if callable(pause): + try: + pause() + logger.info("Tenki: paused sandbox for task %s", self._task_id) + except Exception as exc: + logger.warning( + "Tenki: pause failed for task %s; leaving sandbox live to " + "preserve un-snapshotted state (not terminating): %s", + self._task_id, exc, + ) + else: + logger.warning( + "Tenki: no durable snapshot and no pause support for task %s; " + "leaving sandbox live to preserve state (not terminating)", + self._task_id, + ) + return + + for method_name in ("terminate", "close"): + method = getattr(sandbox, method_name, None) + if not callable(method): + continue + try: + method() + logger.info("Tenki: terminated sandbox for task %s", self._task_id) + except Exception as exc: + logger.warning("Tenki: cleanup failed: %s", exc) + return + finally: + self._close_client(client) + with self._lock: + if self._sandbox is sandbox: + self._sandbox = None + if self._client is client: + self._client = None + self._cleanup_in_progress = False + self._cleanup_sandbox = None + + def _save_persistent_snapshot(self, sandbox: Any) -> bool: + snapshot_id: str | None = None + try: + snapshot = sandbox.snapshot(name=self._sandbox_name(), wait=True) + snapshot_id = getattr(snapshot, "id", None) or getattr(snapshot, "snapshot_id", None) + except Exception as exc: + logger.warning("Tenki: filesystem snapshot failed: %s", exc) + return False + if not snapshot_id: + logger.warning("Tenki: snapshot completed without an id; preserving paused sandbox instead") + return False + # snapshot(wait=True) only waits for READY; durability is a separate, + # required gate. If durability is not confirmed the snapshot may not be + # a safe recovery copy, so we must NOT record it as the persistent + # pointer or let the caller terminate the live sandbox. Return False so + # cleanup pauses the sandbox and the prior (known-durable) snapshot + # pointer is left intact for recovery. + if self._client is not None: + snapshots = getattr(self._client, "snapshots", None) + wait_durable = getattr(snapshots, "wait_durable", None) + if callable(wait_durable): + try: + wait_durable(snapshot_id, timeout=300) + except Exception as exc: + logger.warning( + "Tenki: snapshot %s for task %s did not reach durability (%s); " + "preserving paused sandbox and prior snapshot instead", + snapshot_id, self._task_id, exc, + ) + return False + _store_snapshot(self._task_id, snapshot_id, self._snapshot_store) + logger.info("Tenki: saved filesystem snapshot %s for task %s", snapshot_id, self._task_id) + return True + + @staticmethod + def _close_client(client: Any) -> None: + # Best-effort: a failed close must never propagate — cleanup() resets + # _cleanup_in_progress after this call, and an escaping exception would + # leave the flag stuck and brick the environment. + if client is None: + return + close = getattr(client, "close", None) + if callable(close): + try: + close() + except Exception as exc: + logger.warning("Tenki: client close failed: %s", exc) diff --git a/tools/file_operations.py b/tools/file_operations.py index 76446befaa5e..9741579dce1a 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). +across all terminal backends (local, docker, ssh, singularity, modal, daytona, tenki). 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. @@ -794,7 +794,7 @@ class ShellFileOperations(FileOperations): File operations implemented via shell commands. Works with ANY terminal backend that has execute(command, cwd) method. - This includes local, docker, singularity, ssh, modal, and daytona environments. + This includes local, docker, singularity, ssh, modal, daytona, and tenki environments. """ def __init__(self, terminal_env, cwd: str = None): @@ -1879,7 +1879,7 @@ def _lsp_local_only(self) -> bool: LSP servers run on the host process — they need access to the files they're linting. Remote/sandboxed backends (Docker, - Modal, SSH, Daytona) keep files inside the sandbox where the + Modal, SSH, Daytona, Tenki) keep files inside the sandbox where the host-side LSP server can't reach them, so we skip the LSP path for those entirely. """ diff --git a/tools/file_tools.py b/tools/file_tools.py index e602e8e0a675..0312c145ce0f 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -1052,7 +1052,9 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: _creation_locks_lock, _resolve_container_task_id, _is_unusable_container_cwd, + _is_backend_guest_subpath, _CONTAINER_BACKENDS, + _container_config_from_env_config, ) import time @@ -1110,6 +1112,8 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: image = overrides.get("modal_image") or config["modal_image"] elif env_type == "daytona": image = overrides.get("daytona_image") or config["daytona_image"] + elif env_type == "tenki": + image = overrides.get("tenki_image") or config["tenki_image"] else: image = "" @@ -1126,7 +1130,11 @@ def _get_file_ops(task_id: str = "default") -> ShellFileOperations: # bypass the guard. Valid in-container override paths (RL/benchmark # sandboxes that set cwd to /workspace, /root, etc.) are absolute # non-host paths and pass through untouched. - if env_type in _CONTAINER_BACKENDS and _is_unusable_container_cwd(cwd): + if ( + env_type in _CONTAINER_BACKENDS + and _is_unusable_container_cwd(cwd) + and not _is_backend_guest_subpath(env_type, cwd) + ): if cwd != config["cwd"]: logger.info( "Ignoring host/relative cwd override %r for %s backend " @@ -1137,18 +1145,8 @@ 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"}: - 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), - "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_run_as_host_user": config.get("docker_run_as_host_user", False), - "docker_network": config.get("docker_network", True), - } + if env_type in _CONTAINER_BACKENDS: + container_config = _container_config_from_env_config(config) ssh_config = None if env_type == "ssh": diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index d35feb861cf5..232ac714c21a 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -204,6 +204,7 @@ # ─── Terminal backends ───────────────────────────────────────────────── "terminal.modal": ("modal==1.3.4",), "terminal.daytona": ("daytona==0.155.0",), + "terminal.tenki": ("tenki-sandbox==0.1.1",), # ─── Skills ──────────────────────────────────────────────────────────── "skill.google_workspace": ( diff --git a/tools/skills_tool.py b/tools/skills_tool.py index a5613f62c4c8..823a08cd48f7 100644 --- a/tools/skills_tool.py +++ b/tools/skills_tool.py @@ -171,7 +171,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"} + {"docker", "singularity", "modal", "ssh", "daytona", "tenki"} ) _secret_capture_callback = None diff --git a/tools/tenki_config.py b/tools/tenki_config.py new file mode 100644 index 000000000000..78f07771d9d8 --- /dev/null +++ b/tools/tenki_config.py @@ -0,0 +1,201 @@ +"""Helpers for reading Tenki CLI configuration without exposing secrets.""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +from utils import fast_safe_load + +TENKI_DEFAULT_API_ENDPOINT = "https://api.tenki.cloud" + +_SECRET_KEYS = frozenset({ + "auth_token", + "api_key", + "access_token", + "session_token", + "token", +}) + +_SDK_AUTH_PREFIXES = ("cookie:", "ory_st_", "sk-") + + +def tenki_cli_config_path() -> Path: + """Return the Tenki CLI config path. + + ``TENKI_CONFIG_PATH`` is honored for tests and uncommon CLI installs. + """ + override = os.getenv("TENKI_CONFIG_PATH") + if override: + return Path(override).expanduser() + return Path.home() / ".config" / "tenki" / "config.yaml" + + +def load_tenki_cli_config() -> dict[str, Any]: + """Load Tenki CLI config, returning ``{}`` on missing or invalid files.""" + path = tenki_cli_config_path() + try: + data = fast_safe_load(path.read_text(encoding="utf-8")) or {} + except OSError: + return {} + except Exception: + return {} + return data if isinstance(data, dict) else {} + + +def _string(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + +def _scoped_env(name: str) -> str: + """Read a credential env var honoring the active profile secret scope. + + Under a multiplexed gateway turn a profile scope is installed, and the + token must come from *that* profile's secrets — never from a raw + ``os.environ`` read that could hold another profile's value. When no + multiplexing is active this behaves exactly like ``os.getenv``. + """ + try: + from agent.secret_scope import get_secret + + return _string(get_secret(name, "")) + except Exception: + # Fail closed: an unscoped read under active multiplexing (or any + # resolution error) must NOT silently leak a process-global value. + return "" + + +def _global_credential_fallback_allowed() -> bool: + """Whether machine-global credential sources (the shared Tenki CLI login) + may be consulted. + + Skipped whenever a profile secret scope is authoritative — a multiplexed + profile without its own Tenki token must not borrow the machine-global + ``tenki login`` credential that another profile may be relying on. + """ + try: + from agent.secret_scope import current_secret_scope, is_multiplex_active + + return current_secret_scope() is None and not is_multiplex_active() + except Exception: + return True + + +def _first_string(data: dict[str, Any], keys: tuple[str, ...]) -> str: + for key in keys: + value = _string(data.get(key)) + if value: + return value + return "" + + +def _normalize_cli_auth_token(secret: str, key: str = "") -> str: + """Return a Tenki SDK-compatible auth token from Tenki CLI config. + + Tenki CLI v0.6 stores its browser session cookie as a bare ``auth_token``. + The Python SDK expects cookie credentials to be prefixed with ``cookie:``; + otherwise it sends the value as a bearer token and the API returns + ``sandbox: unauthorized``. + """ + secret = _string(secret) + if not secret or secret.startswith(_SDK_AUTH_PREFIXES): + return secret + if key.lower() == "auth_token": + return f"cookie:{secret}" + return secret + + +def _find_secret_value(data: Any) -> str: + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(key, str) and key.lower() in _SECRET_KEYS: + secret = _string(value) + if secret: + return _normalize_cli_auth_token(secret, key) + found = _find_secret_value(value) + if found: + return found + elif isinstance(data, list): + for item in data: + found = _find_secret_value(item) + if found: + return found + return "" + + +def resolve_tenki_api_endpoint(explicit: str = "") -> str: + """Resolve the Tenki API endpoint from config/env/CLI defaults. + + Scope-aware (see :func:`_scoped_env`): under a multiplexed profile turn the + active profile's setting wins, and the shared machine Tenki CLI config is + consulted only when no profile scope is authoritative. + """ + explicit = _string(explicit) + if explicit: + return explicit + for env_name in ("TENKI_API_ENDPOINT", "TENKI_API_URL"): + value = _scoped_env(env_name) + if value: + return value + if _global_credential_fallback_allowed(): + cfg = load_tenki_cli_config() + endpoint = _first_string(cfg, ("api_endpoint", "api_url", "endpoint")) + if endpoint: + return endpoint + return TENKI_DEFAULT_API_ENDPOINT + + +def resolve_tenki_workspace_id(explicit: str = "") -> str: + """Resolve the Tenki workspace id. Scope-aware; workspace/project decide + where sandboxes are created, so a multiplexed profile must not silently + borrow the machine-global workspace of another tenant.""" + explicit = _string(explicit) + if explicit: + return explicit + for env_name in ("TENKI_WORKSPACE_ID", "TENKI_WORKSPACE"): + value = _scoped_env(env_name) + if value: + return value + if not _global_credential_fallback_allowed(): + return "" + return _first_string(load_tenki_cli_config(), ("current_workspace_id", "workspace_id", "workspace")) + + +def resolve_tenki_project_id(explicit: str = "") -> str: + """Resolve the Tenki project id. Scope-aware for the same reason as + :func:`resolve_tenki_workspace_id`.""" + explicit = _string(explicit) + if explicit: + return explicit + for env_name in ("TENKI_PROJECT_ID", "TENKI_PROJECT"): + value = _scoped_env(env_name) + if value: + return value + if not _global_credential_fallback_allowed(): + return "" + return _first_string(load_tenki_cli_config(), ("current_project_id", "project_id", "project")) + + +def resolve_tenki_auth_token(explicit: str = "") -> str: + """Resolve a Tenki auth token/API key without logging or persisting it. + + Reads are profile-scope-aware (see :func:`_scoped_env`): under a + multiplexed gateway turn the active profile's secrets win, and the shared + machine ``tenki login`` credential is consulted only when no profile scope + is authoritative. + """ + explicit = _string(explicit) + if explicit: + return explicit + for env_name in ("TENKI_AUTH_TOKEN", "TENKI_API_KEY"): + value = _scoped_env(env_name) + if value: + return value + if not _global_credential_fallback_allowed(): + return "" + return _find_secret_value(load_tenki_cli_config()) + + +def has_tenki_auth() -> bool: + return bool(resolve_tenki_auth_token()) diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index fc13367eb116..e788538e459e 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -3,16 +3,17 @@ Terminal Tool Module A terminal tool that executes commands in local, Docker, Modal, SSH, -Singularity, and Daytona environments. Supports local execution, +Singularity, Daytona, and Tenki environments. Supports local execution, containerized backends, and cloud sandboxes, including managed Modal mode. Supported environments: - "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) +- "tenki": Execute in Tenki cloud sandboxes Features: -- Multiple execution backends (local, docker, modal) +- Multiple execution backends (local, docker, modal, tenki, etc.) - Background task support - VM/container lifecycle management - Automatic cleanup after inactivity @@ -1060,7 +1061,7 @@ def _maybe_reap_docker_orphans(container_config: Dict[str, Any]) -> None: # Per-task environment overrides registry. -# Allows environments (e.g., TerminalBench2Env) to specify a custom Docker/Modal +# Allows environments (e.g., TerminalBench2Env) to specify a custom Docker/Modal/Tenki # image for a specific task_id BEFORE the agent loop starts. When the terminal or # file tools create a new sandbox for that task_id, they check this registry first # and fall back to the TERMINAL_MODAL_IMAGE (etc.) env var if no override is set. @@ -1079,6 +1080,7 @@ def register_task_env_overrides(task_id: str, overrides: Dict[str, Any]): Supported override keys: - modal_image: str -- Path to Dockerfile or Docker Hub image name + - tenki_image: str -- Tenki sandbox image/template identifier - docker_image: str -- Docker image name - cwd: str -- Working directory inside the sandbox @@ -1146,7 +1148,7 @@ def _resolve_container_task_id(task_id: Optional[str]) -> str: """ _ISOLATION_KEYS = frozenset({ "docker_image", "modal_image", "singularity_image", - "daytona_image", "env_type", + "daytona_image", "tenki_image", "env_type", }) if task_id and task_id in _task_env_overrides: overrides = _task_env_overrides[task_id] @@ -1213,7 +1215,20 @@ 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"}) +_CONTAINER_BACKENDS = frozenset({"docker", "singularity", "modal", "daytona", "tenki"}) + +# Guest-home roots whose subtree is a valid container cwd even though the root +# shares a host-looking prefix. Tenki's guest home is /home/tenki, so +# /home/tenki/project is a real sandbox path, not a host path to discard. +_CONTAINER_GUEST_HOME_ROOTS = {"tenki": "/home/tenki"} + + +def _is_backend_guest_subpath(env_type: str, cwd: str) -> bool: + """True when *cwd* is the backend's guest-home root or a path beneath it.""" + root = _CONTAINER_GUEST_HOME_ROOTS.get(env_type) + if not root or not cwd: + return False + return cwd == root or cwd.startswith(root.rstrip("/") + "/") def _is_ssh_remote_tilde_cwd(backend: str, cwd: str) -> bool: @@ -1260,7 +1275,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"} + container_backend = env_type in _CONTAINER_BACKENDS docker_backend = env_type == "docker" # Docker/container-only env vars may be bridged from config.yaml even when @@ -1287,6 +1302,22 @@ def _get_env_config() -> Dict[str, Any]: docker_env = {} docker_extra_args = [] + # Tenki settings may be bridged from config.yaml even when the active + # backend is local/ssh. Do not parse their numeric/JSON payloads until the + # tenki backend is selected; a stale or invalid value must not make the + # local terminal unusable (mirrors the container_/docker_ guards above). + tenki_backend = env_type == "tenki" + if tenki_backend: + tenki_forward_env = _parse_env_var("TERMINAL_TENKI_FORWARD_ENV", "[]", json.loads, "valid JSON") + tenki_max_duration = _parse_env_var("TERMINAL_TENKI_MAX_DURATION", "3600") + tenki_idle_timeout = _parse_env_var("TERMINAL_TENKI_IDLE_TIMEOUT", "0") + tenki_pause_retention = _parse_env_var("TERMINAL_TENKI_PAUSE_RETENTION", "0") + else: + tenki_forward_env = [] + tenki_max_duration = 3600 + tenki_idle_timeout = 0 + tenki_pause_retention = 0 + # Default cwd: local uses the host's current directory, ssh uses the # remote home, and everything else starts in the backend's default # root-like cwd. @@ -1294,6 +1325,8 @@ def _get_env_config() -> Dict[str, Any]: default_cwd = _safe_getcwd() elif env_type == "ssh": default_cwd = "~" + elif env_type == "tenki": + default_cwd = "/home/tenki" else: default_cwd = "/root" @@ -1315,8 +1348,10 @@ def _get_env_config() -> Dict[str, Any]: host_cwd = candidate cwd = "/workspace" elif env_type in _CONTAINER_BACKENDS and cwd: - # Host paths and relative paths that won't work inside containers - if _is_unusable_container_cwd(cwd) and cwd != default_cwd: + # Host paths and relative paths that won't work inside containers. A + # path inside the backend's own guest-home subtree is valid even though + # it may share a host-looking prefix (see _is_backend_guest_subpath). + if _is_unusable_container_cwd(cwd) and not _is_backend_guest_subpath(env_type, cwd): logger.info("Ignoring TERMINAL_CWD=%r for %s backend " "(host/relative path won't work in sandbox). Using %r instead.", cwd, env_type, default_cwd) @@ -1330,6 +1365,18 @@ def _get_env_config() -> Dict[str, Any]: "singularity_image": os.getenv("TERMINAL_SINGULARITY_IMAGE", f"docker://{default_image}"), "modal_image": os.getenv("TERMINAL_MODAL_IMAGE", default_image), "daytona_image": os.getenv("TERMINAL_DAYTONA_IMAGE", default_image), + "tenki_image": os.getenv("TERMINAL_TENKI_IMAGE", ""), + "tenki_api_endpoint": os.getenv("TERMINAL_TENKI_API_ENDPOINT", ""), + "tenki_workspace_id": os.getenv("TERMINAL_TENKI_WORKSPACE_ID", ""), + "tenki_project_id": os.getenv("TERMINAL_TENKI_PROJECT_ID", ""), + "tenki_name_prefix": os.getenv("TERMINAL_TENKI_NAME_PREFIX", "hermes"), + "tenki_allow_inbound": os.getenv("TERMINAL_TENKI_ALLOW_INBOUND", "false").lower() in {"true", "1", "yes"}, + "tenki_allow_outbound": os.getenv("TERMINAL_TENKI_ALLOW_OUTBOUND", "true").lower() in {"true", "1", "yes"}, + "tenki_max_duration": tenki_max_duration, + "tenki_idle_timeout": tenki_idle_timeout, + "tenki_pause_retention": tenki_pause_retention, + "tenki_sync_hermes_home": os.getenv("TERMINAL_TENKI_SYNC_HERMES_HOME", "false").lower() in {"true", "1", "yes"}, + "tenki_forward_env": tenki_forward_env, "cwd": cwd, "host_cwd": host_cwd, "docker_mount_cwd_to_workspace": mount_docker_cwd, @@ -1349,11 +1396,14 @@ def _get_env_config() -> Dict[str, Any]: ).lower() in {"true", "1", "yes"}, "local_persistent": os.getenv("TERMINAL_LOCAL_PERSISTENT", "false").lower() in {"true", "1", "yes"}, # Container resource config (applies to docker, singularity, modal, - # daytona -- ignored for local/ssh) + # daytona, tenki -- ignored for local/ssh) "container_cpu": container_cpu, "container_memory": container_memory, # MB (default 5GB) "container_disk": container_disk, # MB (default 50GB) - "container_persistent": os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"true", "1", "yes"}, + "container_persistent": os.getenv( + "TERMINAL_CONTAINER_PERSISTENT", + "false" if env_type == "tenki" else "true", + ).lower() in {"true", "1", "yes"}, "docker_volumes": docker_volumes, "docker_env": docker_env, "docker_run_as_host_user": os.getenv("TERMINAL_DOCKER_RUN_AS_HOST_USER", "false").lower() in {"true", "1", "yes"}, @@ -1387,6 +1437,37 @@ def _get_modal_backend_state(modal_mode: object | None) -> Dict[str, Any]: ) +def _container_config_from_env_config(config: Dict[str, Any]) -> Dict[str, Any]: + """Build the shared container-backend config passed to _create_environment.""" + return { + "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_network": config.get("docker_network", True), + "docker_persist_across_processes": config.get("docker_persist_across_processes", True), + "docker_orphan_reaper": config.get("docker_orphan_reaper", True), + "tenki_api_endpoint": config.get("tenki_api_endpoint", ""), + "tenki_workspace_id": config.get("tenki_workspace_id", ""), + "tenki_project_id": config.get("tenki_project_id", ""), + "tenki_name_prefix": config.get("tenki_name_prefix", "hermes"), + "tenki_allow_inbound": config.get("tenki_allow_inbound", False), + "tenki_allow_outbound": config.get("tenki_allow_outbound", True), + "tenki_max_duration": config.get("tenki_max_duration", 3600), + "tenki_idle_timeout": config.get("tenki_idle_timeout", 0), + "tenki_pause_retention": config.get("tenki_pause_retention", 0), + "tenki_sync_hermes_home": config.get("tenki_sync_hermes_home", False), + "tenki_forward_env": config.get("tenki_forward_env", []), + } + + def _create_environment(env_type: str, image: str, cwd: str, timeout: int, ssh_config: dict = None, container_config: dict = None, local_config: dict = None, @@ -1397,7 +1478,7 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, Args: env_type: One of "local", "docker", "singularity", "modal", - "daytona", "ssh" + "daytona", "tenki", "ssh" image: Docker/Singularity/Modal image name (ignored for local/ssh) cwd: Working directory timeout: Default command timeout @@ -1520,6 +1601,31 @@ def _create_environment(env_type: str, image: str, cwd: str, timeout: int, persistent_filesystem=persistent, task_id=task_id, ) + elif env_type == "tenki": + from tools.environments.tenki import TenkiEnvironment as _TenkiEnvironment + + return _TenkiEnvironment( + image=image, + cwd=cwd, + timeout=timeout, + cpu=cpu, + memory=memory, + disk=disk, + persistent_filesystem=persistent, + task_id=task_id, + api_endpoint=cc.get("tenki_api_endpoint", ""), + workspace_id=cc.get("tenki_workspace_id", ""), + project_id=cc.get("tenki_project_id", ""), + name_prefix=cc.get("tenki_name_prefix", "hermes"), + allow_inbound=cc.get("tenki_allow_inbound", False), + allow_outbound=cc.get("tenki_allow_outbound", True), + max_duration=cc.get("tenki_max_duration", 3600), + idle_timeout=cc.get("tenki_idle_timeout", 0), + pause_retention=cc.get("tenki_pause_retention", 0), + sync_hermes_home=cc.get("tenki_sync_hermes_home", False), + forward_env=cc.get("tenki_forward_env", []), + ) + 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") @@ -1535,7 +1641,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', or 'ssh'" + f"'singularity', 'modal', 'daytona', 'tenki', or 'ssh'" ) @@ -2090,6 +2196,8 @@ def terminal_tool( image = overrides.get("modal_image") or config["modal_image"] elif env_type == "daytona": image = overrides.get("daytona_image") or config["daytona_image"] + elif env_type == "tenki": + image = overrides.get("tenki_image") or config["tenki_image"] else: image = "" @@ -2105,7 +2213,11 @@ def terminal_tool( # Valid in-container override paths (RL/benchmark sandboxes that set # cwd to /workspace, /root, etc.) are absolute non-host paths and pass # through untouched. - if env_type in _CONTAINER_BACKENDS and _is_unusable_container_cwd(cwd): + if ( + env_type in _CONTAINER_BACKENDS + and _is_unusable_container_cwd(cwd) + and not _is_backend_guest_subpath(env_type, cwd) + ): if cwd != config["cwd"]: logger.info( "Ignoring host/relative cwd override %r for %s backend " @@ -2198,23 +2310,8 @@ def terminal_tool( } 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_network": config.get("docker_network", True), - "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) local_config = None if env_type == "local": @@ -2897,10 +2994,42 @@ def check_terminal_requirements() -> bool: from daytona import Daytona # noqa: F401 — SDK presence check return os.getenv("DAYTONA_API_KEY") is not None + elif env_type == "tenki": + if importlib.util.find_spec("tenki_sandbox") is None: + try: + from tools.lazy_deps import ensure as _lazy_ensure + + _lazy_ensure("terminal.tenki", prompt=False) + importlib.invalidate_caches() + except Exception as exc: + logger.error( + "tenki-sandbox is required for Tenki terminal backend: " + "pip install tenki-sandbox==0.1.1 (%s)", + exc, + ) + return False + if importlib.util.find_spec("tenki_sandbox") is None: + logger.error( + "tenki-sandbox is required for Tenki terminal backend: " + "pip install tenki-sandbox==0.1.1" + ) + return False + try: + from tools.tenki_config import has_tenki_auth + except Exception: + has_tenki_auth = lambda: False # noqa: E731 + if not has_tenki_auth(): + logger.error( + "Tenki backend selected but no Tenki auth was found. Run `tenki login` " + "or set TENKI_AUTH_TOKEN/TENKI_API_KEY." + ) + return False + return True + else: logger.error( "Unknown TERMINAL_ENV '%s'. Use one of: local, docker, singularity, " - "modal, daytona, ssh.", + "modal, daytona, tenki, ssh.", env_type, ) return False @@ -2919,6 +3048,7 @@ def check_terminal_requirements() -> bool: print(f" Environment type: {config['env_type']}") print(f" Docker image: {config['docker_image']}") print(f" Modal image: {config['modal_image']}") + print(f" Tenki image: {config['tenki_image'] or '(Tenki default)'}") print(f" Working directory: {config['cwd']}") print(f" Default timeout: {config['timeout']}s") print(f" Lifetime: {config['lifetime_seconds']}s") @@ -2943,12 +3073,13 @@ def check_terminal_requirements() -> bool: print( " TERMINAL_ENV: " f"{os.getenv('TERMINAL_ENV', 'local')} " - "(local/docker/singularity/modal/daytona/ssh)" + "(local/docker/singularity/modal/daytona/tenki/ssh)" ) print(f" TERMINAL_DOCKER_IMAGE: {os.getenv('TERMINAL_DOCKER_IMAGE', default_img)}") print(f" TERMINAL_SINGULARITY_IMAGE: {os.getenv('TERMINAL_SINGULARITY_IMAGE', f'docker://{default_img}')}") print(f" TERMINAL_MODAL_IMAGE: {os.getenv('TERMINAL_MODAL_IMAGE', default_img)}") print(f" TERMINAL_DAYTONA_IMAGE: {os.getenv('TERMINAL_DAYTONA_IMAGE', default_img)}") + print(f" TERMINAL_TENKI_IMAGE: {os.getenv('TERMINAL_TENKI_IMAGE', '(Tenki default)')}") print(f" TERMINAL_CWD: {os.getenv('TERMINAL_CWD', _safe_getcwd())}") from hermes_constants import display_hermes_home as _dhh print(f" TERMINAL_SANDBOX_DIR: {os.getenv('TERMINAL_SANDBOX_DIR', f'{_dhh()}/sandboxes')}") diff --git a/tools/tool_result_storage.py b/tools/tool_result_storage.py index b9ceccf75b43..fc1bd1afee88 100644 --- a/tools/tool_result_storage.py +++ b/tools/tool_result_storage.py @@ -152,7 +152,7 @@ def maybe_persist_tool_result( """Layer 2: persist oversized result into the sandbox, return preview + path. Writes via env.execute() so the file is accessible from any backend - (local, Docker, SSH, Modal, Daytona). Falls back to inline truncation + (local, Docker, SSH, Modal, Daytona, Tenki). Falls back to inline truncation if write fails or no env is available. Args: diff --git a/uv.lock b/uv.lock index 21bd0827c77e..07dde5e69015 100644 --- a/uv.lock +++ b/uv.lock @@ -1683,6 +1683,9 @@ teams = [ { name = "aiohttp" }, { name = "microsoft-teams-apps" }, ] +tenki = [ + { name = "tenki-sandbox" }, +] termux = [ { name = "agent-client-protocol" }, { name = "honcho-ai" }, @@ -1845,6 +1848,7 @@ requires-dist = [ { name = "starlette", marker = "extra == 'web'", specifier = "==1.0.1" }, { name = "supermemory", marker = "extra == 'supermemory'", specifier = "==3.50.0" }, { name = "tenacity", specifier = "==9.1.4" }, + { name = "tenki-sandbox", marker = "extra == 'tenki'", specifier = "==0.1.1" }, { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.21" }, { name = "tzdata", marker = "sys_platform == 'win32'", specifier = "==2025.3" }, { name = "urllib3", specifier = ">=2.7.0,<3" }, @@ -1853,7 +1857,7 @@ requires-dist = [ { name = "websockets", specifier = "==15.0.1" }, { name = "youtube-transcript-api", marker = "extra == 'youtube'", specifier = "==1.2.4" }, ] -provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] +provides-extras = ["anthropic", "exa", "firecrawl", "parallel-web", "fal", "edge-tts", "modal", "daytona", "tenki", "hindsight", "dev", "messaging", "cron", "slack", "matrix", "wecom", "cli", "tts-premium", "voice", "pty", "honcho", "supermemory", "mem0", "vision", "mcp", "nemo-relay", "homeassistant", "sms", "teams", "computer-use", "acp", "mistral", "bedrock", "vertex", "azure-identity", "termux", "termux-all", "dingtalk", "feishu", "google", "youtube", "web", "all"] [[package]] name = "hf-xet" @@ -4061,6 +4065,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, ] +[[package]] +name = "tenki-sandbox" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, + { name = "websocket-client" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/7f/6e59a084acece58d2349c80d95996cfc4971dcbc61038d6cc9a822edf3dc/tenki_sandbox-0.1.1.tar.gz", hash = "sha256:2748d3cb381c1f955163b368189899517aa04f70e59d64fb06a999f400dfb0b6", size = 107483, upload-time = "2026-06-10T09:23:28.337Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/6b/9e3a0eaf5064d47e3ec1115ba9585d117feb5507312e195d15ee9a9bba28/tenki_sandbox-0.1.1-py3-none-any.whl", hash = "sha256:826ab21db80b3ebe74c9a67f3eeb09f8d880535677f8d5dc3d03ad402210f97c", size = 94883, upload-time = "2026-06-10T09:23:26.958Z" }, +] + [[package]] name = "termcolor" version = "3.3.0" @@ -4395,6 +4413,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + [[package]] name = "websockets" version = "15.0.1" diff --git a/website/docs/developer-guide/architecture.md b/website/docs/developer-guide/architecture.md index 6d6ec8695ae4..6ec69cb6f14b 100644 --- a/website/docs/developer-guide/architecture.md +++ b/website/docs/developer-guide/architecture.md @@ -40,7 +40,7 @@ This page is the top-level map of Hermes Agent internals. Use it to orient yours ▼ ▼ ┌───────────────────┐ ┌──────────────────────┐ │ Session Storage │ │ Tool Backends │ -│ (SQLite + FTS5) │ │ Terminal (6 backends) │ +│ (SQLite + FTS5) │ │ Terminal (7 backends) │ │ hermes_state.py │ │ Browser (5 backends) │ │ gateway/session.py│ │ Web (4 backends) │ └───────────────────┘ │ MCP (dynamic) │ @@ -106,7 +106,7 @@ hermes-agent/ │ ├── credential_files.py # File-based credential passthrough │ ├── env_passthrough.py # Env var passthrough for sandboxes │ ├── ansi_strip.py # ANSI escape stripping -│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, singularity) +│ └── environments/ # Terminal backends (local, docker, ssh, modal, daytona, tenki, singularity) │ ├── gateway/ # Messaging platform gateway │ ├── run.py # GatewayRunner — message dispatch (large file) @@ -211,7 +211,7 @@ A shared runtime resolver used by CLI, gateway, cron, ACP, and auxiliary calls. ### Tool System -Central tool registry (`tools/registry.py`) with 70+ registered tools across ~28 toolsets. Each tool file self-registers at import time. The registry handles schema collection, dispatch, availability checking, and error wrapping. Terminal tools support 6 backends (local, Docker, SSH, Daytona, Modal, Singularity). +Central tool registry (`tools/registry.py`) with 70+ registered tools across ~28 toolsets. Each tool file self-registers at import time. The registry handles schema collection, dispatch, availability checking, and error wrapping. Terminal tools support 7 backends (local, Docker, SSH, Daytona, Tenki, Modal, Singularity). → [Tools Runtime](./tools-runtime.md) diff --git a/website/docs/getting-started/nix-setup.md b/website/docs/getting-started/nix-setup.md index 17d34883c9e6..8d3d364924e4 100644 --- a/website/docs/getting-started/nix-setup.md +++ b/website/docs/getting-started/nix-setup.md @@ -700,6 +700,7 @@ This is resolved by uv alongside core dependencies — no PYTHONPATH patching, n | `hindsight` | Hindsight memory provider | | `modal` | Modal terminal backend | | `daytona` | Daytona terminal backend | +| `tenki` | Tenki terminal backend | | `exa` | Exa web search | | `firecrawl` | Firecrawl web search | | `fal` | FAL image generation | diff --git a/website/docs/guides/tips.md b/website/docs/guides/tips.md index fd4501080a0d..4c792c9c9336 100644 --- a/website/docs/guides/tips.md +++ b/website/docs/guides/tips.md @@ -181,7 +181,7 @@ By default, messaging sessions never auto-reset — context lives until you `/re ### Use Docker for Untrusted Code -When working with untrusted repositories or running unfamiliar code, use Docker or Daytona as your terminal backend. Set `TERMINAL_BACKEND=docker` in your `.env`. Destructive commands inside a container can't harm your host system. +When working with untrusted repositories or running unfamiliar code, use Docker, Daytona, or Tenki as your terminal backend. Set `TERMINAL_BACKEND=docker` in your `.env`. Destructive commands inside a container can't harm your host system. ```bash # In your .env: @@ -217,7 +217,7 @@ When the agent triggers a dangerous command approval (`rm -rf`, `DROP TABLE`, et Hermes checks every command against a curated list of dangerous patterns before execution. This includes recursive deletes, SQL drops, piping curl to shell, and more. Don't disable this in production — it exists for good reasons. :::warning -When running in a container backend (Docker, Singularity, Modal, Daytona), dangerous command checks are **skipped** because the container is the security boundary. Make sure your container images are properly locked down. +When running in a container backend (Docker, Singularity, Modal, Daytona, Tenki), dangerous command checks are **skipped** because the container is the security boundary. Make sure your container images are properly locked down. ::: ### Use Allowlists for Messaging Bots diff --git a/website/docs/index.mdx b/website/docs/index.mdx index f7a943cba9f7..f8d07e24c272 100644 --- a/website/docs/index.mdx +++ b/website/docs/index.mdx @@ -121,7 +121,7 @@ It's not a coding copilot tethered to an IDE or a chatbot wrapper around a singl ## Key Features - **A closed learning loop** — Agent-curated memory with periodic nudges, autonomous skill creation, skill self-improvement during use, FTS5 cross-session recall with LLM summarization, and [Honcho](https://github.com/plastic-labs/honcho) dialectic user modeling -- **Runs anywhere, not just your laptop** — 6 terminal backends: local, Docker, SSH, Daytona, Singularity, Modal. Daytona and Modal offer serverless persistence — your environment hibernates when idle, costing nearly nothing +- **Runs anywhere, not just your laptop** — 7 terminal backends: local, Docker, SSH, Daytona, Tenki, Singularity, Modal. Cloud backends let your agent run isolated compute away from your host - **Lives where you do** — CLI, Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost, Email, SMS, DingTalk, Feishu, WeCom, Weixin, QQ Bot, Yuanbao, BlueBubbles, Home Assistant, Microsoft Teams, Google Chat, and more — 20+ platforms from one gateway - **Built by model trainers** — Created by [Nous Research](https://nousresearch.com), the lab behind Hermes, Nomos, and Psyche. Works with [Nous Portal](https://portal.nousresearch.com), [OpenRouter](https://openrouter.ai), OpenAI, or any endpoint - **Scheduled automations** — Built-in cron with delivery to any platform diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 53f7a18a0bed..97d7470ff8cd 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -162,6 +162,11 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `HINDSIGHT_TIMEOUT` | Timeout in seconds for Hindsight memory-provider API calls (default: `60`). Bump this if your Hindsight instance is slow to respond during `/sync` or `on_session_switch` and you're seeing timeouts in `errors.log`. | | `SUPERMEMORY_API_KEY` | Semantic long-term memory with profile recall and session ingest ([supermemory.ai](https://supermemory.ai)) | | `DAYTONA_API_KEY` | Daytona cloud sandboxes ([daytona.io](https://daytona.io/)) | +| `TENKI_AUTH_TOKEN` / `TENKI_API_KEY` | Tenki cloud sandboxes ([tenki.cloud](https://tenki.cloud)); alternatively run `tenki login` | +| `TENKI_CONFIG_PATH` | Override the Tenki CLI config path Hermes reads for auth, workspace, and project defaults | +| `TENKI_API_ENDPOINT` / `TENKI_API_URL` | Direct Tenki API endpoint override used when terminal config is blank | +| `TENKI_WORKSPACE_ID` / `TENKI_WORKSPACE` | Direct Tenki workspace ID override used when terminal config is blank | +| `TENKI_PROJECT_ID` / `TENKI_PROJECT` | Direct Tenki project ID override used when terminal config is blank | ### Skill API Keys @@ -205,7 +210,7 @@ These variables configure the [Tool Gateway](/user-guide/features/tool-gateway) | Variable | Description | |----------|-------------| -| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona` | +| `TERMINAL_ENV` | Backend: `local`, `docker`, `ssh`, `singularity`, `modal`, `daytona`, `tenki` | | `HERMES_DOCKER_BINARY` | Override the container binary Hermes shells out to (e.g. `podman`, `/usr/local/bin/docker`). When unset, Hermes auto-discovers `docker` or `podman` on `PATH`. Needed when both are installed and you want the non-default, or when the binary lives outside `PATH`. | | `TERMINAL_DOCKER_IMAGE` | Docker image (default: `nikolaik/python-nodejs:python3.11-nodejs20`) | | `TERMINAL_DOCKER_FORWARD_ENV` | JSON array of env var names to explicitly forward into Docker terminal sessions. Note: skill-declared `required_environment_variables` are forwarded automatically — you only need this for vars not declared by any skill. | @@ -214,6 +219,18 @@ These variables configure the [Tool Gateway](/user-guide/features/tool-gateway) | `TERMINAL_SINGULARITY_IMAGE` | Singularity image or `.sif` path | | `TERMINAL_MODAL_IMAGE` | Modal container image | | `TERMINAL_DAYTONA_IMAGE` | Daytona sandbox image | +| `TERMINAL_TENKI_IMAGE` | Optional Tenki sandbox image/template; blank uses Tenki default | +| `TERMINAL_TENKI_API_ENDPOINT` | Tenki API endpoint (default: `https://api.tenki.cloud`) | +| `TERMINAL_TENKI_WORKSPACE_ID` | Tenki workspace ID; blank falls back to Tenki CLI config | +| `TERMINAL_TENKI_PROJECT_ID` | Tenki project ID; blank falls back to Tenki CLI config | +| `TERMINAL_TENKI_NAME_PREFIX` | Prefix for Hermes-created Tenki sandbox names (default: `hermes`) | +| `TERMINAL_TENKI_ALLOW_INBOUND` | Allow inbound network access in Tenki sandboxes (`true`/`false`, default: `false`) | +| `TERMINAL_TENKI_ALLOW_OUTBOUND` | Allow outbound network access in Tenki sandboxes (`true`/`false`, default: `true`) | +| `TERMINAL_TENKI_MAX_DURATION` | Tenki sandbox maximum duration in seconds (default: `3600`) | +| `TERMINAL_TENKI_IDLE_TIMEOUT` | Tenki sandbox idle timeout in seconds; converted to minutes for the SDK (`0` disables explicit idle timeout) | +| `TERMINAL_TENKI_PAUSE_RETENTION` | Tenki pause retention duration in seconds (`0` uses Tenki default) | +| `TERMINAL_TENKI_SYNC_HERMES_HOME` | Opt-in sync of selected `~/.hermes` credentials, skills, and cache files into Tenki sandboxes (`true`/`false`, default: `false`) | +| `TERMINAL_TENKI_FORWARD_ENV` | JSON array of env var names to explicitly forward into Tenki sandboxes. Use this for task credentials such as `GITHUB_TOKEN`; values are resolved from the shell first, then `~/.hermes/.env`. | | `TERMINAL_TIMEOUT` | Command timeout in seconds | | `TERMINAL_LIFETIME_SECONDS` | Max lifetime for terminal sessions in seconds | | `TERMINAL_CWD` | Deprecated direct override for gateway/cron terminal sessions. Prefer `terminal.cwd` in `config.yaml`; CLI still uses the launch directory. | @@ -231,14 +248,14 @@ For cloud sandbox backends, persistence is filesystem-oriented. `TERMINAL_LIFETI | `TERMINAL_SSH_KEY` | Path to private key | | `TERMINAL_SSH_PERSISTENT` | Override persistent shell for SSH (default: follows `TERMINAL_PERSISTENT_SHELL`) | -## Container Resources (Docker, Singularity, Modal, Daytona) +## Container Resources (Docker, Singularity, Modal, Daytona, Tenki) | Variable | Description | |----------|-------------| | `TERMINAL_CONTAINER_CPU` | CPU cores (default: 1) | | `TERMINAL_CONTAINER_MEMORY` | Memory in MB (default: 5120) | | `TERMINAL_CONTAINER_DISK` | Disk in MB (default: 51200) | -| `TERMINAL_CONTAINER_PERSISTENT` | Persist container filesystem across sessions (default: `true`) | +| `TERMINAL_CONTAINER_PERSISTENT` | Persist container filesystem across sessions (default: `true`; Tenki defaults to `false` unless explicitly set) | | `TERMINAL_SANDBOX_DIR` | Host directory for workspaces and overlays (default: `~/.hermes/sandboxes/`) | ## Persistent Shell diff --git a/website/docs/user-guide/configuration.md b/website/docs/user-guide/configuration.md index e65399a67d85..e2e00b7a0320 100644 --- a/website/docs/user-guide/configuration.md +++ b/website/docs/user-guide/configuration.md @@ -108,11 +108,11 @@ Before that stash step, Hermes also restores tracked `package-lock.json` diffs l ## Terminal Backend Configuration -Hermes supports six 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, or a Singularity/Apptainer container. +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 Tenki sandbox, or a Singularity/Apptainer container. ```yaml terminal: - backend: local # local | docker | ssh | modal | daytona | singularity + backend: local # local | docker | ssh | modal | daytona | tenki | singularity cwd: "." # Gateway/cron working directory (CLI always uses launch dir) timeout: 180 # Per-command timeout in seconds home_mode: auto # auto | real | profile — subprocess HOME policy @@ -120,9 +120,15 @@ terminal: singularity_image: "docker://nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Singularity backend modal_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Modal backend daytona_image: "nikolaik/python-nodejs:python3.11-nodejs20" # Container image for Daytona backend + tenki_image: "" # Optional Tenki 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_sync_hermes_home: false # Opt-in sync of selected ~/.hermes files + tenki_forward_env: [] # Explicit host env vars to forward into Tenki ``` -For cloud sandboxes such as Modal and Daytona, `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, and Tenki, `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. Tenki defaults to `container_persistent: false` so sandboxes are terminated when Hermes cleans them up; when persistence is enabled, Hermes pauses and later resumes the matching Tenki sandbox. ### Backend Overview @@ -133,6 +139,7 @@ For cloud sandboxes such as Modal and Daytona, `container_persistent: true` mean | **ssh** | Remote server via SSH | Network boundary | Remote dev, powerful hardware | | **modal** | Modal cloud sandbox | Full (cloud VM) | Ephemeral cloud compute, evals | | **daytona** | Daytona workspace | Full (cloud container) | Managed cloud dev environments | +| **tenki** | Tenki sandbox | Full (cloud sandbox) | On-demand cloud compute | | **singularity** | Singularity/Apptainer container | Namespaces (--containall) | HPC clusters, shared machines | ### Local Backend @@ -370,6 +377,42 @@ terminal: **Disk limit:** Daytona enforces a 10 GiB maximum. Requests above this are capped with a warning. +### Tenki Backend + +Runs commands in a [Tenki](https://tenki.cloud) sandbox. Hermes creates sandboxes on demand and terminates them by default. + +```yaml +terminal: + backend: tenki + cwd: "/home/tenki" + container_persistent: false # Default for Tenki + tenki_api_endpoint: "https://api.tenki.cloud" + tenki_workspace_id: "" # Falls back to Tenki CLI config + tenki_project_id: "" # Falls back to Tenki CLI config + 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 # Opt in only if child sandboxes need selected ~/.hermes files + tenki_forward_env: [] # Explicit credentials like GITHUB_TOKEN / GH_TOKEN +``` + +**Required:** Tenki CLI login, `TENKI_AUTH_TOKEN`, or `TENKI_API_KEY`. Hermes also reads the Tenki CLI config for the current workspace and project IDs. + +**Persistence:** Tenki is terminate-only by default. Set `container_persistent: true` only if you intentionally want Hermes to pause and resume a task-named sandbox. + +**Sudo:** Tenki sandboxes use the sandbox's own sudoers policy. Hermes never prompts for or forwards the host `SUDO_PASSWORD` to Tenki. The default Tenki image supports passwordless sudo. + +**Optional `.hermes` sync:** Set `tenki_sync_hermes_home: true` if a Tenki sandbox needs the same selected credential, skill, and cache files that Modal and Daytona receive. Leave it off when Tenki is only an execution sandbox and secrets should remain with the supervisor process. + +**Credential forwarding:** `terminal.env_passthrough` intentionally blocks common credential names such as `GITHUB_TOKEN` and `GH_TOKEN`. For Git or package-manager tokens that must be visible inside Tenki sandboxes, list the variable names in `terminal.tenki_forward_env`; Hermes resolves them profile-scope-aware (from your current shell / the active profile scope first, then `~/.hermes/.env`). The supervisor's own Tenki control-plane token (`TENKI_AUTH_TOKEN` / `TENKI_API_KEY`) is **not** forwarded by default; add it to `tenki_forward_env` only when child sandboxes must create nested Tenki sandboxes, and note that anything forwarded is readable by (model-controlled) guest code. + +> **Multiplexing caveat:** forwarded credentials are not profile-isolated under the multiplexing gateway's shared terminal cache — two profiles served by one gateway process can reuse the same live sandbox. Avoid forwarding sensitive credentials (especially the control-plane token) into sandboxes when running multiple profiles from a single multiplexed gateway. + +**Fully remote supervisor pattern:** To make Hermes itself live remotely, run the Hermes process inside a long-lived Tenki supervisor sandbox and configure that process with `terminal.backend: tenki`. The supervisor owns `~/.hermes`, model credentials, sessions, memory, and gateway/dashboard processes. Terminal, file, `execute_code`, and delegated subagent execution then create child Tenki sandboxes on demand. Give the supervisor Tenki credentials with `tenki login`, `TENKI_AUTH_TOKEN`, or `TENKI_API_KEY`; child sandboxes do not need host sudo passwords. + ### 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. @@ -400,13 +443,14 @@ 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. +- **Tenki** — Needs Tenki CLI login or `TENKI_AUTH_TOKEN`/`TENKI_API_KEY`. Workspace/project can come from the Tenki CLI config or `terminal.tenki_workspace_id` / `terminal.tenki_project_id`. - **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. ### Remote-to-Host File Sync on Teardown -For the **SSH**, **Modal**, and **Daytona** backends (anywhere the agent's working tree lives on a different machine than the host running Hermes), Hermes tracks files the agent touched inside the remote sandbox and, on session teardown / sandbox cleanup, **syncs the modified files back to the host** under `~/.hermes/cache/remote-syncs//`. +For the **SSH**, **Modal**, **Daytona**, and **Tenki** backends (anywhere the agent's working tree lives on a different machine than the host running Hermes), Hermes tracks files the agent touched inside the remote sandbox and, on session teardown / sandbox cleanup, **syncs the modified files back to the host** under `~/.hermes/cache/remote-syncs//`. Tenki also supports opt-in selected `.hermes` credential/skill/cache sync with `terminal.tenki_sync_hermes_home: true`; it is disabled by default. - Triggers on: session close, `/new`, `/reset`, gateway message timeout, `delegate_task` subagent completion when the child used a remote backend. - Covers the whole tree the agent modified, not just files it explicitly opened. Additions, edits, and deletions are all captured. diff --git a/website/docs/user-guide/features/tools.md b/website/docs/user-guide/features/tools.md index 92a5bc069047..22a57e5f4dce 100644 --- a/website/docs/user-guide/features/tools.md +++ b/website/docs/user-guide/features/tools.md @@ -71,7 +71,7 @@ The terminal tool can execute commands in different environments: ```yaml # In ~/.hermes/config.yaml terminal: - backend: local # or: docker, ssh, singularity, modal, daytona + backend: local # or: docker, ssh, singularity, modal, daytona, tenki cwd: "." # Working directory timeout: 180 # Command timeout in seconds ``` @@ -128,7 +128,7 @@ Configure CPU, memory, disk, and persistence for all container backends: ```yaml terminal: - backend: docker # or singularity, modal, daytona + backend: docker # or singularity, modal, daytona, tenki container_cpu: 1 # CPU cores (default: 1) container_memory: 5120 # Memory in MB (default: 5GB) container_disk: 51200 # Disk in MB (default: 50GB) diff --git a/website/docs/user-guide/security.md b/website/docs/user-guide/security.md index 71d1b131d2ed..6848ac159912 100644 --- a/website/docs/user-guide/security.md +++ b/website/docs/user-guide/security.md @@ -183,7 +183,7 @@ The following patterns trigger approval prompts (defined in `tools/approval.py`) | `gateway run` with `&`/`disown`/`nohup`/`setsid` | Prevents starting gateway outside service manager | :::info -**Container bypass**: When running in `docker`, `singularity`, `modal`, or `daytona` 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`, or `tenki` 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) @@ -399,11 +399,11 @@ terminal: - **Ephemeral mode** (`container_persistent: false`): Uses tmpfs for workspace — everything is lost on cleanup :::tip -For production gateway deployments, use `docker`, `modal`, or `daytona` 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`, or `tenki` backend to isolate agent commands from your host system. This eliminates the need for dangerous command approval entirely. ::: :::warning -If you add names to `terminal.docker_forward_env`, those variables are intentionally injected into the container for terminal commands. This is useful for task-specific credentials like `GITHUB_TOKEN`, but it also means code running in the container can read and exfiltrate them. +If you add names to `terminal.docker_forward_env` or `terminal.tenki_forward_env`, those variables are intentionally injected into the sandbox for terminal commands. This is useful for task-specific credentials like `GITHUB_TOKEN`, but it also means code running in the sandbox can read and exfiltrate them. ::: ## Terminal Backend Security Comparison @@ -416,6 +416,7 @@ If you add names to `terminal.docker_forward_env`, those variables are intention | **singularity** | Container | ❌ Skipped | HPC environments | | **modal** | Cloud sandbox | ❌ Skipped | Scalable cloud isolation | | **daytona** | Cloud sandbox | ❌ Skipped | Persistent cloud workspaces | +| **tenki** | Cloud sandbox | ❌ Skipped | On-demand cloud compute | ## Environment Variable Passthrough {#environment-variable-passthrough} @@ -491,6 +492,7 @@ Paths are relative to `~/.hermes/`. Files are mounted to `/root/.hermes/` inside | **terminal** (local) | Blocks explicit Hermes infrastructure vars (provider keys, gateway tokens, tool API keys) | ✅ Passthrough vars bypass the blocklist | | **terminal** (Docker) | No host env vars by default | ✅ Passthrough vars + `docker_forward_env` forwarded via `-e` | | **terminal** (Modal) | No host env/files by default | ✅ Credential files mounted; env passthrough via sync | +| **terminal** (Tenki) | No arbitrary host env vars by default | ✅ Passthrough vars + `tenki_forward_env` forwarded via sandbox env | | **MCP** | Blocks everything except safe system vars + explicitly configured `env` | ❌ Not affected by passthrough (use MCP `env` config instead) | ### Security Considerations diff --git a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md index 2dde2ad9d124..8978978d6d76 100644 --- a/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md +++ b/website/docs/user-guide/skills/bundled/autonomous-ai-agents/autonomous-ai-agents-hermes-agent.md @@ -1034,7 +1034,7 @@ See `tests/agent/test_prompt_builder.py::TestEnvironmentHints` for a worked exam Factual guidance about the host OS, user home, cwd, terminal backend, and shell (bash vs. PowerShell on Windows) is emitted from `agent/prompt_builder.py::build_environment_hints()`. This is also where the WSL hint and per-backend probe logic live. The convention: - **Local terminal backend** → emit host info (OS, `$HOME`, cwd) + Windows-specific notes (hostname ≠ username, `terminal` uses bash not PowerShell). -- **Remote terminal backend** (anything in `_REMOTE_TERMINAL_BACKENDS`: `docker, singularity, modal, daytona, ssh, managed_modal`) → **suppress** host info entirely and describe only the backend. A live `uname`/`whoami`/`pwd` probe runs inside the backend via `tools.environments.get_environment(...).execute(...)`, cached per process in `_BACKEND_PROBE_CACHE`, with a static fallback if the probe times out. +- **Remote terminal backend** (anything in `_REMOTE_TERMINAL_BACKENDS`: `docker, singularity, modal, daytona, tenki, ssh, managed_modal`) → **suppress** host info entirely and describe only the backend. A live `uname`/`whoami`/`pwd` probe runs inside the backend via `tools.environments.get_environment(...).execute(...)`, cached per process in `_BACKEND_PROBE_CACHE`, with a static fallback if the probe times out. - **Key fact for prompt authoring:** when `TERMINAL_ENV != "local"`, *every* file tool (`read_file`, `write_file`, `patch`, `search_files`) runs inside the backend container, not on the host. The system prompt must never describe the host in that case — the agent can't touch it. Full design notes, the exact emitted strings, and testing pitfalls: diff --git a/website/docs/user-guide/skills/bundled/software-development/software-development-plan.md b/website/docs/user-guide/skills/bundled/software-development/software-development-plan.md index 36d390bd2f34..f1d43b2b6098 100644 --- a/website/docs/user-guide/skills/bundled/software-development/software-development-plan.md +++ b/website/docs/user-guide/skills/bundled/software-development/software-development-plan.md @@ -63,7 +63,7 @@ If the task is code-related, include exact file paths, likely test targets, and Save the plan with `write_file` under: - `.hermes/plans/YYYY-MM-DD_HHMMSS-.md` -Treat that as relative to the active working directory / backend workspace. Hermes file tools are backend-aware, so using this relative path keeps the plan with the workspace on local, docker, ssh, modal, and daytona backends. +Treat that as relative to the active working directory / backend workspace. Hermes file tools are backend-aware, so using this relative path keeps the plan with the workspace on local, docker, ssh, modal, daytona, and tenki backends. If the runtime provides a specific target path, use that exact path. If not, create a sensible timestamped filename yourself under `.hermes/plans/`. diff --git a/website/scripts/generate-llms-txt.py b/website/scripts/generate-llms-txt.py index a34c57792a3d..fbeb79fc5e65 100644 --- a/website/scripts/generate-llms-txt.py +++ b/website/scripts/generate-llms-txt.py @@ -204,7 +204,7 @@ def emit_llms_index() -> str: "autonomous coding and task agent with persistent memory, agent-created skills, " "and a messaging gateway that lives on 21+ messaging platforms — 19 native to " "the gateway plus IRC and Microsoft Teams via plugins (Telegram, Discord, Slack, " - "SMS, Matrix, ...). Runs on local, Docker, SSH, Daytona, Modal, or Singularity " + "SMS, Matrix, ...). Runs on local, Docker, SSH, Daytona, Tenki, Modal, or Singularity " "backends. Works with Nous Portal, OpenRouter, OpenAI, Anthropic, Google, or any " "OpenAI-compatible endpoint." )