From d9c09eb3a85cff62fb9fc70700ad9d640d89d0a1 Mon Sep 17 00:00:00 2001 From: Guzman Pintos Date: Wed, 22 Jul 2026 13:19:28 -0300 Subject: [PATCH 1/2] Add Tenki remote sandbox provider New sandbox provider backed by Tenki's Python SDK (tenki-sandbox): creates a session, bootstraps sandbox-agent inside it, and exposes it on a public Tenki preview URL. Registered in the provider registry, pyproject extras ([tenki]), dashboard (readiness probe, backend picker, config form), and docs. Resource limits validated at construction so bad values fail at config-save. Gated live e2e test plus unit tests. - Route bundle uploads over Tenki's data plane: sandbox-agent caps HTTP bodies at 2 MiB, so evo new 413'd on larger bundles. TenkiSandboxClient streams the bundle tar through the SDK's chunked write_stream and extracts it in-sandbox; everything else stays on sandbox-agent. - Raise from tear_down when close retries are exhausted, so an un-closable sandbox no longer looks cleaned up. - Surface which auth env var the readiness probe found (TENKI_AUTH_TOKEN vs TENKI_API_KEY) in the Backend tab. - Ship the Tenki brand mark (SVG) in the picker and fix static/logos packaging so vendor logos ship in the wheel. Requires an x86_64 Tenki image and a project id (config project_id or TENKI_PROJECT_ID; the SDK has no env fallback for it). --- README.md | 5 +- plugins/evo/npm/skills/infra-setup/SKILL.md | 4 +- .../infra-setup/references/provider-matrix.md | 1 + plugins/evo/pyproject.toml | 6 +- plugins/evo/skills/infra-setup/SKILL.md | 4 +- .../infra-setup/references/provider-matrix.md | 1 + .../backends/sandbox_providers/__init__.py | 15 + .../evo/backends/sandbox_providers/tenki.py | 382 ++ plugins/evo/src/evo/dashboard.py | 15 + plugins/evo/src/evo/static/app.js | 29 +- plugins/evo/src/evo/static/logos/tenki.svg | 5 + plugins/evo/uv.lock | 3550 +++++++++-------- tests/live/test_tenki.py | 149 + tests/unit/test_tenki_provider.py | 128 + 14 files changed, 2558 insertions(+), 1736 deletions(-) create mode 100644 plugins/evo/src/evo/backends/sandbox_providers/tenki.py create mode 100644 plugins/evo/src/evo/static/logos/tenki.svg create mode 100644 tests/live/test_tenki.py create mode 100644 tests/unit/test_tenki_provider.py diff --git a/README.md b/README.md index bee1d5d1..241016b9 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ You give it a codebase. It discovers metrics to optimize, sets up the evaluation - **Benchmark discovery.** The `discover` skill explores the repo, figures out what to measure, and instruments the evaluation. -Runs on Claude Code, Codex, Cursor, Kimi, OpenClaw, Hermes, Opencode, or Pi. Experiments run locally or on remote sandboxes — Modal, E2B, Daytona, AWS, Azure, SSH. +Runs on Claude Code, Codex, Cursor, Kimi, OpenClaw, Hermes, Opencode, or Pi. Experiments run locally or on remote sandboxes — Modal, E2B, Daytona, Tenki, AWS, Azure, SSH.

@@ -81,7 +81,7 @@ npm install -g @anthropic-ai/claude-code # or @openai/codex, openclaw, @eare evo install # claude-code | codex | cursor | hermes | kimi | opencode | openclaw | pi ``` -For remote backends, install with the matching provider extra: `uv tool install 'evo-hq-cli[modal]'` (or `[e2b]`, `[daytona]`, `[aws]`, `[azure]`, `[all]`). +For remote backends, install with the matching provider extra: `uv tool install 'evo-hq-cli[modal]'` (or `[e2b]`, `[daytona]`, `[tenki]`, `[aws]`, `[azure]`, `[all]`). ### Codex hook trust @@ -127,6 +127,7 @@ When `discover` builds a benchmark from scratch, it attaches a held-out-slice sc | **modal** | Modal serverless cloud | `uv tool install 'evo-hq-cli[modal]'` | | **e2b** | E2B cloud sandboxes | `uv tool install 'evo-hq-cli[e2b]'` | | **daytona** | Daytona cloud workspaces | `uv tool install 'evo-hq-cli[daytona]'` | +| **tenki** | Tenki cloud sandboxes | `uv tool install 'evo-hq-cli[tenki]'` | | **aws** | AWS EC2 sandboxes | `uv tool install 'evo-hq-cli[aws]'` | | **azure** | Azure VMs | `uv tool install 'evo-hq-cli[azure]'` | diff --git a/plugins/evo/npm/skills/infra-setup/SKILL.md b/plugins/evo/npm/skills/infra-setup/SKILL.md index daf30af1..747a51a6 100644 --- a/plugins/evo/npm/skills/infra-setup/SKILL.md +++ b/plugins/evo/npm/skills/infra-setup/SKILL.md @@ -6,7 +6,7 @@ evo_version: 0.8.0 # Infra Setup -Use this when the user wants to change where experiments run: local worktrees, pool slots, or a remote provider such as Modal, E2B, Daytona, AWS, Azure, SSH, manual, or a custom dotted-path provider. +Use this when the user wants to change where experiments run: local worktrees, pool slots, or a remote provider such as Modal, E2B, Daytona, Tenki, AWS, Azure, SSH, manual, or a custom dotted-path provider. ## Goals @@ -25,6 +25,7 @@ Use this when the user wants to change where experiments run: local worktrees, p - `modal` - `e2b` - `daytona` +- `tenki` - `aws` - `azure` - `manual` @@ -72,6 +73,7 @@ Before trying to switch a workspace to a remote provider, confirm the basics: - `modal`: auth + optional config - `e2b`: API key + optional config - `daytona`: API key and API URL/target if needed + - `tenki`: API key + project id + optional config - `aws`: creds, region, image, SSH key pair/private key, and usually network config - `azure`: subscription, resource group, region, SSH key/private key, and VM/image choices - `ssh`: reachable host, working SSH user, and key/port if needed diff --git a/plugins/evo/npm/skills/infra-setup/references/provider-matrix.md b/plugins/evo/npm/skills/infra-setup/references/provider-matrix.md index 35a1fe73..620a264e 100644 --- a/plugins/evo/npm/skills/infra-setup/references/provider-matrix.md +++ b/plugins/evo/npm/skills/infra-setup/references/provider-matrix.md @@ -7,6 +7,7 @@ Use this as the compact summary. This is setup guidance, not a runtime dependenc | `modal` | Modal Python SDK | If missing, install `evo-hq-cli[modal]` (or inject `modal` with `pipx`); then run `modal token new` | `app_name`, `gpu`, `region`, `timeout_seconds`, `health_timeout_seconds`, `apt_install`, `pip_install` | | `e2b` | E2B Python SDK | If missing, install `evo-hq-cli[e2b]` (or inject `e2b` with `pipx`); then `export E2B_API_KEY=...` | `template`, `api_key`, `domain`, `root`, `timeout_seconds`, `health_timeout_seconds`, `allow_internet_access`, `secure` | | `daytona` | Daytona Python SDK | If missing, install `evo-hq-cli[daytona]` (or inject `daytona` with `pipx`); then `export DAYTONA_API_KEY=...` | `api_key`, `api_url`, `target`, `timeout_seconds`, `health_timeout_seconds`, `ssh_host`, `ssh_port`, `ssh_token_ttl_minutes`, `sandbox_timeout_seconds` | +| `tenki` | Tenki Python SDK (`tenki-sandbox`) | If missing, install `evo-hq-cli[tenki]` (or inject `tenki-sandbox` with `pipx`); then `export TENKI_API_KEY=tk_...` and `export TENKI_PROJECT_ID=...` | `auth_token` (or `api_key`), `project_id`, `workspace_id`, `base_url`, `name_prefix`, `image`, `snapshot_id`, `cpu_cores`, `memory_mb`, `disk_size_gb`, `idle_timeout_minutes`, `root`, `timeout_seconds`, `create_timeout_seconds`, `bootstrap_timeout_seconds`, `health_timeout_seconds` | | `aws` | `boto3` | If missing, install `evo-hq-cli[aws]` (or inject `boto3` with `pipx`); then export AWS creds and region | `region`, `image_id`, `key_name`, `key`, `instance_type`, `subnet_id`, `security_group_ids`, `ssh_user`, `ssh_port`, `timeout_seconds`, `health_timeout_seconds`, `keep_warm` | | `azure` | Azure Python SDK (`azure-identity`, `azure-mgmt-resource`, `azure-mgmt-network`, `azure-mgmt-compute`) | If missing, install `evo-hq-cli[azure]`; then use `az login` or Azure env creds, and provide subscription/resource-group config | `subscription_id`, `resource_group`, `location`, `vm_size`, `image`, `key`, `ssh_public_key`, `ssh_user`, `ssh_cidr`, `vnet_cidr`, `subnet_cidr`, `ssh_port`, `timeout_seconds`, `health_timeout_seconds`, `keep_warm` | | `ssh` | local `ssh` transport | `ssh user@host` must work first; then add `-i` / `-p` if needed | `host`, `key`, `port`, `tunnel_port`, `keep_warm`, `health_timeout_seconds` | diff --git a/plugins/evo/pyproject.toml b/plugins/evo/pyproject.toml index ca96e550..5e625e44 100644 --- a/plugins/evo/pyproject.toml +++ b/plugins/evo/pyproject.toml @@ -27,6 +27,9 @@ e2b = [ daytona = [ "daytona>=0.170.0", ] +tenki = [ + "tenki-sandbox>=0.4.0", +] aws = [ "boto3>=1.42.66", ] @@ -40,6 +43,7 @@ all = [ "modal>=0.62.0", "e2b>=2.20.0", "daytona>=0.170.0", + "tenki-sandbox>=0.4.0", "boto3>=1.42.66", "azure-identity>=1.17.1", "azure-mgmt-resource>=23.1.1", @@ -77,4 +81,4 @@ script-files = [] where = ["src"] [tool.setuptools.package-data] -evo = ["static/*", "opencode_plugin/*.js", "openclaw_plugin/*.js", "openclaw_plugin/native/*.js", "openclaw_plugin/native/*.json", "openclaw_plugin/native/*.md"] +evo = ["static/*", "static/logos/*", "opencode_plugin/*.js", "openclaw_plugin/*.js", "openclaw_plugin/native/*.js", "openclaw_plugin/native/*.json", "openclaw_plugin/native/*.md"] diff --git a/plugins/evo/skills/infra-setup/SKILL.md b/plugins/evo/skills/infra-setup/SKILL.md index daf30af1..747a51a6 100644 --- a/plugins/evo/skills/infra-setup/SKILL.md +++ b/plugins/evo/skills/infra-setup/SKILL.md @@ -6,7 +6,7 @@ evo_version: 0.8.0 # Infra Setup -Use this when the user wants to change where experiments run: local worktrees, pool slots, or a remote provider such as Modal, E2B, Daytona, AWS, Azure, SSH, manual, or a custom dotted-path provider. +Use this when the user wants to change where experiments run: local worktrees, pool slots, or a remote provider such as Modal, E2B, Daytona, Tenki, AWS, Azure, SSH, manual, or a custom dotted-path provider. ## Goals @@ -25,6 +25,7 @@ Use this when the user wants to change where experiments run: local worktrees, p - `modal` - `e2b` - `daytona` +- `tenki` - `aws` - `azure` - `manual` @@ -72,6 +73,7 @@ Before trying to switch a workspace to a remote provider, confirm the basics: - `modal`: auth + optional config - `e2b`: API key + optional config - `daytona`: API key and API URL/target if needed + - `tenki`: API key + project id + optional config - `aws`: creds, region, image, SSH key pair/private key, and usually network config - `azure`: subscription, resource group, region, SSH key/private key, and VM/image choices - `ssh`: reachable host, working SSH user, and key/port if needed diff --git a/plugins/evo/skills/infra-setup/references/provider-matrix.md b/plugins/evo/skills/infra-setup/references/provider-matrix.md index 35a1fe73..620a264e 100644 --- a/plugins/evo/skills/infra-setup/references/provider-matrix.md +++ b/plugins/evo/skills/infra-setup/references/provider-matrix.md @@ -7,6 +7,7 @@ Use this as the compact summary. This is setup guidance, not a runtime dependenc | `modal` | Modal Python SDK | If missing, install `evo-hq-cli[modal]` (or inject `modal` with `pipx`); then run `modal token new` | `app_name`, `gpu`, `region`, `timeout_seconds`, `health_timeout_seconds`, `apt_install`, `pip_install` | | `e2b` | E2B Python SDK | If missing, install `evo-hq-cli[e2b]` (or inject `e2b` with `pipx`); then `export E2B_API_KEY=...` | `template`, `api_key`, `domain`, `root`, `timeout_seconds`, `health_timeout_seconds`, `allow_internet_access`, `secure` | | `daytona` | Daytona Python SDK | If missing, install `evo-hq-cli[daytona]` (or inject `daytona` with `pipx`); then `export DAYTONA_API_KEY=...` | `api_key`, `api_url`, `target`, `timeout_seconds`, `health_timeout_seconds`, `ssh_host`, `ssh_port`, `ssh_token_ttl_minutes`, `sandbox_timeout_seconds` | +| `tenki` | Tenki Python SDK (`tenki-sandbox`) | If missing, install `evo-hq-cli[tenki]` (or inject `tenki-sandbox` with `pipx`); then `export TENKI_API_KEY=tk_...` and `export TENKI_PROJECT_ID=...` | `auth_token` (or `api_key`), `project_id`, `workspace_id`, `base_url`, `name_prefix`, `image`, `snapshot_id`, `cpu_cores`, `memory_mb`, `disk_size_gb`, `idle_timeout_minutes`, `root`, `timeout_seconds`, `create_timeout_seconds`, `bootstrap_timeout_seconds`, `health_timeout_seconds` | | `aws` | `boto3` | If missing, install `evo-hq-cli[aws]` (or inject `boto3` with `pipx`); then export AWS creds and region | `region`, `image_id`, `key_name`, `key`, `instance_type`, `subnet_id`, `security_group_ids`, `ssh_user`, `ssh_port`, `timeout_seconds`, `health_timeout_seconds`, `keep_warm` | | `azure` | Azure Python SDK (`azure-identity`, `azure-mgmt-resource`, `azure-mgmt-network`, `azure-mgmt-compute`) | If missing, install `evo-hq-cli[azure]`; then use `az login` or Azure env creds, and provide subscription/resource-group config | `subscription_id`, `resource_group`, `location`, `vm_size`, `image`, `key`, `ssh_public_key`, `ssh_user`, `ssh_cidr`, `vnet_cidr`, `subnet_cidr`, `ssh_port`, `timeout_seconds`, `health_timeout_seconds`, `keep_warm` | | `ssh` | local `ssh` transport | `ssh user@host` must work first; then add `-i` / `-p` if needed | `host`, `key`, `port`, `tunnel_port`, `keep_warm`, `health_timeout_seconds` | diff --git a/plugins/evo/src/evo/backends/sandbox_providers/__init__.py b/plugins/evo/src/evo/backends/sandbox_providers/__init__.py index e5d6a439..818822fc 100644 --- a/plugins/evo/src/evo/backends/sandbox_providers/__init__.py +++ b/plugins/evo/src/evo/backends/sandbox_providers/__init__.py @@ -107,12 +107,27 @@ def _load_azure(config: dict[str, Any]) -> SandboxProvider: return _azure_module.AzureProvider(config) +def _load_tenki(config: dict[str, Any]) -> SandboxProvider: + try: + from . import tenki as _tenki_module + except ImportError as exc: + raise RemoteBackendUnavailable( + "Tenki provider requested but the 'tenki-sandbox' Python SDK is " + "not installed. Install it with: python -m pip install " + "'evo-hq-cli[tenki]' " + "(or `pipx inject evo-hq-cli tenki-sandbox` if evo itself was " + "installed with pipx)." + ) from exc + return _tenki_module.TenkiProvider(config) + + _LOADERS: dict[str, Callable[[dict[str, Any]], SandboxProvider]] = { "modal": _load_modal, "manual": _load_manual, "ssh": _load_ssh, "e2b": _load_e2b, "daytona": _load_daytona, + "tenki": _load_tenki, "aws": _load_aws, "azure": _load_azure, } diff --git a/plugins/evo/src/evo/backends/sandbox_providers/tenki.py b/plugins/evo/src/evo/backends/sandbox_providers/tenki.py new file mode 100644 index 00000000..1e1d7600 --- /dev/null +++ b/plugins/evo/src/evo/backends/sandbox_providers/tenki.py @@ -0,0 +1,382 @@ +"""Tenki sandbox provider. + +Uses Tenki's Python SDK (`tenki-sandbox`) to provision a sandbox session, +boot sandbox-agent inside it, and expose that service on a public Tenki +preview URL. Requires an x86_64 Tenki image (sandbox-agent ships as an +x86_64 musl binary) and a project id, which Tenki's SDK does not resolve +from the environment -- evo falls back to TENKI_PROJECT_ID itself. + +Tenki SDK reference: https://tenki.cloud/docs/sandbox/sdk +""" +from __future__ import annotations + +import os +import time +from typing import Any + +from tenki_sandbox import Client, Sandbox +from tenki_sandbox import ( + InvalidResourceConfigError, + MissingAuthTokenError, + SessionNotFoundError, + UnauthorizedError, +) + +try: + from tenki_sandbox._resource_validation import validate_create_resources +except ImportError: + validate_create_resources = None + +from ...sandbox_client import SandboxAgentClient +from ..protocol import ( + RemoteBackendUnavailable, + SandboxHandle, + SandboxSpec, +) +from ._common import ( + install_sandbox_agent_script, + SandboxAgentProviderMixin, + shell_quote, + wait_for_sandbox_agent, +) + + +DEFAULT_NAME_PREFIX = "evo-sandbox" +DEFAULT_TIMEOUT_SECONDS = 3600 +DEFAULT_CREATE_TIMEOUT_SECONDS = 180.0 +DEFAULT_BOOTSTRAP_TIMEOUT_SECONDS = 180.0 +DEFAULT_HEALTH_TIMEOUT = 60.0 +DEFAULT_ROOT = "/home/tenki/evo" + +_AUTH_HINT = ( + "Set TENKI_API_KEY (or TENKI_AUTH_TOKEN), or pass " + "--provider-config auth_token=tk_..." +) +UPLOAD_CHUNK_BYTES = 1024 * 1024 + + +class TenkiSandboxClient(SandboxAgentClient): + """sandbox-agent client that routes bulk uploads over Tenki's data plane. + + sandbox-agent caps HTTP request bodies at 2 MiB with no override flag, + which 413s any parent-commit bundle larger than that. Tenki's SDK + offers a chunked streaming write on its authenticated data plane, so + this client sends the bundle tar through that channel and extracts it + with an in-sandbox exec. Everything else stays on sandbox-agent. + """ + + def __init__( + self, + base_url: str, + bearer_token: str | None, + *, + provider: "TenkiProvider", + native_id: str, + ) -> None: + super().__init__(base_url, bearer_token) + self._provider = provider + self._native_id = native_id + + def clone(self) -> "TenkiSandboxClient": + return TenkiSandboxClient( + self.base_url, + self.bearer_token or None, + provider=self._provider, + native_id=self._native_id, + ) + + def fs_upload_batch(self, dest_dir: str, tar_bytes: bytes) -> None: + tar_path = f"{dest_dir.rstrip('/')}/.evo-upload.tar" + chunks = ( + tar_bytes[i : i + UPLOAD_CHUNK_BYTES] + for i in range(0, len(tar_bytes), UPLOAD_CHUNK_BYTES) + ) + with self._provider._client() as client: + sandbox = client.get(self._native_id) + sandbox.exec("mkdir", "-p", dest_dir, check=True) + sandbox.fs.write_stream(tar_path, chunks) + sandbox.exec("tar", "-xf", tar_path, "-C", dest_dir, check=True) + sandbox.exec("rm", "-f", tar_path) + + +class TenkiProvider(SandboxAgentProviderMixin): + name = "tenki" + + def __init__(self, config: dict[str, Any]) -> None: + self.config = dict(config) + self.auth_token = ( + str(config.get("auth_token", "")).strip() + or str(config.get("api_key", "")).strip() + or None + ) + self.base_url = str(config.get("base_url", "")).strip() or None + self.project_id = ( + str(config.get("project_id", "")).strip() + or os.environ.get("TENKI_PROJECT_ID", "").strip() + or None + ) + self.workspace_id = ( + str(config.get("workspace_id", "")).strip() + or os.environ.get("TENKI_WORKSPACE_ID", "").strip() + or None + ) + self.name_prefix = ( + str(config.get("name_prefix", DEFAULT_NAME_PREFIX)).strip() + or DEFAULT_NAME_PREFIX + ) + self.image = str(config.get("image", "")).strip() or None + self.snapshot_id = str(config.get("snapshot_id", "")).strip() or None + self.cpu_cores = _parse_positive_int(config.get("cpu_cores"), "cpu_cores") + self.memory_mb = _parse_positive_int(config.get("memory_mb"), "memory_mb") + self.disk_size_gb = _parse_positive_int( + config.get("disk_size_gb"), "disk_size_gb" + ) + self.idle_timeout_minutes = _parse_positive_int( + config.get("idle_timeout_minutes"), "idle_timeout_minutes" + ) + if validate_create_resources is not None: + try: + validate_create_resources( + self.cpu_cores, self.memory_mb, self.disk_size_gb + ) + except InvalidResourceConfigError as exc: + raise RemoteBackendUnavailable( + f"Tenki provider config rejected: {exc}" + ) from exc + self.root = str(config.get("root", DEFAULT_ROOT)).strip() or DEFAULT_ROOT + self.timeout = int(config.get("timeout_seconds", DEFAULT_TIMEOUT_SECONDS)) + self.create_timeout = float( + config.get("create_timeout_seconds", DEFAULT_CREATE_TIMEOUT_SECONDS) + ) + self.bootstrap_timeout = float( + config.get("bootstrap_timeout_seconds", DEFAULT_BOOTSTRAP_TIMEOUT_SECONDS) + ) + self.health_timeout = float( + config.get("health_timeout_seconds", DEFAULT_HEALTH_TIMEOUT) + ) + + def provision(self, spec: SandboxSpec) -> SandboxHandle: + max_duration = min(spec.timeout_seconds, self.timeout) + create_kwargs: dict[str, Any] = { + "name": self.name_prefix, + "wait": True, + "timeout": self.create_timeout, + "allow_inbound": True, + "allow_outbound": True, + "max_duration": max_duration, + "env": spec.env or None, + } + if self.project_id: + create_kwargs["project_id"] = self.project_id + if self.workspace_id: + create_kwargs["workspace_id"] = self.workspace_id + if self.cpu_cores is not None: + create_kwargs["cpu_cores"] = self.cpu_cores + if self.memory_mb is not None: + create_kwargs["memory_mb"] = self.memory_mb + if self.disk_size_gb is not None: + create_kwargs["disk_size_gb"] = self.disk_size_gb + if self.idle_timeout_minutes is not None: + create_kwargs["idle_timeout_minutes"] = self.idle_timeout_minutes + if self.snapshot_id: + create_kwargs["snapshot_id"] = self.snapshot_id + elif self.image: + create_kwargs["image"] = self.image + if self.auth_token: + create_kwargs["auth_token"] = self.auth_token + if self.base_url: + create_kwargs["base_url"] = self.base_url + + try: + sandbox = Sandbox.create(**create_kwargs) + except (MissingAuthTokenError, UnauthorizedError) as exc: + raise RemoteBackendUnavailable( + f"Tenki provider requested but authentication failed. {_AUTH_HINT}" + ) from exc + except Exception as exc: + hint = "" + if "project_id" in str(exc): + hint = ( + " Pass --provider-config project_id=... or set " + "TENKI_PROJECT_ID (find it with `tenki project list`)." + ) + raise RemoteBackendUnavailable( + f"Tenki sandbox creation failed: {exc}.{hint}" + ) from exc + + sandbox_id = sandbox.id + install_root = f"{self.root}/{sandbox_id}" + workspace_root = f"{install_root}/repo" + bundle_dir = f"{install_root}/bundles" + bin_path = f"{install_root}/bin/sandbox-agent" + log_path = f"{install_root}/sandbox-agent.log" + pid_path = f"{install_root}/sandbox-agent.pid" + + bootstrap = "\n".join([ + "set -e", + 'if [ "$(uname -m)" != "x86_64" ]; then', + " echo \"evo remote mode needs an x86_64 Tenki image; got $(uname -m)\" >&2", + " exit 1", + "fi", + f"mkdir -p {shell_quote(install_root)}/bin", + f"mkdir -p {shell_quote(workspace_root)}", + f"mkdir -p {shell_quote(bundle_dir)}", + "command -v git >/dev/null 2>&1 || {", + " echo 'git is required in the Tenki image for evo remote mode' >&2", + " exit 1", + "}", + install_sandbox_agent_script(bin_path), + f"if [ -s {shell_quote(pid_path)} ] && kill -0 \"$(cat {shell_quote(pid_path)})\" 2>/dev/null; then", + " exit 0", + "fi", + ( + f"nohup {shell_quote(bin_path)} server " + f"--token={shell_quote(spec.bearer_token)} " + f"--host 0.0.0.0 --port {spec.exposed_port} " + f">{shell_quote(log_path)} 2>&1 & echo $! > {shell_quote(pid_path)}" + ), + "sleep 0.5", + f"kill -0 \"$(cat {shell_quote(pid_path)})\"", + ]) + try: + result = sandbox.exec( + "bash", "-lc", bootstrap, + timeout=self.bootstrap_timeout, + ) + except Exception as exc: + note = self._close_or_leak_note(sandbox_id, sandbox) + raise RemoteBackendUnavailable( + f"Tenki sandbox bootstrap failed: {exc}{note}" + ) from exc + if result.exit_code != 0: + note = self._close_or_leak_note(sandbox_id, sandbox) + raise RemoteBackendUnavailable( + f"Tenki sandbox bootstrap failed: " + f"{result.stderr_text or result.stdout_text}{note}" + ) + + try: + exposed = sandbox.expose_port(spec.exposed_port, ttl=max_duration) + except Exception as exc: + note = self._close_or_leak_note(sandbox_id, sandbox) + raise RemoteBackendUnavailable( + f"Tenki sandbox {sandbox_id} could not expose port " + f"{spec.exposed_port}: {exc}{note}" + ) from exc + + base_url = exposed.url + if not base_url.startswith(("http://", "https://")): + base_url = f"https://{base_url}" + handle = SandboxHandle( + provider=self.name, + base_url=base_url, + bearer_token=spec.bearer_token, + native_id=sandbox_id, + metadata={ + "workspace_root": workspace_root, + "bundle_dir": bundle_dir, + "install_root": install_root, + "pid_path": pid_path, + "log_path": log_path, + }, + ) + try: + wait_for_sandbox_agent( + base_url, + spec.bearer_token, + timeout_s=self.health_timeout, + label=f"Tenki sandbox {sandbox_id}", + ) + except Exception as exc: + note = self._close_or_leak_note(sandbox_id, sandbox) + if note: + raise RemoteBackendUnavailable(f"{exc}{note}") from exc + raise + return handle + + def build_client(self, handle: SandboxHandle) -> TenkiSandboxClient: + return TenkiSandboxClient( + handle.base_url, + handle.bearer_token, + provider=self, + native_id=handle.native_id, + ) + + def tear_down(self, handle: SandboxHandle) -> None: + if not self._close_sandbox(handle.native_id): + raise RemoteBackendUnavailable( + f"Tenki sandbox {handle.native_id} could not be closed and " + f"may still be running; close it from the Tenki console or " + f"with the tenki CLI, or retry." + ) + + def is_alive(self, handle: SandboxHandle) -> bool: + try: + with self._client() as client: + sandbox = client.get(handle.native_id) + return sandbox.state == "RUNNING" + except Exception: + return False + + def _client(self) -> Client: + kwargs: dict[str, Any] = {} + if self.auth_token: + kwargs["auth_token"] = self.auth_token + if self.base_url: + kwargs["base_url"] = self.base_url + try: + return Client(**kwargs) + except MissingAuthTokenError as exc: + raise RemoteBackendUnavailable( + f"Tenki SDK initialization failed: no auth token. {_AUTH_HINT}" + ) from exc + + def _close_sandbox( + self, sandbox_id: str, sandbox: Sandbox | None = None + ) -> bool: + if sandbox is not None: + try: + sandbox.close_if_open() + return True + except SessionNotFoundError: + return True + except Exception: + pass + for attempt in range(2): + try: + with self._client() as client: + client.get(sandbox_id).close_if_open() + return True + except SessionNotFoundError: + return True + except Exception: + if attempt == 0: + time.sleep(1.0) + return False + + def _close_or_leak_note( + self, sandbox_id: str, sandbox: Sandbox | None = None + ) -> str: + if self._close_sandbox(sandbox_id, sandbox): + return "" + return ( + f" Cleanup also failed: sandbox {sandbox_id} may still be " + f"running and billing; close it from the Tenki console or with " + f"the tenki CLI." + ) + + +def _parse_positive_int(value: Any, key: str) -> int | None: + if value is None or str(value).strip() == "": + return None + try: + parsed = int(str(value).strip()) + except (TypeError, ValueError) as exc: + raise RemoteBackendUnavailable( + f"Tenki provider config {key!r} must be an integer, got {value!r}" + ) from exc + if parsed <= 0: + raise RemoteBackendUnavailable( + f"Tenki provider config {key!r} must be a positive integer, got {parsed}" + ) + return parsed diff --git a/plugins/evo/src/evo/dashboard.py b/plugins/evo/src/evo/dashboard.py index ff40583a..f4c380a2 100644 --- a/plugins/evo/src/evo/dashboard.py +++ b/plugins/evo/src/evo/dashboard.py @@ -230,6 +230,16 @@ def _provider_readiness(config: dict[str, Any]) -> dict[str, Any]: if provider == "e2b" and provider_config.get("api_key") else ("env" if os.environ.get("E2B_API_KEY") else "missing") ) + if provider == "tenki" and ( + provider_config.get("auth_token") or provider_config.get("api_key") + ): + tenki_source = "workspace-config" + elif os.environ.get("TENKI_AUTH_TOKEN"): + tenki_source = "env (TENKI_AUTH_TOKEN)" + elif os.environ.get("TENKI_API_KEY"): + tenki_source = "env (TENKI_API_KEY)" + else: + tenki_source = "missing" manual_cfg = provider_config if provider == "manual" else {} ssh_cfg = provider_config if provider == "ssh" else {} return { @@ -252,6 +262,11 @@ def _provider_readiness(config: dict[str, Any]) -> dict[str, Any]: "auth_present": daytona_auth_present, "auth_source": "env" if daytona_auth_present else "missing", }, + "tenki": { + "sdk_installed": _module_available("tenki_sandbox"), + "auth_present": tenki_source != "missing", + "auth_source": tenki_source, + }, "aws": { "sdk_installed": _module_available("boto3"), "auth_present": aws_auth_present, diff --git a/plugins/evo/src/evo/static/app.js b/plugins/evo/src/evo/static/app.js index 37d4d73e..65882434 100644 --- a/plugins/evo/src/evo/static/app.js +++ b/plugins/evo/src/evo/static/app.js @@ -102,6 +102,20 @@ const REMOTE_PROVIDER_FIELDS = { {key: 'ssh_token_ttl_minutes', label: 'SSH token TTL minutes', type: 'int', advanced: true}, {key: 'sandbox_timeout_seconds', label: 'Sandbox timeout', type: 'int', advanced: true}, ], + tenki: [ + {key: 'auth_token', label: 'API key', type: 'secret'}, + {key: 'project_id', label: 'Project ID', type: 'text'}, + {key: 'workspace_id', label: 'Workspace ID', type: 'text', advanced: true}, + {key: 'cpu_cores', label: 'CPU cores', type: 'int', min: 1}, + {key: 'memory_mb', label: 'Memory MB', type: 'int', min: 1}, + {key: 'image', label: 'Image', type: 'text'}, + {key: 'snapshot_id', label: 'Snapshot ID', type: 'text', advanced: true}, + {key: 'disk_size_gb', label: 'Disk size GB', type: 'int', min: 1, advanced: true}, + {key: 'base_url', label: 'API base URL', type: 'text', advanced: true}, + {key: 'idle_timeout_minutes', label: 'Idle timeout minutes', type: 'int', min: 1, advanced: true}, + {key: 'timeout_seconds', label: 'Timeout seconds', type: 'int', advanced: true}, + {key: 'health_timeout_seconds', label: 'Health timeout', type: 'float', advanced: true}, + ], aws: [ {key: 'region', label: 'Region', type: 'text'}, {key: 'image_id', label: 'Image ID', type: 'text'}, @@ -309,6 +323,11 @@ const BACKEND_FALLBACK_SVG = { `, + tenki: ``, aws: `