diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index d60a014c33..1ce12c26c5 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -1,11 +1,12 @@ import os import hashlib import warnings +from typing import List, Optional from uuid import getnode from json import loads from urllib.parse import urlparse, quote_plus -from pydantic import BaseModel, ConfigDict, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator _TRUTHY = {"true", "1", "t", "y", "yes", "on", "enable", "enabled"} @@ -969,28 +970,103 @@ def enabled(self) -> bool: _SANDBOX_LOCAL_WARNED = False +# Sandbox providers this runner can provision. Kept in lockstep with the runner's +# KNOWN_SANDBOX_PROVIDER_IDS and the SDK's KNOWN_SANDBOX_PROVIDERS so all three readers agree. +_KNOWN_SANDBOX_PROVIDERS = ("local", "daytona") + + +def _parse_enabled_sandbox_providers(raw: Optional[str]) -> List[str]: + """Parse ``AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS`` with the runner's rules. + + Unset -> ``["local"]``; explicit empty invalid; ids normalized lowercase; unknown and + duplicate ids invalid. Reimplements the TypeScript runner parser so the API's provider + pre-filter matches the runner's final authority (runner-selfhosting-cleanup/interface.md + sections 2 and 4). + """ + if raw is None: + return ["local"] + trimmed = raw.strip() + if trimmed == "": + raise ValueError( + "AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS is set but empty; unset it for the " + "default 'local', or list at least one provider." + ) + ids = [part.strip().lower() for part in trimmed.split(",")] + seen: set = set() + for provider_id in ids: + if provider_id == "": + raise ValueError( + f"AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS has an empty entry in '{trimmed}'." + ) + if provider_id not in _KNOWN_SANDBOX_PROVIDERS: + raise ValueError( + f"AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS lists unknown provider " + f"'{provider_id}'; known: {', '.join(_KNOWN_SANDBOX_PROVIDERS)}." + ) + if provider_id in seen: + raise ValueError( + f"AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS lists provider '{provider_id}' " + f"more than once." + ) + seen.add(provider_id) + return ids + + +def _parse_default_sandbox_provider(raw: Optional[str], enabled: List[str]) -> str: + """Parse ``AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER``; unset -> ``local``; must be enabled.""" + value = (raw or "").strip().lower() or "local" + if value not in _KNOWN_SANDBOX_PROVIDERS: + raise ValueError( + f"AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER is unknown provider '{value}'; " + f"known: {', '.join(_KNOWN_SANDBOX_PROVIDERS)}." + ) + if value not in enabled: + raise ValueError( + f"AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER '{value}' is not in the enabled set " + f"[{', '.join(enabled)}]." + ) + return value + + +def _enabled_sandbox_providers_default() -> List[str]: + return _parse_enabled_sandbox_providers( + os.getenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS") + ) + + +def _default_sandbox_provider_default() -> str: + return _parse_default_sandbox_provider( + os.getenv("AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER"), + _enabled_sandbox_providers_default(), + ) + class RunnerConfig(BaseModel): """Agent runner (services/runner) sidecar configuration.""" concurrency_limit: int = int(os.getenv("AGENTA_RUNNER_CONCURRENCY_LIMIT") or "1000") - # `local` sandbox runs unconfined host bash — not a tenant boundary; on by default - # for zero-config self-host. Canonical declaration; services/oss reads the same var directly. - sandbox_local_allowed: bool = ( - os.getenv("AGENTA_SANDBOX_LOCAL_ALLOWED") or "true" - ).lower() in _TRUTHY + # The sandbox-provider registry the runner and API share. `local` is unconfined host bash — + # not a tenant boundary; enabled by default for zero-config self-host. Canonical declaration; + # services/oss and the SDK read the same variables directly with the same rules. + enabled_sandbox_providers: List[str] = Field( + default_factory=_enabled_sandbox_providers_default + ) + default_sandbox_provider: str = Field( + default_factory=_default_sandbox_provider_default + ) model_config = ConfigDict(extra="ignore") @model_validator(mode="after") def _warn_local_sandbox(self) -> "RunnerConfig": global _SANDBOX_LOCAL_WARNED - if self.sandbox_local_allowed and not _SANDBOX_LOCAL_WARNED: + if "local" in self.enabled_sandbox_providers and not _SANDBOX_LOCAL_WARNED: _SANDBOX_LOCAL_WARNED = True warnings.warn( - "AGENTA_SANDBOX_LOCAL_ALLOWED is on (default): local sandbox is not a " - "tenant boundary. Set it to false to harden a shared/multi-tenant deployment.", + "local sandbox is enabled (default): it is unconfined host bash, not a " + "tenant boundary. Set AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS=daytona to " + "harden a shared/multi-tenant deployment.", stacklevel=2, ) return self diff --git a/api/oss/tests/pytest/unit/utils/test_env_runner_config.py b/api/oss/tests/pytest/unit/utils/test_env_runner_config.py index 4c09bcd573..caf102f5b4 100644 --- a/api/oss/tests/pytest/unit/utils/test_env_runner_config.py +++ b/api/oss/tests/pytest/unit/utils/test_env_runner_config.py @@ -1,60 +1,93 @@ -"""Unit tests for `RunnerConfig.sandbox_local_allowed` (api/oss/src/utils/env.py). +"""Unit tests for the API-side sandbox-provider registry parser (api/oss/src/utils/env.py). -Declarative config only: the value is a class-level default evaluated at import time -(same pattern as `WebhooksConfig.allow_insecure`), so tests reload the module after -setting/clearing the env var. See test_webhooks_utils.py for the precedent this mirrors. -The actual runtime gate lives in services/oss/src/agent/app.py (this env var's canonical -declaration only, not a second live enforcement point in the api service). +The API reimplements the runner's `AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS` / +`AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER` parsing so its provider pre-filter matches the +runner's final authority. These cases mirror the runner's TypeScript parser tests +(runner-selfhosting-cleanup/qa.md section 2) so both readers agree on every input. """ import importlib import pytest +from oss.src.utils import env -def test_sandbox_local_allowed_defaults_true(monkeypatch): - monkeypatch.delenv("AGENTA_SANDBOX_LOCAL_ALLOWED", raising=False) - from oss.src.utils import env - importlib.reload(env) - assert env.RunnerConfig().sandbox_local_allowed is True +# --- Parser rules (pure functions, no env) -------------------------------------- # -@pytest.mark.parametrize("value", ["true", "1", "yes", "on"]) -def test_sandbox_local_allowed_true_values(monkeypatch, value): - from oss.src.utils import env +def test_enabled_unset_gives_local(): + assert env._parse_enabled_sandbox_providers(None) == ["local"] - monkeypatch.setenv("AGENTA_SANDBOX_LOCAL_ALLOWED", value) - try: - importlib.reload(env) - assert env.RunnerConfig().sandbox_local_allowed is True - finally: - monkeypatch.delenv("AGENTA_SANDBOX_LOCAL_ALLOWED", raising=False) - importlib.reload(env) +def test_enabled_explicit_pair_is_set_equal_order_independent(): + a = env._parse_enabled_sandbox_providers("local,daytona") + b = env._parse_enabled_sandbox_providers("daytona,local") + assert sorted(a) == sorted(b) == ["daytona", "local"] -@pytest.mark.parametrize("value", ["false", "0", "no", "off"]) -def test_sandbox_local_allowed_false_values(monkeypatch, value): - from oss.src.utils import env - monkeypatch.setenv("AGENTA_SANDBOX_LOCAL_ALLOWED", value) - try: - importlib.reload(env) - assert env.RunnerConfig().sandbox_local_allowed is False - finally: - monkeypatch.delenv("AGENTA_SANDBOX_LOCAL_ALLOWED", raising=False) - importlib.reload(env) +def test_enabled_normalizes_whitespace_and_case(): + assert env._parse_enabled_sandbox_providers(" LOCAL , Daytona ") == [ + "local", + "daytona", + ] -def test_sandbox_local_allowed_true_when_unset_after_reload(monkeypatch): - from oss.src.utils import env +@pytest.mark.parametrize("raw", ["", " "]) +def test_enabled_explicit_empty_fails(raw): + with pytest.raises(ValueError): + env._parse_enabled_sandbox_providers(raw) - monkeypatch.delenv("AGENTA_SANDBOX_LOCAL_ALLOWED", raising=False) - try: - importlib.reload(env) - assert env.RunnerConfig().sandbox_local_allowed is True - finally: - importlib.reload(env) + +def test_enabled_duplicate_fails(): + with pytest.raises(ValueError): + env._parse_enabled_sandbox_providers("local,local") + + +def test_enabled_unknown_fails(): + with pytest.raises(ValueError): + env._parse_enabled_sandbox_providers("local,e2b") + + +def test_default_unset_gives_local(): + assert env._parse_default_sandbox_provider(None, ["local"]) == "local" + + +def test_default_outside_enabled_fails(): + with pytest.raises(ValueError): + env._parse_default_sandbox_provider("local", ["daytona"]) + + +def test_default_honors_explicit_enabled_value(): + assert ( + env._parse_default_sandbox_provider("daytona", ["local", "daytona"]) + == "daytona" + ) + + +# --- RunnerConfig integration (default_factory reads the environment) ----------- # + + +def test_runner_config_defaults_local_when_unset(monkeypatch): + monkeypatch.delenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", raising=False) + monkeypatch.delenv("AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER", raising=False) + config = env.RunnerConfig() + assert config.enabled_sandbox_providers == ["local"] + assert config.default_sandbox_provider == "local" + + +def test_runner_config_reads_enabled_pair(monkeypatch): + monkeypatch.setenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", "local,daytona") + monkeypatch.setenv("AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER", "daytona") + config = env.RunnerConfig() + assert config.enabled_sandbox_providers == ["local", "daytona"] + assert config.default_sandbox_provider == "daytona" + + +def test_runner_config_rejects_invalid_enabled(monkeypatch): + monkeypatch.setenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", "local,e2b") + with pytest.raises(Exception): + env.RunnerConfig() def test_sandbox_runner_defaults_local_when_unset(monkeypatch): diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index 3c6ad11ad8..a0010c6cb1 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -407,18 +407,17 @@ services: # OTLP tracing-export fallback AND the session heartbeat/record-persist calls. AGENTA_API_INTERNAL_URL: ${AGENTA_API_INTERNAL_URL:-http://api:8000} AGENTA_API_KEY: ${AGENTA_API_KEY:-} - SANDBOX_AGENT_PROVIDER: ${SANDBOX_AGENT_PROVIDER:-local} - DAYTONA_API_KEY: ${DAYTONA_API_KEY:-} - DAYTONA_API_URL: ${DAYTONA_API_URL:-} - DAYTONA_TARGET: ${DAYTONA_TARGET:-} - DAYTONA_SNAPSHOT: ${DAYTONA_SNAPSHOT:-agenta-sandbox-pi} - DAYTONA_SNAPSHOT_AGENT: ${DAYTONA_SNAPSHOT_AGENT:-} - DAYTONA_IMAGE: ${DAYTONA_IMAGE:-} - DAYTONA_AUTOSTOP: ${DAYTONA_AUTOSTOP:-} + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: ${AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS:-local} + AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER: ${AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER:-local} + AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} + AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} + AGENTA_RUNNER_DAYTONA_TARGET: ${AGENTA_RUNNER_DAYTONA_TARGET:-} + AGENTA_RUNNER_DAYTONA_SNAPSHOT: ${AGENTA_RUNNER_DAYTONA_SNAPSHOT:-} + AGENTA_RUNNER_DAYTONA_IMAGE: ${AGENTA_RUNNER_DAYTONA_IMAGE:-} + AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES:-} AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} - DAYTONA_AUTODELETE: ${DAYTONA_AUTODELETE:-} - AGENTA_AGENT_SANDBOX_PI_INSTALLED: ${AGENTA_AGENT_SANDBOX_PI_INSTALLED:-false} + AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} # === STORAGE ============================================== # volumes: - ../../../services/runner/src:/app/src diff --git a/hosting/docker-compose/ee/docker-compose.gh.local.yml b/hosting/docker-compose/ee/docker-compose.gh.local.yml index 082f9875d3..5b094fb9c9 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.local.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.local.yml @@ -273,19 +273,18 @@ services: AGENTA_RUNNER_HOST: ${AGENTA_RUNNER_HOST:-0.0.0.0} AGENTA_API_INTERNAL_URL: ${AGENTA_API_INTERNAL_URL:-http://api:8000} AGENTA_API_KEY: ${AGENTA_API_KEY:-} - SANDBOX_AGENT_PROVIDER: ${SANDBOX_AGENT_PROVIDER:-local} + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: ${AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS:-local} + AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER: ${AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER:-local} PI_CODING_AGENT_DIR: ${PI_CODING_AGENT_DIR:-/pi-agent} - DAYTONA_API_KEY: ${DAYTONA_API_KEY:-} - DAYTONA_API_URL: ${DAYTONA_API_URL:-} - DAYTONA_TARGET: ${DAYTONA_TARGET:-} - DAYTONA_SNAPSHOT: ${DAYTONA_SNAPSHOT:-} - DAYTONA_SNAPSHOT_AGENT: ${DAYTONA_SNAPSHOT_AGENT:-} - DAYTONA_IMAGE: ${DAYTONA_IMAGE:-} - DAYTONA_AUTOSTOP: ${DAYTONA_AUTOSTOP:-} + AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} + AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} + AGENTA_RUNNER_DAYTONA_TARGET: ${AGENTA_RUNNER_DAYTONA_TARGET:-} + AGENTA_RUNNER_DAYTONA_SNAPSHOT: ${AGENTA_RUNNER_DAYTONA_SNAPSHOT:-} + AGENTA_RUNNER_DAYTONA_IMAGE: ${AGENTA_RUNNER_DAYTONA_IMAGE:-} + AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES:-} AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} - DAYTONA_AUTODELETE: ${DAYTONA_AUTODELETE:-} - AGENTA_AGENT_SANDBOX_PI_INSTALLED: ${AGENTA_AGENT_SANDBOX_PI_INSTALLED:-true} + AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} # === NETWORK ============================================== # networks: - agenta-ee-gh-network diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index 3e76a5a21e..7da1ee30b4 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -271,19 +271,18 @@ services: AGENTA_RUNNER_HOST: ${AGENTA_RUNNER_HOST:-0.0.0.0} AGENTA_API_INTERNAL_URL: ${AGENTA_API_INTERNAL_URL:-http://api:8000} AGENTA_API_KEY: ${AGENTA_API_KEY:-} - SANDBOX_AGENT_PROVIDER: ${SANDBOX_AGENT_PROVIDER:-local} + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: ${AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS:-local} + AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER: ${AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER:-local} PI_CODING_AGENT_DIR: ${PI_CODING_AGENT_DIR:-/pi-agent} - DAYTONA_API_KEY: ${DAYTONA_API_KEY:-} - DAYTONA_API_URL: ${DAYTONA_API_URL:-} - DAYTONA_TARGET: ${DAYTONA_TARGET:-} - DAYTONA_SNAPSHOT: ${DAYTONA_SNAPSHOT:-} - DAYTONA_SNAPSHOT_AGENT: ${DAYTONA_SNAPSHOT_AGENT:-} - DAYTONA_IMAGE: ${DAYTONA_IMAGE:-} - DAYTONA_AUTOSTOP: ${DAYTONA_AUTOSTOP:-} + AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} + AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} + AGENTA_RUNNER_DAYTONA_TARGET: ${AGENTA_RUNNER_DAYTONA_TARGET:-} + AGENTA_RUNNER_DAYTONA_SNAPSHOT: ${AGENTA_RUNNER_DAYTONA_SNAPSHOT:-} + AGENTA_RUNNER_DAYTONA_IMAGE: ${AGENTA_RUNNER_DAYTONA_IMAGE:-} + AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES:-} AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} - DAYTONA_AUTODELETE: ${DAYTONA_AUTODELETE:-} - AGENTA_AGENT_SANDBOX_PI_INSTALLED: ${AGENTA_AGENT_SANDBOX_PI_INSTALLED:-true} + AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} # === NETWORK ============================================== # networks: - agenta-ee-gh-network diff --git a/hosting/docker-compose/ee/env.ee.dev.example b/hosting/docker-compose/ee/env.ee.dev.example index 19151c777d..2ec46545f7 100644 --- a/hosting/docker-compose/ee/env.ee.dev.example +++ b/hosting/docker-compose/ee/env.ee.dev.example @@ -57,7 +57,6 @@ AGENTA_CRYPT_KEY=replace-me # AGENTA_API_KEY= # AGENTA_AGENT_SANDBOX_PI_DIR=/home/sandbox/.pi/agent # AGENTA_AGENT_SANDBOX_PI_VERSION=0.80.6 -# AGENTA_AGENT_SANDBOX_PI_INSTALLED=true # ================================================================== # # Agenta - API @@ -113,9 +112,10 @@ AGENTA_INSECURE_EGRESS_ALLOWED=true # ================================================================== # # Agenta - Sandbox (agent runner) # ================================================================== # -# `local` sandbox runs unconfined host bash; default true, set to false to harden a -# shared/multi-tenant deployment. -AGENTA_SANDBOX_LOCAL_ALLOWED=true +# One operator entry, read by BOTH the api and the runner services. Comma-separated +# provider ids; unset means `local`. Set to `local,daytona` to also enable Daytona. +AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS=local +AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local # ================================================================== # # alembic @@ -145,15 +145,21 @@ AGENTA_SANDBOX_LOCAL_ALLOWED=true # ================================================================== # # daytona # ================================================================== # +# Code evaluator (api/services) — the code-execution sandbox. Keep the bare DAYTONA_* +# names; the code evaluator still reads them (its own rename is a follow-up). # DAYTONA_API_KEY= # DAYTONA_API_URL=https://app.daytona.io/api # DAYTONA_TARGET=eu -# DAYTONA_SNAPSHOT=agenta-sandbox-pi # DAYTONA_SNAPSHOT_CODE= -# DAYTONA_SNAPSHOT_AGENT= -# DAYTONA_IMAGE= -# DAYTONA_AUTOSTOP=15 # idle minutes before a sandbox is stopped (disk kept) -# DAYTONA_AUTODELETE=30 # idle minutes before a stopped sandbox is deleted +# DAYTONA_SNAPSHOT= # generic fallback snapshot when DAYTONA_SNAPSHOT_CODE is unset +# Agent runner — the agent sandbox. Read only by the runner service. +# AGENTA_RUNNER_DAYTONA_API_KEY= +# AGENTA_RUNNER_DAYTONA_API_URL=https://app.daytona.io/api +# AGENTA_RUNNER_DAYTONA_TARGET=eu +# AGENTA_RUNNER_DAYTONA_SNAPSHOT= # empty -> runner uses its pinned default snapshot +# AGENTA_RUNNER_DAYTONA_IMAGE= +# AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # idle minutes before a sandbox is stopped (disk kept) +# AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 # idle minutes before a stopped sandbox is deleted # AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS= # milliseconds to keep an idle session running between turns # AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM= # maximum idle sessions that may stay running between turns # NGROK_AUTHTOKEN= @@ -254,7 +260,8 @@ POSTHOG_API_KEY=phc_3urGRy5TL1HhaHnRYL0JSHxJxigRVackhphHtozUmdp # ================================================================== # # runner (sandboxagent.dev) # ================================================================== # -# SANDBOX_AGENT_PROVIDER=local +# Provider selection moved to the "Agenta - Sandbox (agent runner)" section above +# (AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS / AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER). # ================================================================== # # smtp diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index aae4b996cb..f436232ae2 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -59,7 +59,6 @@ AGENTA_CRYPT_KEY=replace-me # AGENTA_API_KEY= # AGENTA_AGENT_SANDBOX_PI_DIR=/home/sandbox/.pi/agent # AGENTA_AGENT_SANDBOX_PI_VERSION=0.80.6 -# AGENTA_AGENT_SANDBOX_PI_INSTALLED=true # ================================================================== # # Agenta - API @@ -115,9 +114,10 @@ AGENTA_CRYPT_KEY=replace-me # ================================================================== # # Agenta - Sandbox (agent runner) # ================================================================== # -# `local` sandbox runs unconfined host bash; default true, set to false to harden a -# shared/multi-tenant deployment. -# AGENTA_SANDBOX_LOCAL_ALLOWED=true +# One operator entry, read by BOTH the api and the runner services. Comma-separated +# provider ids; unset means `local`. Set to `local,daytona` to also enable Daytona. +# AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS=local +# AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local # ================================================================== # # alembic @@ -147,15 +147,21 @@ AGENTA_CRYPT_KEY=replace-me # ================================================================== # # daytona # ================================================================== # +# Code evaluator (api/services) — the code-execution sandbox. Keep the bare DAYTONA_* +# names; the code evaluator still reads them (its own rename is a follow-up). # DAYTONA_API_KEY= # DAYTONA_API_URL=https://app.daytona.io/api # DAYTONA_TARGET=eu -# DAYTONA_SNAPSHOT= # DAYTONA_SNAPSHOT_CODE= -# DAYTONA_SNAPSHOT_AGENT= -# DAYTONA_IMAGE= -# DAYTONA_AUTOSTOP=15 # idle minutes before a sandbox is stopped (disk kept) -# DAYTONA_AUTODELETE=30 # idle minutes before a stopped sandbox is deleted +# DAYTONA_SNAPSHOT= # generic fallback snapshot when DAYTONA_SNAPSHOT_CODE is unset +# Agent runner — the agent sandbox. Read only by the runner service. +# AGENTA_RUNNER_DAYTONA_API_KEY= +# AGENTA_RUNNER_DAYTONA_API_URL=https://app.daytona.io/api +# AGENTA_RUNNER_DAYTONA_TARGET=eu +# AGENTA_RUNNER_DAYTONA_SNAPSHOT= # empty -> runner uses its pinned default snapshot +# AGENTA_RUNNER_DAYTONA_IMAGE= +# AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # idle minutes before a sandbox is stopped (disk kept) +# AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 # idle minutes before a stopped sandbox is deleted # AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS= # milliseconds to keep an idle session running between turns # AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM= # maximum idle sessions that may stay running between turns @@ -255,7 +261,8 @@ POSTHOG_API_KEY=phc_3urGRy5TL1HhaHnRYL0JSHxJxigRVackhphHtozUmdp # ================================================================== # # runner (sandboxagent.dev) # ================================================================== # -# SANDBOX_AGENT_PROVIDER=local +# Provider selection moved to the "Agenta - Sandbox (agent runner)" section above +# (AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS / AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER). # ================================================================== # # smtp diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index 1029355268..bfa6934620 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -391,18 +391,17 @@ services: # fallback AND the session heartbeat/record-persist calls. AGENTA_API_INTERNAL_URL: ${AGENTA_API_INTERNAL_URL:-http://api:8000} AGENTA_API_KEY: ${AGENTA_API_KEY:-} - SANDBOX_AGENT_PROVIDER: ${SANDBOX_AGENT_PROVIDER:-local} - DAYTONA_API_KEY: ${DAYTONA_API_KEY:-} - DAYTONA_API_URL: ${DAYTONA_API_URL:-} - DAYTONA_TARGET: ${DAYTONA_TARGET:-} - DAYTONA_SNAPSHOT: ${DAYTONA_SNAPSHOT:-agenta-sandbox-pi} - DAYTONA_SNAPSHOT_AGENT: ${DAYTONA_SNAPSHOT_AGENT:-} - DAYTONA_IMAGE: ${DAYTONA_IMAGE:-} - DAYTONA_AUTOSTOP: ${DAYTONA_AUTOSTOP:-} + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: ${AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS:-local} + AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER: ${AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER:-local} + AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} + AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} + AGENTA_RUNNER_DAYTONA_TARGET: ${AGENTA_RUNNER_DAYTONA_TARGET:-} + AGENTA_RUNNER_DAYTONA_SNAPSHOT: ${AGENTA_RUNNER_DAYTONA_SNAPSHOT:-} + AGENTA_RUNNER_DAYTONA_IMAGE: ${AGENTA_RUNNER_DAYTONA_IMAGE:-} + AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES:-} AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} - DAYTONA_AUTODELETE: ${DAYTONA_AUTODELETE:-} - AGENTA_AGENT_SANDBOX_PI_INSTALLED: ${AGENTA_AGENT_SANDBOX_PI_INSTALLED:-false} + AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} # === STORAGE ============================================== # volumes: - ../../../services/runner/src:/app/src diff --git a/hosting/docker-compose/oss/docker-compose.gh.local.yml b/hosting/docker-compose/oss/docker-compose.gh.local.yml index abca29fb6c..2f12342c7e 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.local.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.local.yml @@ -271,19 +271,18 @@ services: AGENTA_RUNNER_HOST: ${AGENTA_RUNNER_HOST:-0.0.0.0} AGENTA_API_INTERNAL_URL: ${AGENTA_API_INTERNAL_URL:-http://api:8000} AGENTA_API_KEY: ${AGENTA_API_KEY:-} - SANDBOX_AGENT_PROVIDER: ${SANDBOX_AGENT_PROVIDER:-local} + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: ${AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS:-local} + AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER: ${AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER:-local} PI_CODING_AGENT_DIR: ${PI_CODING_AGENT_DIR:-/pi-agent} - DAYTONA_API_KEY: ${DAYTONA_API_KEY:-} - DAYTONA_API_URL: ${DAYTONA_API_URL:-} - DAYTONA_TARGET: ${DAYTONA_TARGET:-} - DAYTONA_SNAPSHOT: ${DAYTONA_SNAPSHOT:-} - DAYTONA_SNAPSHOT_AGENT: ${DAYTONA_SNAPSHOT_AGENT:-} - DAYTONA_IMAGE: ${DAYTONA_IMAGE:-} - DAYTONA_AUTOSTOP: ${DAYTONA_AUTOSTOP:-} + AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} + AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} + AGENTA_RUNNER_DAYTONA_TARGET: ${AGENTA_RUNNER_DAYTONA_TARGET:-} + AGENTA_RUNNER_DAYTONA_SNAPSHOT: ${AGENTA_RUNNER_DAYTONA_SNAPSHOT:-} + AGENTA_RUNNER_DAYTONA_IMAGE: ${AGENTA_RUNNER_DAYTONA_IMAGE:-} + AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES:-} AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} - DAYTONA_AUTODELETE: ${DAYTONA_AUTODELETE:-} - AGENTA_AGENT_SANDBOX_PI_INSTALLED: ${AGENTA_AGENT_SANDBOX_PI_INSTALLED:-true} + AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} # === NETWORK ============================================== # networks: - agenta-oss-gh-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml index 402b5371d5..39d689c441 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml @@ -296,18 +296,17 @@ services: # fallback AND the session heartbeat/record-persist calls. AGENTA_API_INTERNAL_URL: ${AGENTA_API_INTERNAL_URL:-http://api:8000} AGENTA_API_KEY: ${AGENTA_API_KEY:-} - SANDBOX_AGENT_PROVIDER: ${SANDBOX_AGENT_PROVIDER:-local} - DAYTONA_API_KEY: ${DAYTONA_API_KEY:-} - DAYTONA_API_URL: ${DAYTONA_API_URL:-} - DAYTONA_TARGET: ${DAYTONA_TARGET:-} - DAYTONA_SNAPSHOT: ${DAYTONA_SNAPSHOT:-} - DAYTONA_SNAPSHOT_AGENT: ${DAYTONA_SNAPSHOT_AGENT:-} - DAYTONA_IMAGE: ${DAYTONA_IMAGE:-} - DAYTONA_AUTOSTOP: ${DAYTONA_AUTOSTOP:-} + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: ${AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS:-local} + AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER: ${AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER:-local} + AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} + AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} + AGENTA_RUNNER_DAYTONA_TARGET: ${AGENTA_RUNNER_DAYTONA_TARGET:-} + AGENTA_RUNNER_DAYTONA_SNAPSHOT: ${AGENTA_RUNNER_DAYTONA_SNAPSHOT:-} + AGENTA_RUNNER_DAYTONA_IMAGE: ${AGENTA_RUNNER_DAYTONA_IMAGE:-} + AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES:-} AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} - DAYTONA_AUTODELETE: ${DAYTONA_AUTODELETE:-} - AGENTA_AGENT_SANDBOX_PI_INSTALLED: ${AGENTA_AGENT_SANDBOX_PI_INSTALLED:-true} + AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} # === NETWORK ============================================== # networks: - agenta-gh-ssl-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index 7f0d32aca7..05097862fd 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -288,19 +288,18 @@ services: AGENTA_RUNNER_HOST: ${AGENTA_RUNNER_HOST:-0.0.0.0} AGENTA_API_INTERNAL_URL: ${AGENTA_API_INTERNAL_URL:-http://api:8000} AGENTA_API_KEY: ${AGENTA_API_KEY:-} - SANDBOX_AGENT_PROVIDER: ${SANDBOX_AGENT_PROVIDER:-local} + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: ${AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS:-local} + AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER: ${AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER:-local} PI_CODING_AGENT_DIR: ${PI_CODING_AGENT_DIR:-/pi-agent} - DAYTONA_API_KEY: ${DAYTONA_API_KEY:-} - DAYTONA_API_URL: ${DAYTONA_API_URL:-} - DAYTONA_TARGET: ${DAYTONA_TARGET:-} - DAYTONA_SNAPSHOT: ${DAYTONA_SNAPSHOT:-} - DAYTONA_SNAPSHOT_AGENT: ${DAYTONA_SNAPSHOT_AGENT:-} - DAYTONA_IMAGE: ${DAYTONA_IMAGE:-} - DAYTONA_AUTOSTOP: ${DAYTONA_AUTOSTOP:-} + AGENTA_RUNNER_DAYTONA_API_KEY: ${AGENTA_RUNNER_DAYTONA_API_KEY:-} + AGENTA_RUNNER_DAYTONA_API_URL: ${AGENTA_RUNNER_DAYTONA_API_URL:-} + AGENTA_RUNNER_DAYTONA_TARGET: ${AGENTA_RUNNER_DAYTONA_TARGET:-} + AGENTA_RUNNER_DAYTONA_SNAPSHOT: ${AGENTA_RUNNER_DAYTONA_SNAPSHOT:-} + AGENTA_RUNNER_DAYTONA_IMAGE: ${AGENTA_RUNNER_DAYTONA_IMAGE:-} + AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES:-} AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} - DAYTONA_AUTODELETE: ${DAYTONA_AUTODELETE:-} - AGENTA_AGENT_SANDBOX_PI_INSTALLED: ${AGENTA_AGENT_SANDBOX_PI_INSTALLED:-true} + AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} # === NETWORK ============================================== # networks: - agenta-oss-gh-network diff --git a/hosting/docker-compose/oss/env.oss.dev.example b/hosting/docker-compose/oss/env.oss.dev.example index 41f299964f..a1d5887e97 100644 --- a/hosting/docker-compose/oss/env.oss.dev.example +++ b/hosting/docker-compose/oss/env.oss.dev.example @@ -57,7 +57,6 @@ AGENTA_CRYPT_KEY=replace-me # AGENTA_API_KEY= # AGENTA_AGENT_SANDBOX_PI_DIR=/home/sandbox/.pi/agent # AGENTA_AGENT_SANDBOX_PI_VERSION=0.80.6 -# AGENTA_AGENT_SANDBOX_PI_INSTALLED=true # ================================================================== # # Agenta - API @@ -113,9 +112,10 @@ AGENTA_INSECURE_EGRESS_ALLOWED=true # ================================================================== # # Agenta - Sandbox (agent runner) # ================================================================== # -# `local` sandbox runs unconfined host bash; default true, set to false to harden a -# shared/multi-tenant deployment. -AGENTA_SANDBOX_LOCAL_ALLOWED=true +# One operator entry, read by BOTH the api and the runner services. Comma-separated +# provider ids; unset means `local`. Set to `local,daytona` to also enable Daytona. +AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS=local +AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local # ================================================================== # # alembic @@ -145,15 +145,21 @@ AGENTA_SANDBOX_LOCAL_ALLOWED=true # ================================================================== # # daytona # ================================================================== # +# Code evaluator (api/services) — the code-execution sandbox. Keep the bare DAYTONA_* +# names; the code evaluator still reads them (its own rename is a follow-up). # DAYTONA_API_KEY= # DAYTONA_API_URL=https://app.daytona.io/api # DAYTONA_TARGET=eu -# DAYTONA_SNAPSHOT=agenta-sandbox-pi # DAYTONA_SNAPSHOT_CODE= -# DAYTONA_SNAPSHOT_AGENT= -# DAYTONA_IMAGE= -# DAYTONA_AUTOSTOP=15 # idle minutes before a sandbox is stopped (disk kept) -# DAYTONA_AUTODELETE=30 # idle minutes before a stopped sandbox is deleted +# DAYTONA_SNAPSHOT= # generic fallback snapshot when DAYTONA_SNAPSHOT_CODE is unset +# Agent runner — the agent sandbox. Read only by the runner service. +# AGENTA_RUNNER_DAYTONA_API_KEY= +# AGENTA_RUNNER_DAYTONA_API_URL=https://app.daytona.io/api +# AGENTA_RUNNER_DAYTONA_TARGET=eu +# AGENTA_RUNNER_DAYTONA_SNAPSHOT= # empty -> runner uses its pinned default snapshot +# AGENTA_RUNNER_DAYTONA_IMAGE= +# AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # idle minutes before a sandbox is stopped (disk kept) +# AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 # idle minutes before a stopped sandbox is deleted # AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS= # milliseconds to keep an idle session running between turns # AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM= # maximum idle sessions that may stay running between turns # NGROK_AUTHTOKEN= @@ -254,7 +260,8 @@ POSTHOG_API_KEY=phc_hmVSxIjTW1REBHXgj2aw4HW9X6CXb6FzerBgP9XenC7 # ================================================================== # # runner (sandboxagent.dev) # ================================================================== # -# SANDBOX_AGENT_PROVIDER=local +# Provider selection moved to the "Agenta - Sandbox (agent runner)" section above +# (AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS / AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER). # ================================================================== # # smtp diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index 4d0ba12d02..0c122b61f9 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -59,7 +59,6 @@ AGENTA_CRYPT_KEY=replace-me # AGENTA_API_KEY= # AGENTA_AGENT_SANDBOX_PI_DIR=/home/sandbox/.pi/agent # AGENTA_AGENT_SANDBOX_PI_VERSION=0.80.6 -# AGENTA_AGENT_SANDBOX_PI_INSTALLED=true # ================================================================== # # Agenta - API @@ -115,9 +114,10 @@ AGENTA_CRYPT_KEY=replace-me # ================================================================== # # Agenta - Sandbox (agent runner) # ================================================================== # -# `local` sandbox runs unconfined host bash; default true, set to false to harden a -# shared/multi-tenant deployment. -# AGENTA_SANDBOX_LOCAL_ALLOWED=true +# One operator entry, read by BOTH the api and the runner services. Comma-separated +# provider ids; unset means `local`. Set to `local,daytona` to also enable Daytona. +# AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS=local +# AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local # ================================================================== # # alembic @@ -147,15 +147,21 @@ AGENTA_CRYPT_KEY=replace-me # ================================================================== # # daytona # ================================================================== # +# Code evaluator (api/services) — the code-execution sandbox. Keep the bare DAYTONA_* +# names; the code evaluator still reads them (its own rename is a follow-up). # DAYTONA_API_KEY= # DAYTONA_API_URL=https://app.daytona.io/api # DAYTONA_TARGET=eu -# DAYTONA_SNAPSHOT= # DAYTONA_SNAPSHOT_CODE= -# DAYTONA_SNAPSHOT_AGENT= -# DAYTONA_IMAGE= -# DAYTONA_AUTOSTOP=15 # idle minutes before a sandbox is stopped (disk kept) -# DAYTONA_AUTODELETE=30 # idle minutes before a stopped sandbox is deleted +# DAYTONA_SNAPSHOT= # generic fallback snapshot when DAYTONA_SNAPSHOT_CODE is unset +# Agent runner — the agent sandbox. Read only by the runner service. +# AGENTA_RUNNER_DAYTONA_API_KEY= +# AGENTA_RUNNER_DAYTONA_API_URL=https://app.daytona.io/api +# AGENTA_RUNNER_DAYTONA_TARGET=eu +# AGENTA_RUNNER_DAYTONA_SNAPSHOT= # empty -> runner uses its pinned default snapshot +# AGENTA_RUNNER_DAYTONA_IMAGE= +# AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # idle minutes before a sandbox is stopped (disk kept) +# AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 # idle minutes before a stopped sandbox is deleted # AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS= # milliseconds to keep an idle session running between turns # AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM= # maximum idle sessions that may stay running between turns @@ -255,7 +261,8 @@ POSTHOG_API_KEY=phc_hmVSxIjTW1REBHXgj2aw4HW9X6CXb6FzerBgP9XenC7 # ================================================================== # # runner (sandboxagent.dev) # ================================================================== # -# SANDBOX_AGENT_PROVIDER=local +# Provider selection moved to the "Agenta - Sandbox (agent runner)" section above +# (AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS / AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER). # ================================================================== # # smtp diff --git a/hosting/kubernetes/helm/templates/_helpers.tpl b/hosting/kubernetes/helm/templates/_helpers.tpl index 7e1637d4c9..f6fd01dc8f 100644 --- a/hosting/kubernetes/helm/templates/_helpers.tpl +++ b/hosting/kubernetes/helm/templates/_helpers.tpl @@ -1095,11 +1095,12 @@ imagePullSecrets: - name: AGENTA_SERVICES_CODE_SANDBOX_RUNNER value: {{ $svcCode.sandboxRunner | quote }} {{- end }} -{{- /* agenta.sandboxLocalAllowed — agent runner `local` sandbox gate */}} -{{- if hasKey $agenta "sandboxLocalAllowed" }} -- name: AGENTA_SANDBOX_LOCAL_ALLOWED - value: {{ $agenta.sandboxLocalAllowed | quote }} -{{- end }} +{{- /* agent runner sandbox provider registry — one operator entry, read by api/web/services and the runner */}} +{{- $runnerProviders := default dict (default dict .Values.agentRunner).providers }} +- name: AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS + value: {{ join "," (default (list "local") $runnerProviders.enabled) | quote }} +- name: AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER + value: {{ default "local" $runnerProviders.default | quote }} {{- /* agenta.services.middleware — SDK middleware toggles */}} {{- if hasKey $svcMiddleware "authEnabled" }} - name: AGENTA_SERVICES_MIDDLEWARE_AUTH_ENABLED diff --git a/hosting/kubernetes/helm/templates/runner-deployment.yaml b/hosting/kubernetes/helm/templates/runner-deployment.yaml index d0f681be92..eade3b47b1 100644 --- a/hosting/kubernetes/helm/templates/runner-deployment.yaml +++ b/hosting/kubernetes/helm/templates/runner-deployment.yaml @@ -1,5 +1,7 @@ {{- $runner := default dict .Values.agentRunner -}} -{{- $daytona := default dict $runner.daytona -}} +{{- $providers := default dict $runner.providers -}} +{{- $enabledProviders := default (list "local") $providers.enabled -}} +{{- $daytona := default dict $providers.daytona -}} {{- if eq (include "agenta.agentRunner.enabled" .) "true" }} apiVersion: apps/v1 kind: Deployment @@ -48,35 +50,37 @@ spec: env: - name: AGENTA_RUNNER_PORT value: {{ include "agenta.agentRunner.port" . | quote }} - - name: SANDBOX_AGENT_PROVIDER - value: {{ default "local" $runner.provider | quote }} + - name: AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS + value: {{ join "," $enabledProviders | quote }} + - name: AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER + value: {{ default "local" $providers.default | quote }} {{- if not (hasKey (default dict $runner.env) "PI_CODING_AGENT_DIR") }} - name: PI_CODING_AGENT_DIR value: {{ default "/pi-agent" $runner.piAgentDir | quote }} {{- end }} {{- if $runner.logLevel }} - - name: SANDBOX_AGENT_LOG_LEVEL + - name: AGENTA_RUNNER_LOG_LEVEL value: {{ $runner.logLevel | quote }} {{- end }} {{- if $daytona.apiUrl }} - - name: DAYTONA_API_URL + - name: AGENTA_RUNNER_DAYTONA_API_URL value: {{ $daytona.apiUrl | quote }} {{- end }} {{- if $daytona.target }} - - name: DAYTONA_TARGET + - name: AGENTA_RUNNER_DAYTONA_TARGET value: {{ $daytona.target | quote }} {{- end }} {{- if $daytona.snapshot }} - - name: DAYTONA_SNAPSHOT + - name: AGENTA_RUNNER_DAYTONA_SNAPSHOT value: {{ $daytona.snapshot | quote }} {{- end }} {{- if $daytona.image }} - - name: DAYTONA_IMAGE + - name: AGENTA_RUNNER_DAYTONA_IMAGE value: {{ $daytona.image | quote }} {{- end }} - {{- if $daytona.autostopDelay }} - - name: DAYTONA_AUTOSTOP - value: {{ $daytona.autostopDelay | quote }} + {{- if $daytona.autostopMinutes }} + - name: AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES + value: {{ $daytona.autostopMinutes | quote }} {{- end }} {{- if hasKey $daytona "sessionIdleTtlMs" }} - name: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS @@ -86,20 +90,16 @@ spec: - name: AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM value: {{ $daytona.sessionMaxWarm | quote }} {{- end }} - {{- if $daytona.autodeleteDelay }} - - name: DAYTONA_AUTODELETE - value: {{ $daytona.autodeleteDelay | quote }} + {{- if $daytona.autodeleteMinutes }} + - name: AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES + value: {{ $daytona.autodeleteMinutes | quote }} {{- end }} - {{- if hasKey $daytona "installPi" }} - - name: AGENTA_AGENT_SANDBOX_PI_INSTALLED - value: {{ $daytona.installPi | quote }} - {{- end }} - {{- if or $daytona.apiKey (eq (default "local" $runner.provider) "daytona") }} - - name: DAYTONA_API_KEY + {{- if $daytona.apiKeySecretRef }} + - name: AGENTA_RUNNER_DAYTONA_API_KEY valueFrom: secretKeyRef: - name: {{ include "agenta.secretName" . }} - key: DAYTONA_API_KEY + name: {{ $daytona.apiKeySecretRef.name | quote }} + key: {{ $daytona.apiKeySecretRef.key | quote }} optional: true {{- end }} {{- range $key, $val := $runner.env }} diff --git a/hosting/kubernetes/helm/values.schema.json b/hosting/kubernetes/helm/values.schema.json index 47464c5a5f..3661103f7f 100644 --- a/hosting/kubernetes/helm/values.schema.json +++ b/hosting/kubernetes/helm/values.schema.json @@ -17,7 +17,6 @@ "authKey": { "type": "string", "minLength": 1, "description": "Authorization key. Emitted as AGENTA_AUTH_KEY via Secret." }, "cryptKey": { "type": "string", "minLength": 1, "description": "Encryption key. Emitted as AGENTA_CRYPT_KEY via Secret." }, "insecureEgressAllowed": { "type": ["boolean", "string"], "description": "AGENTA_INSECURE_EGRESS_ALLOWED — default true (permissive, zero-config self-host); set false to harden a shared/multi-tenant deployment." }, - "sandboxLocalAllowed": { "type": ["boolean", "string"], "description": "AGENTA_SANDBOX_LOCAL_ALLOWED — default true (permissive, zero-config self-host); `local` sandbox runs unconfined host bash. Set false to harden a shared/multi-tenant deployment." }, "access": { "type": "object", "additionalProperties": false, @@ -300,14 +299,50 @@ "agentRunner": { "type": "object", "additionalProperties": true, - "description": "Agent runner sidecar config. AGENTA_RUNNER_* are read by the Services API; SANDBOX_AGENT_* by the runner service.", + "description": "Agent runner config. providers.enabled/default are read by both the runner service and the Services API; the Daytona block is read only by the runner service.", "properties": { - "enabled": { "type": "boolean", "description": "Deploy the runner sidecar; the Services API derives AGENTA_RUNNER_INTERNAL_URL from it unless externalUrl is set." }, + "enabled": { "type": "boolean", "description": "Deploy the runner; the Services API derives AGENTA_RUNNER_INTERNAL_URL from it unless externalUrl is set." }, "externalUrl": { "type": "string", "description": "AGENTA_RUNNER_INTERNAL_URL override pointing at an external runner." }, - "provider": { "type": "string", "description": "SANDBOX_AGENT_PROVIDER (e.g. local, daytona) read by the runner service." }, "piAgentDir": { "type": "string", "description": "PI_CODING_AGENT_DIR for local Pi runs (default /pi-agent); unset means no Agenta extension for the run (the runner logs a warning)." }, - "logLevel": { "type": "string", "description": "SANDBOX_AGENT_LOG_LEVEL read by the runner service." }, - "daytona": { "type": "object", "additionalProperties": true, "description": "Daytona sandbox knobs (DAYTONA_* env on the runner service)." } + "logLevel": { "type": "string", "description": "AGENTA_RUNNER_LOG_LEVEL read by the runner service." }, + "providers": { + "type": "object", + "additionalProperties": false, + "description": "Sandbox provider registry. enabled/default are read by the runner and the Services API.", + "properties": { + "enabled": { + "type": "array", + "items": { "type": "string" }, + "description": "AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS as a list; rendered comma-joined. Unset means [local]." + }, + "default": { "type": "string", "description": "AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER; must be one of the enabled providers." }, + "daytona": { + "type": "object", + "additionalProperties": false, + "description": "Runner-owned Daytona provider config (AGENTA_RUNNER_DAYTONA_* on the runner service).", + "properties": { + "apiKeySecretRef": { + "type": "object", + "additionalProperties": false, + "description": "Secret holding the Daytona provisioning credential, injected as AGENTA_RUNNER_DAYTONA_API_KEY.", + "properties": { + "name": { "type": "string" }, + "key": { "type": "string" } + }, + "required": ["name", "key"] + }, + "apiUrl": { "type": "string", "description": "AGENTA_RUNNER_DAYTONA_API_URL." }, + "target": { "type": "string", "description": "AGENTA_RUNNER_DAYTONA_TARGET." }, + "snapshot": { "type": "string", "description": "AGENTA_RUNNER_DAYTONA_SNAPSHOT; unset means the runner's pinned default." }, + "image": { "type": "string", "description": "AGENTA_RUNNER_DAYTONA_IMAGE; mutually exclusive with snapshot." }, + "autostopMinutes": { "type": ["integer", "string"], "description": "AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES." }, + "autodeleteMinutes": { "type": ["integer", "string"], "description": "AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES." }, + "sessionIdleTtlMs": { "type": ["integer", "string"], "description": "AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS." }, + "sessionMaxWarm": { "type": ["integer", "string"], "description": "AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM." } + } + } + } + } } }, "newrelic": { diff --git a/hosting/kubernetes/helm/values.yaml b/hosting/kubernetes/helm/values.yaml index 35811d2ced..9a6a276d76 100644 --- a/hosting/kubernetes/helm/values.yaml +++ b/hosting/kubernetes/helm/values.yaml @@ -131,14 +131,25 @@ redisDurable: # size: 20Gi # ================================================================== # -# agentRunner configures the agent runner sidecar. See values.schema.json for -# the full shape. +# agentRunner — agent runner sidecar. See values.schema.json for +# the full agentRunner shape. # ================================================================== # # agentRunner: # enabled: true -# daytona: -# sessionIdleTtlMs: "" -# sessionMaxWarm: "" +# providers: +# enabled: [local] # AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS (rendered comma-joined) +# default: local # AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER (must be one of enabled) +# daytona: +# apiKeySecretRef: # injected as AGENTA_RUNNER_DAYTONA_API_KEY +# name: agenta-runner-daytona +# key: api-key +# apiUrl: https://app.daytona.io/api +# target: eu +# snapshot: agenta-agent-runner +# autostopMinutes: 15 +# autodeleteMinutes: 30 +# sessionIdleTtlMs: "" +# sessionMaxWarm: "" # ================================================================== # # workers — topology A (workers-sprawl); see docs/designs/workers-sprawl/specs.md diff --git a/hosting/railway/oss/scripts/configure.sh b/hosting/railway/oss/scripts/configure.sh index 8f877e8c3f..37ace086e2 100755 --- a/hosting/railway/oss/scripts/configure.sh +++ b/hosting/railway/oss/scripts/configure.sh @@ -301,7 +301,8 @@ main() { set_vars runner \ AGENTA_RUNNER_PORT=8765 \ - SANDBOX_AGENT_PROVIDER="${SANDBOX_AGENT_PROVIDER:-local}" \ + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS="${AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS:-local}" \ + AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER="${AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER:-local}" \ AGENTA_STORE_ENDPOINT_URL="$seaweedfs_endpoint_url" \ AGENTA_STORE_ACCESS_KEY="$AGENTA_STORE_ACCESS_KEY" \ AGENTA_STORE_SECRET_KEY="$AGENTA_STORE_SECRET_KEY" \ @@ -313,14 +314,15 @@ main() { set_optional_vars runner \ "AGENTA_API_URL=${AGENTA_API_URL:-}" \ "AGENTA_API_KEY=${AGENTA_API_KEY:-}" \ - "DAYTONA_API_KEY=${DAYTONA_API_KEY:-}" \ - "DAYTONA_API_URL=${DAYTONA_API_URL:-}" \ - "DAYTONA_TARGET=${DAYTONA_TARGET:-}" \ - "DAYTONA_SNAPSHOT=${DAYTONA_SNAPSHOT:-}" \ - "DAYTONA_IMAGE=${DAYTONA_IMAGE:-}" \ - "AGENTA_AGENT_SANDBOX_PI_INSTALLED=${AGENTA_AGENT_SANDBOX_PI_INSTALLED:-}" - - unset_vars runner AGENTA_LICENSE SCRIPT_NAME REDIS_URI REDIS_URI_VOLATILE REDIS_URI_DURABLE SUPERTOKENS_CONNECTION_URI POSTGRES_URI_CORE POSTGRES_URI_TRACING POSTGRES_URI_SUPERTOKENS DAYTONA_API_KEY DAYTONA_API_URL DAYTONA_SNAPSHOT DAYTONA_TARGET + "AGENTA_RUNNER_DAYTONA_API_KEY=${AGENTA_RUNNER_DAYTONA_API_KEY:-}" \ + "AGENTA_RUNNER_DAYTONA_API_URL=${AGENTA_RUNNER_DAYTONA_API_URL:-}" \ + "AGENTA_RUNNER_DAYTONA_TARGET=${AGENTA_RUNNER_DAYTONA_TARGET:-}" \ + "AGENTA_RUNNER_DAYTONA_SNAPSHOT=${AGENTA_RUNNER_DAYTONA_SNAPSHOT:-}" \ + "AGENTA_RUNNER_DAYTONA_IMAGE=${AGENTA_RUNNER_DAYTONA_IMAGE:-}" + + # Do NOT list the runner's AGENTA_RUNNER_DAYTONA_* vars here: unset_vars always deletes, + # which previously wiped a Daytona-configured runner's credentials right after setting them. + unset_vars runner AGENTA_LICENSE SCRIPT_NAME REDIS_URI REDIS_URI_VOLATILE REDIS_URI_DURABLE SUPERTOKENS_CONNECTION_URI POSTGRES_URI_CORE POSTGRES_URI_TRACING POSTGRES_URI_SUPERTOKENS set_vars worker-streams \ AGENTA_WEB_URL="https://${public_domain_ref}" \ diff --git a/sdks/python/agenta/sdk/agents/errors.py b/sdks/python/agenta/sdk/agents/errors.py index 2296e4beba..d37619b5dd 100644 --- a/sdks/python/agenta/sdk/agents/errors.py +++ b/sdks/python/agenta/sdk/agents/errors.py @@ -38,16 +38,18 @@ class AgentRunnerConfigurationError(RuntimeError): class LocalSandboxNotAllowedError(ErrorStatus): - """`sandbox: "local"` requested while `AGENTA_SANDBOX_LOCAL_ALLOWED` is off; maps to HTTP 403.""" + """A sandbox provider not in `AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS`; maps to HTTP 403.""" code: int = 403 type: str = f"{ERRORS_BASE_URL}#v0:agent:local-sandbox-not-allowed" def __init__( self, - message: str = ( - "sandbox 'local' is not allowed on this deployment " - "(set AGENTA_SANDBOX_LOCAL_ALLOWED=true to enable)" - ), + sandbox: str = "local", + message: str = None, ) -> None: - super().__init__(code=self.code, type=self.type, message=message) + resolved = message or ( + f"sandbox '{sandbox}' is not enabled on this deployment " + f"(add it to AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS to enable)" + ) + super().__init__(code=self.code, type=self.type, message=resolved) diff --git a/sdks/python/agenta/sdk/agents/handler.py b/sdks/python/agenta/sdk/agents/handler.py index bf36c4beb2..cafe2dcace 100644 --- a/sdks/python/agenta/sdk/agents/handler.py +++ b/sdks/python/agenta/sdk/agents/handler.py @@ -33,6 +33,7 @@ from agenta.sdk.agents.tools import ResolvedToolSet from agenta.sdk.agents.adapters import SandboxAgentBackend, make_harness from agenta.sdk.agents.errors import LocalSandboxNotAllowedError +from agenta.sdk.agents.sandbox_providers import sandbox_provider_enabled from agenta.sdk.agents.mcp import ResolvedMCPServer from agenta.sdk.agents.platform import ( resolve_connection as _platform_resolve_connection, @@ -53,7 +54,6 @@ WorkflowInvokeRequestFlags, WorkflowServiceRequest, ) -from agenta.sdk.utils.constants import TRUTHY from agenta.sdk.utils.logging import get_module_logger log = get_module_logger(__name__) @@ -75,21 +75,16 @@ def _default_template() -> AgentTemplate: return AgentTemplate() -def _sandbox_local_allowed() -> bool: - return ( - os.getenv("AGENTA_SANDBOX_LOCAL_ALLOWED") or "true" - ).strip().lower() in TRUTHY - - def _default_select_backend(agent_template: AgentTemplate) -> Backend: """Env-driven default: `AGENTA_RUNNER_INTERNAL_URL` picks HTTP transport; else local cwd. - `local` (unconfined host bash, not a tenant boundary) is refused unless - `AGENTA_SANDBOX_LOCAL_ALLOWED` is on — a bare `agent_v0` gets this protocol-level - safety behavior for free, same as capability gating and MCP gating above. + A sandbox the deployment has not enabled is refused before any run — a bare `agent_v0` + gets this protocol-level safety behavior for free, same as capability gating and MCP + gating above. The runner remains the final authority; this is a pre-filter that reads the + same `AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS` registry. """ - if agent_template.sandbox == "local" and not _sandbox_local_allowed(): - raise LocalSandboxNotAllowedError() + if not sandbox_provider_enabled(agent_template.sandbox): + raise LocalSandboxNotAllowedError(sandbox=agent_template.sandbox) url = os.getenv("AGENTA_RUNNER_INTERNAL_URL", "").strip() or None return SandboxAgentBackend(sandbox=agent_template.sandbox, url=url, cwd=os.getcwd()) diff --git a/sdks/python/agenta/sdk/agents/sandbox_providers.py b/sdks/python/agenta/sdk/agents/sandbox_providers.py new file mode 100644 index 0000000000..372a533345 --- /dev/null +++ b/sdks/python/agenta/sdk/agents/sandbox_providers.py @@ -0,0 +1,94 @@ +"""Shared sandbox-provider registry parsing for the Python side. + +The agent runner (TypeScript) owns the canonical parse-and-validate boundary for the +`AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS` / `AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER` +registry. Python readers (the SDK handler and the OSS agent service) reimplement the SAME +rules here so both languages agree on every input, and so the API-side pre-filter matches the +runner's final authority (design: runner-selfhosting-cleanup/interface.md sections 2 and 4). + +Rules: +- values are normalized lowercase provider ids separated by commas; +- unset enabled providers means exactly ``local``; +- an explicitly empty list is invalid; +- unknown and duplicate ids are invalid; +- the default must be enabled; unset default means ``local``. +""" + +from __future__ import annotations + +import os +from typing import List, Optional + +KNOWN_SANDBOX_PROVIDERS = ("local", "daytona") + + +class SandboxProviderConfigError(ValueError): + """Raised when the sandbox-provider registry configuration is invalid.""" + + +def parse_enabled_sandbox_providers(raw: Optional[str]) -> List[str]: + """Parse ``AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS``; unset -> ``["local"]``.""" + if raw is None: + return ["local"] + trimmed = raw.strip() + if trimmed == "": + raise SandboxProviderConfigError( + "AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS is set but empty; unset it for the " + "default 'local', or list at least one provider." + ) + ids = [part.strip().lower() for part in trimmed.split(",")] + seen: set[str] = set() + for provider_id in ids: + if provider_id == "": + raise SandboxProviderConfigError( + f"AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS has an empty entry in '{trimmed}'." + ) + if provider_id not in KNOWN_SANDBOX_PROVIDERS: + raise SandboxProviderConfigError( + f"AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS lists unknown provider " + f"'{provider_id}'; known providers: {', '.join(KNOWN_SANDBOX_PROVIDERS)}." + ) + if provider_id in seen: + raise SandboxProviderConfigError( + f"AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS lists provider " + f"'{provider_id}' more than once." + ) + seen.add(provider_id) + return ids + + +def parse_default_sandbox_provider(raw: Optional[str], enabled: List[str]) -> str: + """Parse ``AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER``; unset -> ``local``; must be enabled.""" + value = (raw or "").strip().lower() or "local" + if value not in KNOWN_SANDBOX_PROVIDERS: + raise SandboxProviderConfigError( + f"AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER is unknown provider '{value}'; " + f"known providers: {', '.join(KNOWN_SANDBOX_PROVIDERS)}." + ) + if value not in enabled: + raise SandboxProviderConfigError( + f"AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER '{value}' is not in the enabled set " + f"[{', '.join(enabled)}]. Add it to AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS " + f"or change the default." + ) + return value + + +def enabled_sandbox_providers(env=os.environ) -> List[str]: + """The enabled provider set, parsed from the environment.""" + return parse_enabled_sandbox_providers( + env.get("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS") + ) + + +def default_sandbox_provider(env=os.environ) -> str: + """The routing default, parsed from the environment (validated against the enabled set).""" + return parse_default_sandbox_provider( + env.get("AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER"), + enabled_sandbox_providers(env), + ) + + +def sandbox_provider_enabled(provider: str, env=os.environ) -> bool: + """Whether ``provider`` is enabled on this deployment.""" + return provider.strip().lower() in enabled_sandbox_providers(env) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py index 2d2eb01dea..37362d3be4 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py @@ -340,11 +340,13 @@ async def test_composition_select_backend_is_deployment_specific(): # Local-sandbox gate is the bare SDK default too (a composition-free `agent_v0` gets # this protocol-level safety behavior for free, same as capability/MCP gating above). # --------------------------------------------------------------------------- # -async def test_default_select_backend_refuses_local_sandbox_when_knob_off(monkeypatch): +async def test_default_select_backend_refuses_local_sandbox_when_not_enabled( + monkeypatch, +): from agenta.sdk.agents import LocalSandboxNotAllowedError from agenta.sdk.agents.dtos import AgentTemplate - monkeypatch.setenv("AGENTA_SANDBOX_LOCAL_ALLOWED", "false") + monkeypatch.setenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", "daytona") comp = AgentComposition(resolve_connection=_no_connection) handler = make_agent_handler(comp) @@ -364,7 +366,8 @@ async def test_default_select_backend_refuses_local_sandbox_when_knob_off(monkey async def test_default_select_backend_allows_local_sandbox_by_default(monkeypatch): from agenta.sdk.agents.errors import AgentRunnerConfigurationError - monkeypatch.delenv("AGENTA_SANDBOX_LOCAL_ALLOWED", raising=False) + monkeypatch.delenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", raising=False) + monkeypatch.delenv("AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER", raising=False) comp = AgentComposition(resolve_connection=_no_connection) handler = make_agent_handler(comp) @@ -380,8 +383,11 @@ async def test_default_select_backend_allows_local_sandbox_by_default(monkeypatc ) -async def test_default_select_backend_allows_daytona_sandbox_by_default(monkeypatch): - monkeypatch.delenv("AGENTA_SANDBOX_LOCAL_ALLOWED", raising=False) +async def test_default_select_backend_allows_daytona_sandbox_with_custom_backend( + monkeypatch, +): + monkeypatch.delenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", raising=False) + monkeypatch.delenv("AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER", raising=False) backend = _FakeBackend() comp = AgentComposition( select_backend=lambda template: backend, diff --git a/services/oss/src/agent/app.py b/services/oss/src/agent/app.py index e2b8535047..2f8b9dd752 100644 --- a/services/oss/src/agent/app.py +++ b/services/oss/src/agent/app.py @@ -47,7 +47,7 @@ load_config, runner_dir, runner_url, - sandbox_local_allowed, + sandbox_provider_enabled, ) from oss.src.agent.schemas import AGENT_SCHEMAS from oss.src.agent.tools import resolve_mcp_servers, resolve_tools @@ -73,12 +73,13 @@ def select_backend(agent_template: AgentTemplate) -> Backend: spawns the TypeScript runner CLI from the runner dir. Only ``sandbox`` is read here; it is a backend/environment concern that never enters ``SessionConfig``. - ``local`` is refused unless ``AGENTA_SANDBOX_LOCAL_ALLOWED`` is on: it is unconfined - host bash, not a tenant boundary, on a shared deployment. This is the producer-side - gate; the runner's own id whitelist is a second, independent layer. + A sandbox provider not in ``AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS`` is refused here: + ``local`` is unconfined host bash, not a tenant boundary, on a shared deployment. This is + the producer-side gate; the runner's own enabled-provider check is a second, independent + layer and the final authority. """ - if agent_template.sandbox == "local" and not sandbox_local_allowed(): - raise LocalSandboxNotAllowedError() + if not sandbox_provider_enabled(agent_template.sandbox): + raise LocalSandboxNotAllowedError(sandbox=agent_template.sandbox) return SandboxAgentBackend( sandbox=agent_template.sandbox, url=runner_url(), diff --git a/services/oss/src/agent/config.py b/services/oss/src/agent/config.py index fee5cfae99..5c035385b0 100644 --- a/services/oss/src/agent/config.py +++ b/services/oss/src/agent/config.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Any, List, Optional +from agenta.sdk.agents.sandbox_providers import enabled_sandbox_providers from agenta.sdk.utils.logging import get_module_logger log = get_module_logger(__name__) @@ -53,27 +54,27 @@ def runner_url() -> Optional[str]: return value.strip() if value and value.strip() else None -_TRUTHY = {"true", "1", "t", "y", "yes", "on", "enable", "enabled"} _SANDBOX_LOCAL_WARNED = False -def sandbox_local_allowed() -> bool: - """Whether `sandbox: "local"` (unconfined host bash) may be selected. +def sandbox_provider_enabled(provider: str) -> bool: + """Whether ``provider`` is enabled on this deployment. - Mirrors `api/oss/src/utils/env.py`'s `RunnerConfig.sandbox_local_allowed`: same env - var, same permissive-by-default posture (zero-config self-host). This service does - not depend on `api`, so it reads the var directly (this package's own convention; - see `runner_dir`/`runner_url` above) rather than importing the shared `env` object. + Reads the same `AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS` registry the agent runner and + the SDK read, with the same parsing rules (shared helper in the SDK). `local` is + unconfined host bash and not a tenant boundary, so a deployment that hardens for + multi-tenant use drops it from the enabled set. """ global _SANDBOX_LOCAL_WARNED - allowed = (os.getenv("AGENTA_SANDBOX_LOCAL_ALLOWED") or "true").lower() in _TRUTHY - if allowed and not _SANDBOX_LOCAL_WARNED: + enabled = enabled_sandbox_providers() + if "local" in enabled and not _SANDBOX_LOCAL_WARNED: _SANDBOX_LOCAL_WARNED = True log.warning( - "AGENTA_SANDBOX_LOCAL_ALLOWED is on (default): local sandbox is not a " - "tenant boundary. Set it to false to harden a shared/multi-tenant deployment." + "local sandbox is enabled (default): it is unconfined host bash, not a tenant " + "boundary. Set AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS=daytona to harden a " + "shared/multi-tenant deployment." ) - return allowed + return provider.strip().lower() in enabled def config_dir() -> Path: diff --git a/services/oss/tests/pytest/unit/agent/test_select_backend.py b/services/oss/tests/pytest/unit/agent/test_select_backend.py index 0cfc2ed51e..5645988259 100644 --- a/services/oss/tests/pytest/unit/agent/test_select_backend.py +++ b/services/oss/tests/pytest/unit/agent/test_select_backend.py @@ -26,12 +26,12 @@ def runner_wrapper(tmp_path: Path) -> Path: @pytest.fixture(autouse=True) def _clean_env(monkeypatch, runner_wrapper: Path): - # Start every case from a known-empty deployment environment. These tests exercise - # transport/harness selection, not the local-sandbox gate (on by default anyway), so - # pin it explicitly here for clarity; the gate itself has its own tests below. + # Start every case from a known deployment environment. These tests exercise + # transport/harness selection, so enable both providers; the enabled-provider gate has + # its own tests below. monkeypatch.delenv("AGENTA_RUNNER_INTERNAL_URL", raising=False) monkeypatch.setenv("AGENTA_RUNNER_DIR", str(runner_wrapper)) - monkeypatch.setenv("AGENTA_SANDBOX_LOCAL_ALLOWED", "true") + monkeypatch.setenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", "local,daytona") def _sel(harness="pi_core", sandbox="local"): @@ -73,20 +73,20 @@ def test_no_runner_url_requires_runner_assets(monkeypatch, tmp_path: Path): # --------------------------------------------------------------------------- -# Local-sandbox gate: `sandbox: "local"` is unconfined host bash, not a tenant -# boundary, so it is refused unless AGENTA_SANDBOX_LOCAL_ALLOWED is on. +# Enabled-provider gate: a sandbox not in AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS is +# refused before any run. `local` is unconfined host bash, not a tenant boundary. # --------------------------------------------------------------------------- -def test_local_sandbox_refused_when_knob_off(monkeypatch): - monkeypatch.setenv("AGENTA_SANDBOX_LOCAL_ALLOWED", "false") +def test_local_sandbox_refused_when_not_enabled(monkeypatch): + monkeypatch.setenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", "daytona") with pytest.raises(LocalSandboxNotAllowedError): select_backend(_sel("pi_core", "local")) def test_local_sandbox_allowed_by_default_when_unset(monkeypatch): - monkeypatch.delenv("AGENTA_SANDBOX_LOCAL_ALLOWED", raising=False) + monkeypatch.delenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", raising=False) backend = select_backend(_sel("pi_core", "local")) @@ -94,8 +94,8 @@ def test_local_sandbox_allowed_by_default_when_unset(monkeypatch): assert backend._sandbox == "local" -def test_local_sandbox_allowed_when_knob_on(monkeypatch): - monkeypatch.setenv("AGENTA_SANDBOX_LOCAL_ALLOWED", "true") +def test_local_sandbox_allowed_when_explicitly_enabled(monkeypatch): + monkeypatch.setenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", "local") backend = select_backend(_sel("pi_core", "local")) @@ -103,10 +103,17 @@ def test_local_sandbox_allowed_when_knob_on(monkeypatch): assert backend._sandbox == "local" -def test_daytona_sandbox_always_allowed(monkeypatch): - monkeypatch.setenv("AGENTA_SANDBOX_LOCAL_ALLOWED", "false") +def test_daytona_sandbox_allowed_when_enabled(monkeypatch): + monkeypatch.setenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", "local,daytona") backend = select_backend(_sel("pi_core", "daytona")) assert isinstance(backend, SandboxAgentBackend) assert backend._sandbox == "daytona" + + +def test_daytona_sandbox_refused_when_not_enabled(monkeypatch): + monkeypatch.setenv("AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", "local") + + with pytest.raises(LocalSandboxNotAllowedError): + select_backend(_sel("pi_core", "daytona")) diff --git a/services/runner/sandbox-images/daytona/README.md b/services/runner/sandbox-images/daytona/README.md index 179840293d..932857683e 100644 --- a/services/runner/sandbox-images/daytona/README.md +++ b/services/runner/sandbox-images/daytona/README.md @@ -12,14 +12,15 @@ DAYTONA_API_KEY=... DAYTONA_TARGET=eu uv run build_snapshot.py --force Configure the runner service with: ```bash -SANDBOX_AGENT_PROVIDER=daytona -DAYTONA_SNAPSHOT=agenta-sandbox-pi -AGENTA_AGENT_SANDBOX_PI_INSTALLED=false +AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS=local,daytona +AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=daytona +AGENTA_RUNNER_DAYTONA_API_KEY=... +AGENTA_RUNNER_DAYTONA_SNAPSHOT=agenta-sandbox-pi ``` -`DAYTONA_SNAPSHOT` is shared with the SDK custom-code evaluator runner, so the recipe also -bakes its runtimes (python3, typescript/ts-node). Per-consumer overrides when the two must -diverge: `DAYTONA_SNAPSHOT_CODE` / `DAYTONA_SNAPSHOT_AGENT`. +The SDK custom-code evaluator runner can share the built snapshot (it reads the separate +`DAYTONA_SNAPSHOT_CODE` / `DAYTONA_SNAPSHOT` variables), so the recipe also bakes its +runtimes (python3, typescript/ts-node). ## What is baked @@ -46,19 +47,15 @@ runs the agent; the adapter translates Pi events and dialogs onto ACP. In partic adapter version must not be inherited implicitly from the base image because older versions do not forward Pi extension dialogs as ACP permission requests. -## Pi installation controls +## Pi installation -The runner keeps the reliable bare-image behavior by default: - -- unset or `AGENTA_AGENT_SANDBOX_PI_INSTALLED=true`: install the pinned Pi version into - each new sandbox session and point `pi-acp` at it; -- `AGENTA_AGENT_SANDBOX_PI_INSTALLED=false`: skip the session-time install because the - configured snapshot already contains Pi; and -- `AGENTA_AGENT_SANDBOX_PI_VERSION`: override the pinned session-time fallback version. - -Despite its historical `_INSTALLED` name, the boolean controls whether the runner performs -the session-time installation. Set it to `false` only together with a snapshot known to -contain Pi, such as `agenta-sandbox-pi`. +Harness availability is an image/runtime contract, not operator truth: before each session +the runner probes the expected Pi executable at its pinned in-sandbox path +(`/home/sandbox/.agenta-pi/node_modules/.bin/pi`). A snapshot built with this recipe bakes +the pinned Pi there, so the probe hits and no session-time install runs. If a custom image +or snapshot lacks Pi, the runner installs the pinned version before the session and logs the +repair; if that install fails, the run fails naming the missing executable and attempted +version. There is no "installed" environment flag. The full base image includes Claude Code. We do not distribute the resulting snapshot. Agenta Cloud builds its own internal snapshot, and self-hosters build their own. diff --git a/services/runner/sandbox-images/daytona/build_snapshot.py b/services/runner/sandbox-images/daytona/build_snapshot.py index 9b0df803ef..18f5d78ad8 100644 --- a/services/runner/sandbox-images/daytona/build_snapshot.py +++ b/services/runner/sandbox-images/daytona/build_snapshot.py @@ -10,13 +10,12 @@ the other baked harnesses so Daytona runs do not pay their installation cost for every fresh sandbox. Set the runner service to use it: - DAYTONA_SNAPSHOT=agenta-sandbox-pi - AGENTA_AGENT_SANDBOX_PI_INSTALLED=false + AGENTA_RUNNER_DAYTONA_SNAPSHOT=agenta-sandbox-pi -The snapshot is shared with the SDK code-evaluator runner (which also falls back to -`DAYTONA_SNAPSHOT`), so the recipe additionally installs python3 and typescript/ts-node. -Per-consumer overrides when the two must diverge: DAYTONA_SNAPSHOT_CODE / -DAYTONA_SNAPSHOT_AGENT. +The runner probes for its pinned Pi before each session; because this recipe bakes it, the +probe hits and no session-time install runs. The SDK code-evaluator runner can share the +built snapshot through its own DAYTONA_SNAPSHOT_CODE / DAYTONA_SNAPSHOT variables, so the +recipe additionally installs python3 and typescript/ts-node. Run: DAYTONA_API_KEY=... DAYTONA_TARGET=eu uv run build_snapshot.py [--force] diff --git a/services/runner/src/config/runner-config.ts b/services/runner/src/config/runner-config.ts new file mode 100644 index 0000000000..69c3ca2c56 --- /dev/null +++ b/services/runner/src/config/runner-config.ts @@ -0,0 +1,295 @@ +/** + * Typed runner configuration: the single parse-and-validate boundary for every operator-facing + * `AGENTA_RUNNER_*` environment variable. The runner reads `process.env` in exactly one place — + * here — parses it once before the HTTP server listens, and every other module consumes the typed + * object. This keeps the public configuration surface small, validated, and greppable. + * + * Design contract: docs/design/agent-workflows/projects/runner-selfhosting-cleanup/interface.md + * (sections 2-4). Names describe what a value IS, not the feature that first needed it. + */ + +/** Sandbox providers this runner can actually provision. */ +export const KNOWN_SANDBOX_PROVIDER_IDS = ["local", "daytona"] as const; +export type SandboxProviderId = (typeof KNOWN_SANDBOX_PROVIDER_IDS)[number]; + +/** The runner's pinned default Daytona artifact, used when neither snapshot nor image is set. */ +export const DEFAULT_DAYTONA_SNAPSHOT = "agenta-agent-runner"; + +/** Idle-minute thresholds for Daytona lifecycle transitions (see `provider.ts`). */ +export const DEFAULT_DAYTONA_AUTOSTOP_MINUTES = 15; +export const DEFAULT_DAYTONA_AUTODELETE_MINUTES = 30; + +export const DEFAULT_RUNNER_HOST = "127.0.0.1"; +export const DEFAULT_RUNNER_PORT = 8765; +export const DEFAULT_CONCURRENCY_LIMIT = 1000; +export const DEFAULT_LOG_LEVEL = "silent"; + +/** Thrown when the operator's configuration is invalid. Fails startup before the server listens. */ +export class RunnerConfigError extends Error { + constructor(message: string) { + super(message); + this.name = "RunnerConfigError"; + } +} + +export interface RunnerServerConfig { + host: string; + port: number; + concurrencyLimit: number; + logLevel: string; + replicaId: string | undefined; + token: string | undefined; +} + +export interface RunnerProvidersConfig { + enabled: readonly SandboxProviderId[]; + default: SandboxProviderId; +} + +export interface RunnerDaytonaConfig { + apiKey: string | undefined; + apiUrl: string | undefined; + target: string | undefined; + snapshot: string | undefined; + image: string | undefined; + autostopMinutes: number; + autodeleteMinutes: number; +} + +export interface RunnerCallbackConfig { + apiInternalUrl: string | undefined; +} + +export interface RunnerConfig { + server: RunnerServerConfig; + providers: RunnerProvidersConfig; + daytona: RunnerDaytonaConfig; + callback: RunnerCallbackConfig; +} + +type Env = Record; + +/** + * Treat empty or whitespace-only values as absent. Compose renders an unset variable as + * `${VAR:-}` -> "" inside the container, so "" must collapse to "no value" at this one boundary; + * past it, the typed config carries either a real value or `undefined`. + */ +function nonEmpty(raw: string | undefined): string | undefined { + const trimmed = raw?.trim(); + return trimmed ? trimmed : undefined; +} + +function parsePositiveInt( + raw: string | undefined, + fallback: number, + name: string, +): number { + const value = nonEmpty(raw); + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new RunnerConfigError( + `${name} must be a positive integer, got '${value}'.`, + ); + } + return parsed; +} + +/** + * Parse the enabled-provider registry. Rules (interface.md section 2): + * - unset means exactly `local`; + * - an explicitly empty list is invalid; + * - ids are normalized to lowercase and compared as a set; + * - unknown and duplicate ids are invalid. + */ +export function parseEnabledProviders( + raw: string | undefined, +): SandboxProviderId[] { + if (raw === undefined) return ["local"]; + const trimmed = raw.trim(); + if (trimmed === "") { + throw new RunnerConfigError( + "AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS is set but empty; " + + "unset it for the default 'local', or list at least one provider.", + ); + } + const ids = trimmed.split(",").map((part) => part.trim().toLowerCase()); + const known = new Set(KNOWN_SANDBOX_PROVIDER_IDS); + const seen = new Set(); + for (const id of ids) { + if (id === "") { + throw new RunnerConfigError( + `AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS has an empty entry in '${trimmed}'.`, + ); + } + if (!known.has(id)) { + throw new RunnerConfigError( + `AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS lists unknown provider '${id}'; ` + + `known providers: ${KNOWN_SANDBOX_PROVIDER_IDS.join(", ")}.`, + ); + } + if (seen.has(id)) { + throw new RunnerConfigError( + `AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS lists provider '${id}' more than once.`, + ); + } + seen.add(id); + } + return ids as SandboxProviderId[]; +} + +/** + * Parse the routing default. Unset means `local`; the default must be one of the enabled + * providers (interface.md section 2, rule 5). + */ +export function parseDefaultProvider( + raw: string | undefined, + enabled: readonly SandboxProviderId[], +): SandboxProviderId { + const value = (nonEmpty(raw) ?? "local").toLowerCase(); + if (!(KNOWN_SANDBOX_PROVIDER_IDS as readonly string[]).includes(value)) { + throw new RunnerConfigError( + `AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER is unknown provider '${value}'; ` + + `known providers: ${KNOWN_SANDBOX_PROVIDER_IDS.join(", ")}.`, + ); + } + if (!enabled.includes(value as SandboxProviderId)) { + throw new RunnerConfigError( + `AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER '${value}' is not in the enabled set ` + + `[${enabled.join(", ")}]. Add it to AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS ` + + `or change the default.`, + ); + } + return value as SandboxProviderId; +} + +function parseProviders(env: Env): RunnerProvidersConfig { + const enabled = parseEnabledProviders( + env.AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS, + ); + const defaultProvider = parseDefaultProvider( + env.AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER, + enabled, + ); + return { enabled, default: defaultProvider }; +} + +function parseDaytona(env: Env, enabled: readonly SandboxProviderId[]): RunnerDaytonaConfig { + const apiKey = nonEmpty(env.AGENTA_RUNNER_DAYTONA_API_KEY); + const snapshot = nonEmpty(env.AGENTA_RUNNER_DAYTONA_SNAPSHOT); + const image = nonEmpty(env.AGENTA_RUNNER_DAYTONA_IMAGE); + + if (snapshot && image) { + throw new RunnerConfigError( + "AGENTA_RUNNER_DAYTONA_SNAPSHOT and AGENTA_RUNNER_DAYTONA_IMAGE are mutually " + + "exclusive; set only one.", + ); + } + + const autostopMinutes = parsePositiveInt( + env.AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES, + DEFAULT_DAYTONA_AUTOSTOP_MINUTES, + "AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES", + ); + const autodeleteMinutes = parsePositiveInt( + env.AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES, + DEFAULT_DAYTONA_AUTODELETE_MINUTES, + "AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES", + ); + + if (enabled.includes("daytona") && !apiKey) { + throw new RunnerConfigError( + "AGENTA_RUNNER_DAYTONA_API_KEY is required when 'daytona' is in " + + "AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS.", + ); + } + + return { + apiKey, + apiUrl: nonEmpty(env.AGENTA_RUNNER_DAYTONA_API_URL), + target: nonEmpty(env.AGENTA_RUNNER_DAYTONA_TARGET), + snapshot, + image, + autostopMinutes, + autodeleteMinutes, + }; +} + +function parseServer(env: Env): RunnerServerConfig { + return { + host: nonEmpty(env.AGENTA_RUNNER_HOST) ?? DEFAULT_RUNNER_HOST, + port: parsePositiveInt( + env.AGENTA_RUNNER_PORT, + DEFAULT_RUNNER_PORT, + "AGENTA_RUNNER_PORT", + ), + concurrencyLimit: parsePositiveInt( + env.AGENTA_RUNNER_CONCURRENCY_LIMIT, + DEFAULT_CONCURRENCY_LIMIT, + "AGENTA_RUNNER_CONCURRENCY_LIMIT", + ), + logLevel: nonEmpty(env.AGENTA_RUNNER_LOG_LEVEL) ?? DEFAULT_LOG_LEVEL, + replicaId: nonEmpty(env.AGENTA_RUNNER_REPLICA_ID), + token: nonEmpty(env.AGENTA_RUNNER_TOKEN), + }; +} + +/** + * Pure parse + validate. Takes an explicit environment map so tests can drive every branch + * without touching `process.env`. Throws {@link RunnerConfigError} on the first invalid field. + */ +export function parseRunnerConfig(env: Env = process.env): RunnerConfig { + const providers = parseProviders(env); + const daytona = parseDaytona(env, providers.enabled); + const server = parseServer(env); + return { + server, + providers, + daytona, + callback: { + apiInternalUrl: nonEmpty(env.AGENTA_API_INTERNAL_URL), + }, + }; +} + +let cached: RunnerConfig | undefined; + +/** + * The process-wide typed configuration, parsed and validated on first use and memoized. Boot + * calls this once before the server listens so an invalid configuration fails startup; hot-path + * callers then read the cached object. + */ +export function loadRunnerConfig(env: Env = process.env): RunnerConfig { + if (!cached) cached = parseRunnerConfig(env); + return cached; +} + +/** Test-only: drop the memoized config so the next {@link loadRunnerConfig} re-parses. */ +export function resetRunnerConfigCache(): void { + cached = undefined; +} + +/** The Daytona artifact reference for the startup summary: `snapshot:x`, `image:y`, or the pin. */ +export function daytonaArtifactSummary(daytona: RunnerDaytonaConfig): string { + if (daytona.image) return `image:${daytona.image}`; + if (daytona.snapshot) return `snapshot:${daytona.snapshot}`; + return `snapshot:${DEFAULT_DAYTONA_SNAPSHOT}`; +} + +/** + * One redacted resolved-configuration summary line set (interface.md section 3). No credential + * value or local source path is ever logged. + */ +export function runnerConfigSummary(config: RunnerConfig): string { + const lines = [ + `runner providers enabled=[${config.providers.enabled.join(",")}] ` + + `default=${config.providers.default}`, + ]; + if (config.providers.enabled.includes("daytona")) { + lines.push( + `runner daytona target=${config.daytona.target ?? "default"} ` + + `artifact=${daytonaArtifactSummary(config.daytona)}`, + ); + } + return lines.join("\n"); +} diff --git a/services/runner/src/engines/sandbox_agent.ts b/services/runner/src/engines/sandbox_agent.ts index f690e6a778..e11f32fba5 100644 --- a/services/runner/src/engines/sandbox_agent.ts +++ b/services/runner/src/engines/sandbox_agent.ts @@ -126,6 +126,7 @@ import { type TeardownReason, } from "./sandbox_agent/teardown.ts"; import { buildSandboxProvider } from "./sandbox_agent/provider.ts"; +import { loadRunnerConfig } from "../config/runner-config.ts"; import { DaytonaReconnectTerminalError } from "./sandbox_agent/daytona-provider.ts"; import { buildRunPlan, @@ -711,8 +712,7 @@ export async function acquireEnvironment( let durableCwd: string | undefined; if (mountCreds?.prefix) { const isDaytonaReq = - (request.sandbox ?? process.env.SANDBOX_AGENT_PROVIDER ?? "local") === - "daytona"; + (request.sandbox ?? loadRunnerConfig().providers.default) === "daytona"; durableCwd = isDaytonaReq ? `/home/sandbox/agenta/${mountCreds.prefix}` : `/tmp/agenta/${mountCreds.prefix}`; @@ -1292,14 +1292,9 @@ export async function acquireEnvironment( } // Per-harness session/transcript-dir mounts, remote-only by construction (this whole // branch is `plan.isDaytona`) — local runs never reach here, so they stay mount-free/ - // byte-identical. Opt-out via env, default on wherever a durable cwd mount is active (no - // separate credential/session-id path from the cwd mount). - if ( - canMount && - sessionForMount && - runCred && - process.env.AGENTA_SESSION_HARNESS_MOUNTS !== "false" - ) { + // byte-identical. Transcript mounts derive from the session contract (a durable cwd mount + // is active), with no separate public switch or credential/session-id path. + if (canMount && sessionForMount && runCred) { const dirs = harnessSessionMounts( plan.acpAgent, "/home/sandbox", diff --git a/services/runner/src/engines/sandbox_agent/daemon.ts b/services/runner/src/engines/sandbox_agent/daemon.ts index a1a75568a1..170b23327e 100644 --- a/services/runner/src/engines/sandbox_agent/daemon.ts +++ b/services/runner/src/engines/sandbox_agent/daemon.ts @@ -114,6 +114,10 @@ export const KNOWN_PROVIDER_ENV_VARS = [ // ones. sandbox-agent's local() spawns `{...process.env, ...options.env}` (inherit-then-apply), so // an absent key here doesn't stop the leak — must be forced to "" to override the inherited value. export const KNOWN_SANDBOX_ENV_VARS = [ + // The operator sets the runner's Daytona credential under this name; the runner also bridges it + // into the ambient `DAYTONA_API_KEY` the vendored SDK reads. Blank BOTH so neither reaches a + // local harness process. + "AGENTA_RUNNER_DAYTONA_API_KEY", "DAYTONA_API_KEY", "E2B_API_KEY", ] as const; diff --git a/services/runner/src/engines/sandbox_agent/daytona-provider.ts b/services/runner/src/engines/sandbox_agent/daytona-provider.ts index 2149395c80..5d7e05cffc 100644 --- a/services/runner/src/engines/sandbox_agent/daytona-provider.ts +++ b/services/runner/src/engines/sandbox_agent/daytona-provider.ts @@ -1,7 +1,36 @@ import { Daytona, DaytonaNotFoundError, type Sandbox } from "@daytonaio/sdk"; import { daytona, type DaytonaProviderOptions } from "sandbox-agent/daytona"; +import type { RunnerDaytonaConfig } from "../../config/runner-config.ts"; + type DaytonaClient = Pick; + +/** + * Build a Daytona SDK client explicitly from the typed runner config, instead of relying on the + * SDK reading ambient `DAYTONA_*` values (interface.md section 2). This client drives the + * lifecycle operations the vendored provider does not implement (get/pause/reconnect/delete). + */ +export function buildDaytonaClient(config: RunnerDaytonaConfig): Daytona { + return new Daytona({ + ...(config.apiKey ? { apiKey: config.apiKey } : {}), + ...(config.apiUrl ? { apiUrl: config.apiUrl } : {}), + ...(config.target ? { target: config.target } : {}), + }); +} + +/** + * Bridge the typed Daytona config into the ambient `DAYTONA_*` variables the VENDORED + * `sandbox-agent/daytona` provider constructs its own client from (it calls `new Daytona()` with + * no arguments during sandbox creation, so it cannot be handed an explicit client). The operator + * only ever sets `AGENTA_RUNNER_DAYTONA_*`; the runner derives the SDK's expected names from its + * own typed config here. The daemon force-blanks these before any harness runs (`daemon.ts` + * KNOWN_SANDBOX_ENV_VARS), so the bridged credential never reaches user code. + */ +export function applyDaytonaSdkEnv(config: RunnerDaytonaConfig): void { + if (config.apiKey) process.env.DAYTONA_API_KEY = config.apiKey; + if (config.apiUrl) process.env.DAYTONA_API_URL = config.apiUrl; + if (config.target) process.env.DAYTONA_TARGET = config.target; +} type BaseProvider = ReturnType; interface DaytonaLifecycleDependencies { diff --git a/services/runner/src/engines/sandbox_agent/daytona.ts b/services/runner/src/engines/sandbox_agent/daytona.ts index fdcad2d01b..287fd405cd 100644 --- a/services/runner/src/engines/sandbox_agent/daytona.ts +++ b/services/runner/src/engines/sandbox_agent/daytona.ts @@ -15,62 +15,128 @@ type Log = (message: string) => void; export const DAYTONA_PI_DIR = process.env.AGENTA_AGENT_SANDBOX_PI_DIR ?? "/home/sandbox/.pi/agent"; -// Keep fresh bare images reliable by installing Pi at session time by default. A -// snapshot that already bakes the pinned CLI can explicitly set -// AGENTA_AGENT_SANDBOX_PI_INSTALLED=false to skip that work. +// Harness availability is a runtime contract, not operator truth (interface.md section 7): the +// runner pins the Pi version, probes the expected executable in the sandbox, and installs the +// pinned version when a custom image or snapshot lacks it. There is no "installed" env flag. export const DAYTONA_PI_INSTALL_DIR = "/home/sandbox/.agenta-pi"; -export const DAYTONA_PI_INSTALL = - process.env.AGENTA_AGENT_SANDBOX_PI_INSTALLED !== "false"; -export const DAYTONA_PI_VERSION = - process.env.AGENTA_AGENT_SANDBOX_PI_VERSION ?? "0.80.6"; +export const PINNED_PI_VERSION = "0.80.6"; +/** The expected Pi executable path the runner probes and points `PI_ACP_PI_COMMAND` at. */ +export const DAYTONA_PI_COMMAND = `${DAYTONA_PI_INSTALL_DIR}/node_modules/.bin/pi`; /** * In-sandbox env for the Daytona daemon: where Pi reads its login, any provider keys, * and the Agenta extension env (traceparent + OTLP + tool spec) so the remote Pi traces - * and runs tools exactly like local. No local-only paths (PATH/PI_ACP_PI_COMMAND) here. + * and runs tools exactly like local. No local-only paths (PATH) here. */ export function daytonaEnvVars( piExtEnv: Record, secrets: Record, ): Record { - const env: Record = { + return { PI_CODING_AGENT_DIR: DAYTONA_PI_DIR, + // Point pi-acp at the pinned `pi` the runner probes/installs at a stable path. The published + // snapshot bakes Pi there; a custom image gets the pinned install before the session. + PI_ACP_PI_COMMAND: DAYTONA_PI_COMMAND, ...piExtEnv, // Provider API keys from the vault: the in-sandbox harness authenticates with these. ...secrets, }; - // Point pi-acp at the `pi` we install into the sandbox (the image lacks it). - if (DAYTONA_PI_INSTALL) { - env.PI_ACP_PI_COMMAND = `${DAYTONA_PI_INSTALL_DIR}/node_modules/.bin/pi`; +} + +/** True when the pinned Pi executable is already present at the expected path in the sandbox. */ +async function probePiInstalled(sandbox: any): Promise { + try { + const res = await sandbox.runProcess({ + command: "test", + args: ["-x", DAYTONA_PI_COMMAND], + timeoutMs: 15_000, + }); + return res?.exitCode === 0; + } catch { + return false; } - return env; } -/** Install the `pi` CLI into a Daytona sandbox (the sandbox-agent image lacks it). Best-effort. */ -export async function installPiInSandbox( - sandbox: any, - log: Log = () => {}, -): Promise { +/** + * Link a snapshot-baked `pi` (on PATH, e.g. the recipe's global npm install) to the pinned + * path so a baked snapshot never pays a session-time reinstall. Returns false when no `pi` + * is on PATH. + */ +async function linkGlobalPi(sandbox: any): Promise { try { - await sandbox.mkdirFs({ path: DAYTONA_PI_INSTALL_DIR }); const res = await sandbox.runProcess({ - command: "npm", + command: "sh", args: [ - "install", - "--no-fund", - "--no-audit", - `@earendil-works/pi-coding-agent@${DAYTONA_PI_VERSION}`, + "-lc", + `command -v pi >/dev/null 2>&1 && ` + + `mkdir -p ${DAYTONA_PI_INSTALL_DIR}/node_modules/.bin && ` + + `ln -sf "$(command -v pi)" ${DAYTONA_PI_COMMAND}`, ], - cwd: DAYTONA_PI_INSTALL_DIR, - timeoutMs: 180_000, + timeoutMs: 15_000, }); - if (res?.exitCode !== 0) { - log( - `pi install in sandbox exit=${res?.exitCode}: ${String(res?.stderr).slice(-400)}`, - ); - } + return res?.exitCode === 0; + } catch { + return false; + } +} + +/** Install the pinned `pi` CLI into a Daytona sandbox (a custom image may lack it). */ +async function installPiInSandbox( + sandbox: any, + log: Log = () => {}, +): Promise { + await sandbox.mkdirFs({ path: DAYTONA_PI_INSTALL_DIR }); + const res = await sandbox.runProcess({ + command: "npm", + args: [ + "install", + "--no-fund", + "--no-audit", + `@earendil-works/pi-coding-agent@${PINNED_PI_VERSION}`, + ], + cwd: DAYTONA_PI_INSTALL_DIR, + timeoutMs: 180_000, + }); + if (res?.exitCode !== 0) { + log( + `pi install in sandbox exit=${res?.exitCode}: ${String(res?.stderr).slice(-400)}`, + ); + } +} + +/** + * Probe for the pinned Pi executable and repair when a custom image or snapshot lacks it + * (interface.md section 7). Repair ladder, cheapest first: + * 1. the pinned path already exists (baked or repaired earlier) — done; + * 2. a `pi` is on PATH (the snapshot recipe installs it globally) — link it to the pinned path; + * 3. install the pinned version. + * If Pi is still missing after that, the run fails with the missing executable and the attempted + * version — harness availability is an image/runtime contract, never a silent skip. + */ +export async function ensurePiInSandbox( + sandbox: any, + log: Log = () => {}, +): Promise { + if (await probePiInstalled(sandbox)) return; + if ((await linkGlobalPi(sandbox)) && (await probePiInstalled(sandbox))) { + log(`[pi-repair] linked snapshot-baked pi to ${DAYTONA_PI_COMMAND}`); + return; + } + log( + `[pi-repair] pinned pi ${PINNED_PI_VERSION} missing at ${DAYTONA_PI_COMMAND}; installing`, + ); + try { + await installPiInSandbox(sandbox, log); } catch (err) { - log(`pi install in sandbox skipped: ${(err as Error).message}`); + throw new Error( + `Failed to install pinned pi ${PINNED_PI_VERSION} at ${DAYTONA_PI_COMMAND}: ` + + `${(err as Error).message}`, + ); + } + if (!(await probePiInstalled(sandbox))) { + throw new Error( + `pi ${PINNED_PI_VERSION} is not available at ${DAYTONA_PI_COMMAND} after install.`, + ); } } @@ -151,9 +217,9 @@ export async function prepareDaytonaPiAssets({ ); } const piInstallStartedAt = Date.now(); - if (DAYTONA_PI_INSTALL) await installPiInSandbox(sandbox, log); + await ensurePiInSandbox(sandbox, log); log( - `[timing] stage=pi_install ms=${Math.round(Date.now() - piInstallStartedAt)} sandbox=${sandbox?.sandboxId ?? "-"} session=- skipped=${!DAYTONA_PI_INSTALL}`, + `[timing] stage=pi_install ms=${Math.round(Date.now() - piInstallStartedAt)} sandbox=${sandbox?.sandboxId ?? "-"} session=-`, ); } diff --git a/services/runner/src/engines/sandbox_agent/provider.ts b/services/runner/src/engines/sandbox_agent/provider.ts index e9adfb2d42..10f46b4c7d 100644 --- a/services/runner/src/engines/sandbox_agent/provider.ts +++ b/services/runner/src/engines/sandbox_agent/provider.ts @@ -1,8 +1,20 @@ import { local } from "sandbox-agent/local"; import type { SandboxPermission } from "../../protocol.ts"; +import { + DEFAULT_DAYTONA_SNAPSHOT, + KNOWN_SANDBOX_PROVIDER_IDS, + loadRunnerConfig, + type RunnerConfig, + type RunnerDaytonaConfig, + type SandboxProviderId, +} from "../../config/runner-config.ts"; import { daytonaEnvVars } from "./daytona.ts"; -import { daytonaWithLifecycle } from "./daytona-provider.ts"; +import { + applyDaytonaSdkEnv, + buildDaytonaClient, + daytonaWithLifecycle, +} from "./daytona-provider.ts"; /** * Translate the Layer 2 network policy into Daytona create fields. Daytona enforces egress @@ -31,61 +43,27 @@ export function daytonaNetworkFields( } /** - * Idle-minute thresholds for Daytona lifecycle transitions. Each is measured from last activity - * and refreshed on every turn. Stop must exceed the 300-second maximum silent stretch of a live - * turn. A stopped sandbox keeps its disk; a deleted one is gone and must be recreated. - */ -export const DEFAULT_DAYTONA_AUTOSTOP_MINUTES = 15; -export const DEFAULT_DAYTONA_AUTODELETE_MINUTES = 30; - -/** - * Treat empty or whitespace-only env values as absent. Compose renders unset variables as - * `${VAR:-}` -> "" inside the container, and `??` keeps that empty string, which would - * silently discard the fallback variable. - */ -function nonEmpty(rawValue: string | undefined): string | undefined { - const trimmed = rawValue?.trim(); - return trimmed ? trimmed : undefined; -} - -function positiveMinutes(rawValue: string | undefined, fallback: number): number { - const parsed = Number(rawValue); - if (!Number.isFinite(parsed) || parsed < 1) return fallback; - return Math.floor(parsed); -} - -/** Idle minutes before a warm (stopped) sandbox is reached. Override `DAYTONA_AUTOSTOP`. */ -export function daytonaAutoStopMinutes( - rawValue: string | undefined = process.env.DAYTONA_AUTOSTOP, -): number { - return positiveMinutes(rawValue, DEFAULT_DAYTONA_AUTOSTOP_MINUTES); -} - -/** Idle minutes before a stopped sandbox is deleted. Override `DAYTONA_AUTODELETE`. */ -export function daytonaAutoDeleteMinutes( - rawValue: string | undefined = process.env.DAYTONA_AUTODELETE, -): number { - return positiveMinutes(rawValue, DEFAULT_DAYTONA_AUTODELETE_MINUTES); -} - -/** - * Build the Daytona `create` object from the runner's env + the resolved run inputs. + * Build the Daytona `create` object from the typed runner config + the resolved run inputs. * - * Pulled out as a pure function because the real `daytona()` provider closes over this object - * and constructs a Daytona client (needs API-key env), so the create fields cannot be inspected - * through `buildSandboxProvider`. Testing this directly is the only way to pin that the create - * object carries the auto-stop leak backstop (and `ephemeral`). + * Pulled out as a pure function because the vendored `daytona()` provider closes over this object + * and constructs a Daytona client, so the create fields cannot be inspected through + * `buildSandboxProvider`. Testing this directly is the only way to pin that the create object + * carries the auto-stop leak backstop (and `ephemeral`). + * + * The artifact is the configured snapshot, else the configured image (applied at the top-level + * provider option, so no snapshot rides the create), else the runner's pinned default snapshot. + * Snapshot and image are mutually exclusive — the config parser already rejects setting both. */ export function buildDaytonaCreate( + daytona: RunnerDaytonaConfig, piExtEnv: Record, secrets: Record, sandboxPermission: SandboxPermission | undefined, ): Record { - // Agent override first, then the snapshot shared with the code evaluators. - const snapshot = - nonEmpty(process.env.DAYTONA_SNAPSHOT_AGENT) ?? - nonEmpty(process.env.DAYTONA_SNAPSHOT); - const target = process.env.DAYTONA_TARGET; + const snapshot = daytona.image + ? undefined + : (daytona.snapshot ?? DEFAULT_DAYTONA_SNAPSHOT); + const target = daytona.target; return { // The sandbox-agent provider always sets a default `image`, which Daytona turns into a // build entry that conflicts with `snapshot`. Spreading image:undefined last @@ -97,28 +75,24 @@ export function buildDaytonaCreate( // `ephemeral: false` lets stop park the sandbox. Leave autoArchiveInterval unset so Daytona's // seven-day default sits beyond our 30-minute delete. The ladder is stop, then delete. // These intervals override the wrapper's hardcoded zeroes. A leaked sandbox self-reaps. - autoStopInterval: daytonaAutoStopMinutes(), - autoDeleteInterval: daytonaAutoDeleteMinutes(), + autoStopInterval: daytona.autostopMinutes, + autoDeleteInterval: daytona.autodeleteMinutes, ephemeral: false, }; } -/** Sandbox ids this runner can actually provision (the "expected one of" set). */ -export const KNOWN_SANDBOX_IDS = ["local", "daytona"] as const; - /** Recognized ids that are planned but not yet provisionable (fail with a specific message). */ export const PLANNED_SANDBOX_IDS = ["e2b"] as const; /** * Build the sandbox-agent provider for the requested axis. * - * Daytona needs an image or snapshot that carries the daemon and harness CLI: the shared - * `DAYTONA_SNAPSHOT` (which the build recipe equips for code evaluators too), or the - * `DAYTONA_SNAPSHOT_AGENT` override when the two consumers must diverge. - * Provider keys come from the request secrets. Pi's self-managed login is only uploaded - * when no key is available. The Layer 2 network policy (S1b) is enforced on Daytona via - * `networkBlockAll` / `networkAllowList`; `buildRunPlan` rejects restricted policies the - * local provider cannot enforce before this is reached. + * Daytona is provisioned from an explicit client and create object derived from the typed runner + * config (snapshot/image/target/lifecycle). Provider keys come from the request secrets. The + * Layer 2 network policy (S1b) is enforced on Daytona via `networkBlockAll` / `networkAllowList`; + * `buildRunPlan` rejects restricted policies the local provider cannot enforce before this is + * reached. A known-but-disabled provider is refused here too (defense-in-depth for callers that + * bypass `buildRunPlan`). */ export function buildSandboxProvider( sandboxId: string, @@ -127,13 +101,35 @@ export function buildSandboxProvider( piExtEnv: Record, secrets: Record, sandboxPermission?: SandboxPermission, + config: RunnerConfig = loadRunnerConfig(), ) { + if ( + (KNOWN_SANDBOX_PROVIDER_IDS as readonly string[]).includes(sandboxId) && + !config.providers.enabled.includes(sandboxId as SandboxProviderId) + ) { + throw new Error( + `Sandbox provider '${sandboxId}' is not enabled on this deployment ` + + `(enabled: ${config.providers.enabled.join(", ")}).`, + ); + } + if (sandboxId === "daytona") { - const image = process.env.DAYTONA_IMAGE; - return daytonaWithLifecycle({ - ...(image ? { image } : {}), - create: buildDaytonaCreate(piExtEnv, secrets, sandboxPermission) as any, - }); + // Bridge the typed credential into the ambient names the vendored provider's own + // `new Daytona()` reads during creation; hand the lifecycle wrapper an explicit client. + applyDaytonaSdkEnv(config.daytona); + const image = config.daytona.image; + return daytonaWithLifecycle( + { + ...(image ? { image } : {}), + create: buildDaytonaCreate( + config.daytona, + piExtEnv, + secrets, + sandboxPermission, + ) as any, + }, + { client: buildDaytonaClient(config.daytona) }, + ); } if ((PLANNED_SANDBOX_IDS as readonly string[]).includes(sandboxId)) { @@ -145,11 +141,11 @@ export function buildSandboxProvider( if (sandboxId !== "local") { // Refuse loud: an unrecognized id must not fall through to host execution. throw new Error( - `Unknown sandbox id '${sandboxId}'; expected one of ${KNOWN_SANDBOX_IDS.join(", ")}`, + `Unknown sandbox id '${sandboxId}'; expected one of ${KNOWN_SANDBOX_PROVIDER_IDS.join(", ")}`, ); } // local: spawn `sandbox-agent server` on this host with the daemon env merged in. - const logMode = (process.env.SANDBOX_AGENT_LOG_LEVEL ?? "silent") as any; + const logMode = config.server.logLevel as any; return local({ env, binaryPath, log: logMode }); } diff --git a/services/runner/src/engines/sandbox_agent/run-plan.ts b/services/runner/src/engines/sandbox_agent/run-plan.ts index 200ad17110..d6dae9ace7 100644 --- a/services/runner/src/engines/sandbox_agent/run-plan.ts +++ b/services/runner/src/engines/sandbox_agent/run-plan.ts @@ -30,6 +30,11 @@ import { } from "../skills.ts"; import { assert } from "./capabilities.ts"; import { buildTurnText } from "./transcript.ts"; +import { + KNOWN_SANDBOX_PROVIDER_IDS, + loadRunnerConfig, + type SandboxProviderId, +} from "../../config/runner-config.ts"; type Log = (message: string) => void; @@ -139,6 +144,8 @@ export type BuildRunPlanResult = export interface BuildRunPlanDeps { sandboxProvider?: string; + /** Providers this deployment enables; a request for anything outside this set is rejected. */ + enabledProviders?: readonly SandboxProviderId[]; createLocalCwd?: (durableCwd?: string) => string; createDaytonaCwd?: (durableCwd?: string) => string; /** Pre-computed durable cwd derived from the sign prefix; when set, skips the ephemeral helpers. */ @@ -232,7 +239,8 @@ function defaultDaytonaCwd(durableCwd?: string): string { export function buildRunPlan( request: AgentRunRequest, { - sandboxProvider = process.env.SANDBOX_AGENT_PROVIDER, + sandboxProvider, + enabledProviders, createLocalCwd = defaultLocalCwd, createDaytonaCwd = defaultDaytonaCwd, durableCwd, @@ -240,8 +248,26 @@ export function buildRunPlan( log = () => {}, }: BuildRunPlanDeps = {}, ): BuildRunPlanResult { + const runnerConfig = loadRunnerConfig(); + const defaultProvider = sandboxProvider ?? runnerConfig.providers.default; + const enabled = enabledProviders ?? runnerConfig.providers.enabled; const harness = request.harness || "pi_core"; - const sandboxId = request.sandbox || sandboxProvider || "local"; + const sandboxId = request.sandbox || defaultProvider || "local"; + + // Deployment posture gate (interface.md section 2, rule 7): a request for a known but disabled + // provider fails here, before any cwd/temp dir, mount, file, secret, or sandbox is created. + // There is no silent fallback to another provider. + if ( + (KNOWN_SANDBOX_PROVIDER_IDS as readonly string[]).includes(sandboxId) && + !enabled.includes(sandboxId as SandboxProviderId) + ) { + return { + ok: false, + error: + `Sandbox provider '${sandboxId}' is not enabled on this deployment ` + + `(enabled: ${enabled.join(", ")}).`, + }; + } // The harness identity maps to a real ACP agent the daemon knows (`pi` / `claude`). // `pi_core` (plain Pi) and `pi_agenta` (Pi with Agenta's forced skills/prompt/policy) both diff --git a/services/runner/src/engines/sandbox_agent/session-pool.ts b/services/runner/src/engines/sandbox_agent/session-pool.ts index 245bf8c44c..b8d607f966 100644 --- a/services/runner/src/engines/sandbox_agent/session-pool.ts +++ b/services/runner/src/engines/sandbox_agent/session-pool.ts @@ -22,6 +22,7 @@ import { } from "../../protocol.ts"; import { approvalDecisionOf } from "../../responder.ts"; import type { TeardownReason } from "./teardown.ts"; +import { loadRunnerConfig } from "../../config/runner-config.ts"; function log(message: string): void { process.stderr.write(`[keepalive] ${message}\n`); @@ -113,16 +114,15 @@ export function readKeepaliveConfig( /** * `poolMax` (and the LRU/TTL eviction it drives) is a LOCAL-provider parameter — "how many * ~300 MB hot Claude trees fit on this runner host" — never a global one. Mirrors `run-plan.ts`'s - * own sandbox-id resolution (`request.sandbox || SANDBOX_AGENT_PROVIDER env || "local"`), kept - * here (not imported from there) so this module stays engine-agnostic. The pool dispatch + * own sandbox-id resolution (`request.sandbox || configured default provider`). The pool dispatch * (`server.ts` `isLocalSandbox`) and the continuity module's own local/remote framing both * resolve through this one function, so the "local-only" invariant has a single source of truth. */ export function resolvesToLocalProvider( requestSandbox: string | undefined, - env: NodeJS.ProcessEnv = process.env, + defaultProvider: string = loadRunnerConfig().providers.default, ): boolean { - return (requestSandbox || env.SANDBOX_AGENT_PROVIDER || "local") === "local"; + return (requestSandbox || defaultProvider) === "local"; } // --- Fingerprints and the pool key ------------------------------------------ // diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index adbed21749..62eab0d61a 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -60,20 +60,22 @@ import { type LiveSession, } from "./engines/sandbox_agent/session-pool.ts"; import { runnerInfo } from "./version.ts"; +import { + loadRunnerConfig, + runnerConfigSummary, +} from "./config/runner-config.ts"; +import { applyDaytonaSdkEnv } from "./engines/sandbox_agent/daytona-provider.ts"; import { isEntrypoint } from "./entry.ts"; import { insecureEgressAllowed } from "./tools/ssrf-guard.ts"; import { startAliveWatchdog } from "./sessions/alive.ts"; import { cancelStaleInteractions } from "./sessions/interactions.ts"; import { buildPersistingEmitter } from "./sessions/persist.ts"; -const PORT = Number(process.env.AGENTA_RUNNER_PORT ?? 8765); - -// Bind to loopback by default (sidecar-trust step 1): the `/run` body carries plaintext -// provider secrets and reusable bearer tokens, so the sidecar MUST sit on a trusted, -// non-public network. `127.0.0.1` keeps it reachable only from the same host (the co-located -// Python service) and never `0.0.0.0`. In Kubernetes/Compose, set `AGENTA_RUNNER_HOST` -// to the private pod/internal-network interface; never publish the port to the host. -const HOST = process.env.AGENTA_RUNNER_HOST ?? "127.0.0.1"; +// Server binding (host/port) comes from the typed `RunnerConfig` resolved at boot. The host +// binds to loopback by default (sidecar-trust step 1): the `/run` body carries plaintext provider +// secrets and reusable bearer tokens, so the sidecar MUST sit on a trusted, non-public network. +// In Kubernetes/Compose, set `AGENTA_RUNNER_HOST` to the private pod/internal-network interface; +// never publish the port to the host. // Optional shared `/run` token (sidecar-trust step 2): default OFF. When // `AGENTA_RUNNER_TOKEN` is set, every `/run` request must present the same secret (in @@ -300,8 +302,7 @@ export function resolveKeepaliveProvider( request: AgentRunRequest, ): KeepaliveProviderName | undefined { if (resolvesToLocalProvider(request.sandbox)) return "local"; - const provider = - request.sandbox ?? process.env.SANDBOX_AGENT_PROVIDER ?? "local"; + const provider = request.sandbox ?? loadRunnerConfig().providers.default; return provider === "daytona" ? "daytona" : undefined; } @@ -1130,20 +1131,35 @@ if (isEntrypoint(import.meta.url)) { }, }); - createAgentServer().listen(PORT, HOST, () => { - process.stderr.write( - `[sandbox-agent] http server listening on ${HOST}:${PORT}\n`, - ); - if (insecureEgressAllowed()) { - process.stderr.write( - "[sandbox-agent] WARNING: AGENTA_INSECURE_EGRESS_ALLOWED is set: user MCPs may " + - "target http and private/loopback/metadata hosts. Use only for trusted/single-tenant deployments.\n", - ); - } else { + // Parse and validate the operator configuration ONCE before listening. An invalid + // configuration (empty/unknown provider list, default not enabled, Daytona enabled without a + // credential, mutually exclusive artifact, invalid lifecycle values) fails startup here. Log + // one redacted summary, then bridge the typed Daytona credential into the ambient names the + // vendored SDK reads during sandbox creation. + const runnerConfig = loadRunnerConfig(); + process.stderr.write(`[sandbox-agent] ${runnerConfigSummary(runnerConfig)}\n`); + if (runnerConfig.providers.enabled.includes("daytona")) { + applyDaytonaSdkEnv(runnerConfig.daytona); + } + + createAgentServer().listen( + runnerConfig.server.port, + runnerConfig.server.host, + () => { process.stderr.write( - "[sandbox-agent] Outbound egress is in restricted mode: user MCPs must use https and " + - "public hosts (private/loopback/link-local/metadata targets are blocked).\n", + `[sandbox-agent] http server listening on ${runnerConfig.server.host}:${runnerConfig.server.port}\n`, ); - } - }); + if (insecureEgressAllowed()) { + process.stderr.write( + "[sandbox-agent] WARNING: AGENTA_INSECURE_EGRESS_ALLOWED is set: user MCPs may " + + "target http and private/loopback/metadata hosts. Use only for trusted/single-tenant deployments.\n", + ); + } else { + process.stderr.write( + "[sandbox-agent] Outbound egress is in restricted mode: user MCPs must use https and " + + "public hosts (private/loopback/link-local/metadata targets are blocked).\n", + ); + } + }, + ); } diff --git a/services/runner/tests/setup/hermetic-env.ts b/services/runner/tests/setup/hermetic-env.ts index 72ad229fcd..cf23385c76 100644 --- a/services/runner/tests/setup/hermetic-env.ts +++ b/services/runner/tests/setup/hermetic-env.ts @@ -8,20 +8,34 @@ */ import { beforeEach } from "vitest"; +import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; + const SCRUBBED = [ "AGENTA_INSECURE_EGRESS_ALLOWED", "AGENTA_WEBHOOKS_ALLOW_INSECURE", "AGENTA_WEBHOOK_ALLOW_INSECURE", + // Scrub BOTH the operator-facing runner names and the ambient SDK names the runner bridges + // into, so a loaded dev env cannot silently flip a test that asserts the no-credential default. + "AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS", + "AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER", + "AGENTA_RUNNER_DAYTONA_API_KEY", + "AGENTA_RUNNER_DAYTONA_API_URL", + "AGENTA_RUNNER_DAYTONA_TARGET", + "AGENTA_RUNNER_DAYTONA_SNAPSHOT", + "AGENTA_RUNNER_DAYTONA_IMAGE", + "AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES", + "AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES", "DAYTONA_API_KEY", "DAYTONA_API_URL", "DAYTONA_TARGET", "DAYTONA_SNAPSHOT", - "DAYTONA_SNAPSHOT_AGENT", ]; for (const name of SCRUBBED) delete process.env[name]; -// Re-scrub per test: a prior test may have set one and not restored it. +// Re-scrub per test: a prior test may have set one and not restored it. Also drop the memoized +// runner config so the next `loadRunnerConfig()` re-parses the scrubbed environment. beforeEach(() => { for (const name of SCRUBBED) delete process.env[name]; + resetRunnerConfigCache(); }); diff --git a/services/runner/tests/unit/runner-config.test.ts b/services/runner/tests/unit/runner-config.test.ts new file mode 100644 index 0000000000..0dd470e1bd --- /dev/null +++ b/services/runner/tests/unit/runner-config.test.ts @@ -0,0 +1,188 @@ +/** + * Unit tests for the typed runner configuration parser (the single parse-and-validate boundary). + * Mirrors the API-side parser cases (qa.md section 2) so both readers agree on every input. + * + * Run: pnpm test (or: pnpm exec vitest run tests/unit/runner-config.test.ts) + */ +import { describe, it } from "vitest"; +import assert from "node:assert/strict"; + +import { + DEFAULT_DAYTONA_AUTOSTOP_MINUTES, + DEFAULT_DAYTONA_AUTODELETE_MINUTES, + RunnerConfigError, + parseRunnerConfig, +} from "../../src/config/runner-config.ts"; + +/** Parse with only the given keys set (a valid Daytona key is supplied when daytona is enabled). */ +function parse(env: Record) { + return parseRunnerConfig(env); +} + +describe("enabled providers", () => { + it("unset gives exactly local", () => { + assert.deepEqual(parse({}).providers.enabled, ["local"]); + }); + + it("explicit local,daytona is order-independent set equality", () => { + const a = parse({ + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: "local,daytona", + AGENTA_RUNNER_DAYTONA_API_KEY: "k", + }).providers.enabled; + const b = parse({ + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: "daytona,local", + AGENTA_RUNNER_DAYTONA_API_KEY: "k", + }).providers.enabled; + assert.deepEqual([...a].sort(), [...b].sort()); + }); + + it("normalizes leading/trailing whitespace and case", () => { + assert.deepEqual( + parse({ + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: " LOCAL , Daytona ", + AGENTA_RUNNER_DAYTONA_API_KEY: "k", + }).providers.enabled, + ["local", "daytona"], + ); + }); + + it("duplicate ids fail", () => { + assert.throws( + () => parse({ AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: "local,local" }), + RunnerConfigError, + ); + }); + + it("unknown ids fail", () => { + assert.throws( + () => parse({ AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: "local,e2b" }), + RunnerConfigError, + ); + }); + + it("explicit empty string fails", () => { + assert.throws( + () => parse({ AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: "" }), + RunnerConfigError, + ); + assert.throws( + () => parse({ AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: " " }), + RunnerConfigError, + ); + }); +}); + +describe("default provider", () => { + it("unset gives local", () => { + assert.equal(parse({}).providers.default, "local"); + }); + + it("respects an explicit default within the enabled set", () => { + assert.equal( + parse({ + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: "local,daytona", + AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER: "daytona", + AGENTA_RUNNER_DAYTONA_API_KEY: "k", + }).providers.default, + "daytona", + ); + }); + + it("a default outside the enabled set fails", () => { + assert.throws( + () => + parse({ + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: "daytona", + AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER: "local", + AGENTA_RUNNER_DAYTONA_API_KEY: "k", + }), + RunnerConfigError, + ); + }); +}); + +describe("daytona configuration", () => { + it("daytona enabled without a provisioning credential fails", () => { + assert.throws( + () => parse({ AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: "local,daytona" }), + RunnerConfigError, + ); + }); + + it("daytona absent ignores optional daytona tuning (no credential required)", () => { + const config = parse({ + AGENTA_RUNNER_DAYTONA_TARGET: "eu", + AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES: "9", + }); + assert.deepEqual(config.providers.enabled, ["local"]); + // Optional tuning is still parsed, but with no credential requirement. + assert.equal(config.daytona.target, "eu"); + assert.equal(config.daytona.apiKey, undefined); + }); + + it("snapshot plus image fails", () => { + assert.throws( + () => + parse({ + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: "local,daytona", + AGENTA_RUNNER_DAYTONA_API_KEY: "k", + AGENTA_RUNNER_DAYTONA_SNAPSHOT: "snap", + AGENTA_RUNNER_DAYTONA_IMAGE: "img", + }), + RunnerConfigError, + ); + }); + + it("invalid zero, negative, non-numeric, and fractional lifecycle values fail", () => { + for (const bad of ["0", "-5", "soon", "12.9"]) { + assert.throws( + () => parse({ AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES: bad }), + RunnerConfigError, + `expected '${bad}' to fail`, + ); + } + }); + + it("compose-substituted empty optional values become absent (default lifecycle)", () => { + const config = parse({ + AGENTA_RUNNER_DAYTONA_SNAPSHOT: "", + AGENTA_RUNNER_DAYTONA_IMAGE: "", + AGENTA_RUNNER_DAYTONA_TARGET: "", + AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES: "", + AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: " ", + }); + assert.equal(config.daytona.snapshot, undefined); + assert.equal(config.daytona.image, undefined); + assert.equal(config.daytona.target, undefined); + assert.equal(config.daytona.autostopMinutes, DEFAULT_DAYTONA_AUTOSTOP_MINUTES); + assert.equal(config.daytona.autodeleteMinutes, DEFAULT_DAYTONA_AUTODELETE_MINUTES); + }); +}); + +describe("server + callback sections", () => { + it("empty strings collapse to defaults/absent at the parse boundary", () => { + const config = parse({ + AGENTA_RUNNER_HOST: "", + AGENTA_RUNNER_LOG_LEVEL: "", + AGENTA_RUNNER_TOKEN: "", + AGENTA_API_INTERNAL_URL: "", + }); + assert.equal(config.server.host, "127.0.0.1"); + assert.equal(config.server.logLevel, "silent"); + assert.equal(config.server.token, undefined); + assert.equal(config.callback.apiInternalUrl, undefined); + }); + + it("reads explicit server values", () => { + const config = parse({ + AGENTA_RUNNER_HOST: "0.0.0.0", + AGENTA_RUNNER_PORT: "9000", + AGENTA_RUNNER_LOG_LEVEL: "info", + AGENTA_RUNNER_TOKEN: "secret", + }); + assert.equal(config.server.host, "0.0.0.0"); + assert.equal(config.server.port, 9000); + assert.equal(config.server.logLevel, "info"); + assert.equal(config.server.token, "secret"); + }); +}); diff --git a/services/runner/tests/unit/sandbox-agent-daemon.test.ts b/services/runner/tests/unit/sandbox-agent-daemon.test.ts index 349ac05daa..e2cab5338e 100644 --- a/services/runner/tests/unit/sandbox-agent-daemon.test.ts +++ b/services/runner/tests/unit/sandbox-agent-daemon.test.ts @@ -22,6 +22,7 @@ const touched = [ "CLAUDE_CONFIG_DIR", "COMPOSIO_API_KEY", "DAYTONA_API_KEY", + "AGENTA_RUNNER_DAYTONA_API_KEY", // Every var the clear-inventory test touches is the full known provider inventory plus the // cloud groups, so the afterEach restores them all. ...KNOWN_PROVIDER_ENV_VARS, @@ -111,8 +112,9 @@ describe("buildDaemonEnv", () => { assert.ok(env.PATH); }); - it("force-blanks infra creds (DAYTONA_API_KEY) on every run, managed or not (F-INFRA-ENV)", () => { + it("force-blanks infra creds (DAYTONA_API_KEY + AGENTA_RUNNER_DAYTONA_API_KEY) on every run, managed or not (F-INFRA-ENV)", () => { process.env.DAYTONA_API_KEY = "org-key-should-not-leak"; + process.env.AGENTA_RUNNER_DAYTONA_API_KEY = "runner-key-should-not-leak"; // The underlying sandbox-agent local() provider spawns with // {...process.env, ...options.env} (inherit-then-apply), so an ABSENT key here would not diff --git a/services/runner/tests/unit/sandbox-agent-daytona.test.ts b/services/runner/tests/unit/sandbox-agent-daytona.test.ts index 7582f9aabd..a40b6a1493 100644 --- a/services/runner/tests/unit/sandbox-agent-daytona.test.ts +++ b/services/runner/tests/unit/sandbox-agent-daytona.test.ts @@ -3,24 +3,24 @@ * * Run: pnpm test (or: pnpm exec vitest run tests/unit/sandbox-agent-daytona.test.ts) */ -import { afterEach, describe, it, vi } from "vitest"; +import { afterEach, describe, it } from "vitest"; import assert from "node:assert/strict"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { + DAYTONA_PI_COMMAND, DAYTONA_PI_DIR, - DAYTONA_PI_INSTALL, DAYTONA_PI_INSTALL_DIR, - DAYTONA_PI_VERSION, + PINNED_PI_VERSION, createCookieFetch, daytonaEnvVars, - installPiInSandbox, + ensurePiInSandbox, uploadPiAuthToSandbox, } from "../../src/engines/sandbox_agent/daytona.ts"; -const envKeys = ["PI_CODING_AGENT_DIR", "AGENTA_AGENT_SANDBOX_PI_INSTALLED"]; +const envKeys = ["PI_CODING_AGENT_DIR"]; const previousEnv = new Map(); for (const key of envKeys) previousEnv.set(key, process.env[key]); @@ -37,7 +37,7 @@ afterEach(() => { }); describe("daytonaEnvVars", () => { - it("combines Pi agent dir, extension env, provider secrets, and Pi command", () => { + it("combines Pi agent dir, extension env, provider secrets, and the pinned Pi command", () => { const env = daytonaEnvVars( { TRACEPARENT: "trace", AGENTA_AGENT_TOOLS_RELAY_DIR: "/relay" }, { OPENAI_API_KEY: "key" }, @@ -47,66 +47,105 @@ describe("daytonaEnvVars", () => { assert.equal(env.TRACEPARENT, "trace"); assert.equal(env.AGENTA_AGENT_TOOLS_RELAY_DIR, "/relay"); assert.equal(env.OPENAI_API_KEY, "key"); - if (DAYTONA_PI_INSTALL) { - assert.equal(env.PI_ACP_PI_COMMAND, `${DAYTONA_PI_INSTALL_DIR}/node_modules/.bin/pi`); - } + // The command always points at the runner-pinned Pi path; the probe/repair path guarantees + // the binary is present there before the session runs. + assert.equal(env.PI_ACP_PI_COMMAND, DAYTONA_PI_COMMAND); }); }); -describe("DAYTONA_PI_INSTALL default", () => { - afterEach(() => { - vi.resetModules(); - }); +describe("ensurePiInSandbox (probe and pinned-install repair)", () => { + it("skips the install when the pinned Pi executable is already present (baked snapshot)", async () => { + const calls: any[] = []; + const sandbox = { + mkdirFs: async () => {}, + runProcess: async (input: any) => { + calls.push(input); + // `test -x ` succeeds: Pi is already baked in. + return { exitCode: 0 }; + }, + }; - it("defaults to installing Pi for a fresh bare sandbox", async () => { - delete process.env.AGENTA_AGENT_SANDBOX_PI_INSTALLED; - vi.resetModules(); - const mod = await import("../../src/engines/sandbox_agent/daytona.ts"); - assert.equal(mod.DAYTONA_PI_INSTALL, true); - }); + await ensurePiInSandbox(sandbox); - it("installs Pi when explicitly enabled", async () => { - process.env.AGENTA_AGENT_SANDBOX_PI_INSTALLED = "true"; - vi.resetModules(); - const mod = await import("../../src/engines/sandbox_agent/daytona.ts"); - assert.equal(mod.DAYTONA_PI_INSTALL, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "test"); + assert.deepEqual(calls[0].args, ["-x", DAYTONA_PI_COMMAND]); }); - it("skips the session install only when the snapshot already bakes Pi", async () => { - process.env.AGENTA_AGENT_SANDBOX_PI_INSTALLED = "false"; - vi.resetModules(); - const mod = await import("../../src/engines/sandbox_agent/daytona.ts"); - assert.equal(mod.DAYTONA_PI_INSTALL, false); + it("links a PATH-baked pi to the pinned path instead of reinstalling (recipe snapshot)", async () => { + const calls: any[] = []; + let probed = 0; + const sandbox = { + mkdirFs: async () => {}, + runProcess: async (input: any) => { + calls.push(input); + if (input.command === "test") { + probed += 1; + // Pinned path missing before the link, present after it. + return { exitCode: probed === 1 ? 1 : 0 }; + } + // The `sh -lc command -v pi && ln -sf ...` link succeeds. + return { exitCode: 0 }; + }, + }; + + await ensurePiInSandbox(sandbox); + + assert.ok( + calls.some((c) => c.command === "sh"), + "expected the global-pi link attempt", + ); + assert.equal( + calls.some((c) => c.command === "npm"), + false, + "a baked snapshot must not pay a session-time npm install", + ); }); -}); -describe("installPiInSandbox", () => { - it("installs the pinned Pi version", async () => { + it("installs the pinned Pi version when the probe and PATH both miss (custom image)", async () => { const calls: any[] = []; const sandbox = { mkdirFs: async () => {}, runProcess: async (input: any) => { calls.push(input); + if (input.command === "test") { + // Missing until the install completes. + return { exitCode: calls.some((c) => c.command === "npm") ? 0 : 1 }; + } + if (input.command === "sh") return { exitCode: 1 }; // no pi on PATH return { exitCode: 0 }; }, }; - await installPiInSandbox(sandbox); + await ensurePiInSandbox(sandbox); - assert.equal(DAYTONA_PI_VERSION, "0.80.6"); - assert.deepEqual(calls, [ - { - command: "npm", - args: [ - "install", - "--no-fund", - "--no-audit", - "@earendil-works/pi-coding-agent@0.80.6", - ], - cwd: DAYTONA_PI_INSTALL_DIR, - timeoutMs: 180_000, - }, + const install = calls.find((c) => c.command === "npm"); + assert.ok(install, "expected a pinned npm install"); + assert.deepEqual(install.args, [ + "install", + "--no-fund", + "--no-audit", + `@earendil-works/pi-coding-agent@${PINNED_PI_VERSION}`, ]); + assert.equal(install.cwd, DAYTONA_PI_INSTALL_DIR); + }); + + it("fails the run when Pi is still missing after the install attempt", async () => { + const sandbox = { + mkdirFs: async () => {}, + runProcess: async (input: any) => { + // Probe and PATH both always miss; install "succeeds" but leaves nothing behind. + if (input.command === "test" || input.command === "sh") { + return { exitCode: 1 }; + } + return { exitCode: 0 }; + }, + }; + + await assert.rejects( + () => ensurePiInSandbox(sandbox), + new RegExp(`pi ${PINNED_PI_VERSION} is not available`), + ); }); }); diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index 6daf667df9..f411495c45 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -3,7 +3,7 @@ * * Run: pnpm test (or: pnpm exec vitest run tests/unit/sandbox-agent-orchestration.test.ts) */ -import { describe, it } from "vitest"; +import { beforeEach, describe, it } from "vitest"; import assert from "node:assert/strict"; import { tmpdir } from "node:os"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; @@ -22,6 +22,15 @@ import { runSandboxAgent, type SandboxAgentDeps, } from "../../src/engines/sandbox_agent.ts"; +import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; + +// Orchestration cases include Daytona runs: enable it (with a provisioning credential) on top of +// the hermetic scrub, then drop the memoized config so the run plan reads the enabled set. +beforeEach(() => { + process.env.AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS = "local,daytona"; + process.env.AGENTA_RUNNER_DAYTONA_API_KEY = "test-key"; + resetRunnerConfigCache(); +}); function flushPromises(): Promise { return new Promise((resolve) => setImmediate(resolve)); diff --git a/services/runner/tests/unit/sandbox-agent-provider.test.ts b/services/runner/tests/unit/sandbox-agent-provider.test.ts index 4a2fb16947..625cddbd2e 100644 --- a/services/runner/tests/unit/sandbox-agent-provider.test.ts +++ b/services/runner/tests/unit/sandbox-agent-provider.test.ts @@ -1,38 +1,49 @@ /** - * Unit tests for the Layer 2 network policy -> Daytona create field mapping and the - * stop and delete lifecycle intervals. + * Unit tests for the Layer 2 network policy -> Daytona create field mapping, the stop/delete + * lifecycle intervals (now sourced from the typed runner config), and the provider factory's + * enabled-provider gate. * - * The mapping is tested directly because the real `daytona()` provider closes over its - * create object and constructs a Daytona client (needs API-key env), so it cannot be - * inspected through `buildSandboxProvider`. The orchestration test covers that the run - * plan's `sandboxPermission` reaches `buildSandboxProvider` as the new argument. + * The mapping is tested directly because the real `daytona()` provider closes over its create + * object and constructs a Daytona client, so it cannot be inspected through `buildSandboxProvider`. * * Run: pnpm test (or: pnpm exec vitest run tests/unit/sandbox-agent-provider.test.ts) */ -import { afterEach, describe, it } from "vitest"; +import { describe, it } from "vitest"; import assert from "node:assert/strict"; import { - DEFAULT_DAYTONA_AUTOSTOP_MINUTES, - DEFAULT_DAYTONA_AUTODELETE_MINUTES, buildDaytonaCreate, buildSandboxProvider, - daytonaAutoStopMinutes, - daytonaAutoDeleteMinutes, daytonaNetworkFields, } from "../../src/engines/sandbox_agent/provider.ts"; - -const LIFECYCLE_ENVS = ["DAYTONA_AUTOSTOP", "DAYTONA_AUTODELETE"]; -const previous = Object.fromEntries(LIFECYCLE_ENVS.map((k) => [k, process.env[k]])); - -afterEach(() => { - for (const k of LIFECYCLE_ENVS) { - if (previous[k] === undefined) delete process.env[k]; - else process.env[k] = previous[k]; - } -}); - -const AUTOSTOP_ENV = "DAYTONA_AUTOSTOP"; +import { + DEFAULT_DAYTONA_AUTOSTOP_MINUTES, + DEFAULT_DAYTONA_AUTODELETE_MINUTES, + DEFAULT_DAYTONA_SNAPSHOT, + parseRunnerConfig, + type RunnerConfig, + type RunnerDaytonaConfig, +} from "../../src/config/runner-config.ts"; + +/** A typed Daytona config with the given overrides on top of parsed defaults. */ +function daytonaConfig( + overrides: Partial = {}, +): RunnerDaytonaConfig { + const base = parseRunnerConfig({}).daytona; + return { ...base, ...overrides }; +} + +/** A full runner config with a given enabled set + Daytona overrides (bypasses process.env). */ +function runnerConfig( + enabled: string, + daytona: Partial = {}, +): RunnerConfig { + const config = parseRunnerConfig({ + AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS: enabled, + AGENTA_RUNNER_DAYTONA_API_KEY: "test-key", + }); + return { ...config, daytona: { ...config.daytona, ...daytona } }; +} describe("daytonaNetworkFields", () => { it("blocks all egress for network:off", () => { @@ -74,40 +85,9 @@ describe("daytonaNetworkFields", () => { }); }); -describe("daytona lifecycle interval parsers", () => { - it("use the env value when it is a positive integer", () => { - assert.equal(daytonaAutoStopMinutes("30"), 30); - assert.equal(daytonaAutoDeleteMinutes("2880"), 2880); - }); - - it("floor a fractional env value to whole minutes", () => { - assert.equal(daytonaAutoStopMinutes("12.9"), 12); - }); - - it("fall back to their defaults when the env is unset", () => { - assert.equal(daytonaAutoStopMinutes(undefined), DEFAULT_DAYTONA_AUTOSTOP_MINUTES); - assert.equal(daytonaAutoDeleteMinutes(undefined), DEFAULT_DAYTONA_AUTODELETE_MINUTES); - }); - - it("fall back to the default for a non-numeric env value", () => { - assert.equal(daytonaAutoStopMinutes("soon"), DEFAULT_DAYTONA_AUTOSTOP_MINUTES); - }); - - it("clamp 0 and negatives to the default (a disabled reaper would leak)", () => { - assert.equal(daytonaAutoStopMinutes("0"), DEFAULT_DAYTONA_AUTOSTOP_MINUTES); - assert.equal(daytonaAutoDeleteMinutes("-5"), DEFAULT_DAYTONA_AUTODELETE_MINUTES); - }); - - it("orders the defaults stop before delete", () => { - assert.ok(DEFAULT_DAYTONA_AUTOSTOP_MINUTES >= 1); - assert.ok(DEFAULT_DAYTONA_AUTOSTOP_MINUTES < DEFAULT_DAYTONA_AUTODELETE_MINUTES); - }); -}); - -describe("buildDaytonaCreate (lifecycle on the create object)", () => { +describe("buildDaytonaCreate (lifecycle + artifact on the create object)", () => { it("carries stop and delete intervals without auto-archive by default", () => { - for (const k of LIFECYCLE_ENVS) delete process.env[k]; - const create = buildDaytonaCreate({}, {}, undefined); + const create = buildDaytonaCreate(daytonaConfig(), {}, {}, undefined); // ephemeral:false so a stop PARKS (warm) instead of deleting; the intervals are the reapers. assert.equal(create.ephemeral, false); assert.equal(create.autoStopInterval, DEFAULT_DAYTONA_AUTOSTOP_MINUTES); @@ -115,39 +95,38 @@ describe("buildDaytonaCreate (lifecycle on the create object)", () => { assert.equal(create.autoDeleteInterval, DEFAULT_DAYTONA_AUTODELETE_MINUTES); }); - it("prefers the agent snapshot var over the shared code-evaluator one", () => { - process.env["DAYTONA_SNAPSHOT"] = "daytona-small"; - const shared = buildDaytonaCreate({}, {}, undefined); - assert.equal(shared.snapshot, "daytona-small"); - process.env["DAYTONA_SNAPSHOT_AGENT"] = "agenta-sandbox-pi"; - const own = buildDaytonaCreate({}, {}, undefined); - assert.equal(own.snapshot, "agenta-sandbox-pi"); - delete process.env["DAYTONA_SNAPSHOT"]; - delete process.env["DAYTONA_SNAPSHOT_AGENT"]; + it("falls back to the runner's pinned default snapshot when none is configured", () => { + const create = buildDaytonaCreate(daytonaConfig(), {}, {}, undefined); + assert.equal(create.snapshot, DEFAULT_DAYTONA_SNAPSHOT); }); - it("falls back to the shared snapshot when the agent var is empty (Compose ${VAR:-} renders unset as \"\")", () => { - process.env["DAYTONA_SNAPSHOT_AGENT"] = ""; - process.env["DAYTONA_SNAPSHOT"] = "daytona-small"; - const create = buildDaytonaCreate({}, {}, undefined); + it("uses the configured snapshot when set", () => { + const create = buildDaytonaCreate( + daytonaConfig({ snapshot: "daytona-small" }), + {}, + {}, + undefined, + ); assert.equal(create.snapshot, "daytona-small"); - delete process.env["DAYTONA_SNAPSHOT"]; - delete process.env["DAYTONA_SNAPSHOT_AGENT"]; }); - it("omits the snapshot when both vars are empty strings", () => { - process.env["DAYTONA_SNAPSHOT_AGENT"] = ""; - process.env["DAYTONA_SNAPSHOT"] = ""; - const create = buildDaytonaCreate({}, {}, undefined); + it("omits the snapshot when an image is configured (image via the top-level option)", () => { + const create = buildDaytonaCreate( + daytonaConfig({ image: "custom:latest", snapshot: undefined }), + {}, + {}, + undefined, + ); assert.equal("snapshot" in create, false); - delete process.env["DAYTONA_SNAPSHOT"]; - delete process.env["DAYTONA_SNAPSHOT_AGENT"]; }); - it("carries the env-configured intervals", () => { - process.env["DAYTONA_AUTOSTOP"] = "5"; - process.env["DAYTONA_AUTODELETE"] = "120"; - const create = buildDaytonaCreate({}, {}, undefined); + it("carries the config-supplied lifecycle intervals", () => { + const create = buildDaytonaCreate( + daytonaConfig({ autostopMinutes: 5, autodeleteMinutes: 120 }), + {}, + {}, + undefined, + ); assert.equal(create.autoStopInterval, 5); assert.equal("autoArchiveInterval" in create, false); assert.equal(create.autoDeleteInterval, 120); @@ -155,27 +134,58 @@ describe("buildDaytonaCreate (lifecycle on the create object)", () => { }); }); -describe("buildSandboxProvider (unknown sandbox id must refuse, not run local)", () => { +describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () => { + const localOnly = parseRunnerConfig({}); + it("throws for an unrecognized sandbox id instead of falling back to local", () => { assert.throws( - () => buildSandboxProvider("typo-sandbox", {}, undefined, {}, {}), + () => + buildSandboxProvider( + "typo-sandbox", + {}, + undefined, + {}, + {}, + undefined, + localOnly, + ), /Unknown sandbox id 'typo-sandbox'/, ); }); - it("still resolves 'local' without refusing (no widening/narrowing of the known set)", () => { + it("resolves 'local' without refusing", () => { assert.doesNotThrow(() => - buildSandboxProvider("local", {}, undefined, {}, {}), + buildSandboxProvider("local", {}, undefined, {}, {}, undefined, localOnly), ); }); - it("'daytona' reaches the daytona() constructor, not the unknown-id refusal", () => { - // No DAYTONA_* credentials in this test env, so daytona() itself throws — proving the - // known-id branch was taken (the unknown-id error is never a credential error). + it("refuses 'daytona' when it is not enabled on this deployment", () => { assert.throws( - () => buildSandboxProvider("daytona", {}, undefined, {}, {}), - (err: unknown) => - err instanceof Error && !/Unknown sandbox id/.test(err.message), + () => + buildSandboxProvider( + "daytona", + {}, + undefined, + {}, + {}, + undefined, + localOnly, + ), + /not enabled on this deployment/, + ); + }); + + it("builds the 'daytona' provider when enabled and configured", () => { + assert.doesNotThrow(() => + buildSandboxProvider( + "daytona", + {}, + undefined, + {}, + {}, + undefined, + runnerConfig("local,daytona"), + ), ); }); }); diff --git a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts index 212b5c37d2..a8aa67263d 100644 --- a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts +++ b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts @@ -3,7 +3,7 @@ * * Run: pnpm test (or: pnpm exec vitest run tests/unit/sandbox-agent-run-plan.test.ts) */ -import { afterEach, describe, it } from "vitest"; +import { afterEach, beforeEach, describe, it } from "vitest"; import assert from "node:assert/strict"; import type { AgentRunRequest } from "../../src/protocol.ts"; @@ -12,10 +12,19 @@ import { shouldUploadOwnLogin, } from "../../src/engines/sandbox_agent/run-plan.ts"; import { RESERVED_MCP_SERVER_NAME_MESSAGE } from "../../src/engines/sandbox_agent/mcp.ts"; +import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; const previousPiDir = process.env.PI_CODING_AGENT_DIR; const previousDenyPermissions = process.env.SANDBOX_AGENT_DENY_PERMISSIONS; +// These cases exercise Daytona runs, so enable it (with a provisioning credential) on top of the +// hermetic scrub, then drop the memoized config so buildRunPlan reads the enabled set. +beforeEach(() => { + process.env.AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS = "local,daytona"; + process.env.AGENTA_RUNNER_DAYTONA_API_KEY = "test-key"; + resetRunnerConfigCache(); +}); + afterEach(() => { if (previousPiDir === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = previousPiDir; diff --git a/services/runner/tests/unit/sandbox-lifecycle.test.ts b/services/runner/tests/unit/sandbox-lifecycle.test.ts index 4b48c4b839..541d653a04 100644 --- a/services/runner/tests/unit/sandbox-lifecycle.test.ts +++ b/services/runner/tests/unit/sandbox-lifecycle.test.ts @@ -5,7 +5,7 @@ * * Run: pnpm exec vitest run tests/unit/sandbox-lifecycle.test.ts */ -import { describe, it } from "vitest"; +import { beforeEach, describe, it } from "vitest"; import assert from "node:assert/strict"; import { runSandboxAgent } from "../../src/engines/sandbox_agent.ts"; @@ -13,6 +13,15 @@ import type { SandboxAgentDeps } from "../../src/engines/sandbox_agent.ts"; import type { AgentRunRequest } from "../../src/protocol.ts"; import { DaytonaReconnectTerminalError } from "../../src/engines/sandbox_agent/daytona-provider.ts"; import { SessionContinuityStore } from "../../src/engines/sandbox_agent/session-continuity.ts"; +import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; + +// This whole suite drives the remote (Daytona) lifecycle: enable it (with a provisioning +// credential) on top of the hermetic scrub, then drop the memoized config. +beforeEach(() => { + process.env.AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS = "local,daytona"; + process.env.AGENTA_RUNNER_DAYTONA_API_KEY = "test-key"; + resetRunnerConfigCache(); +}); interface FakeOpts { /** What the harness reports for the turn. `"paused"` must never park. */ diff --git a/services/runner/tests/unit/session-pool.test.ts b/services/runner/tests/unit/session-pool.test.ts index b6c4b88346..675eb0491e 100644 --- a/services/runner/tests/unit/session-pool.test.ts +++ b/services/runner/tests/unit/session-pool.test.ts @@ -27,37 +27,21 @@ import { describe("resolvesToLocalProvider (local/remote gate)", () => { it("is true when the request explicitly asks for local", () => { - assert.equal(resolvesToLocalProvider("local", {}), true); + assert.equal(resolvesToLocalProvider("local", "local"), true); }); it("is false when the request explicitly asks for daytona", () => { - assert.equal(resolvesToLocalProvider("daytona", {}), false); + assert.equal(resolvesToLocalProvider("daytona", "local"), false); }); - it("falls back to SANDBOX_AGENT_PROVIDER when the request omits sandbox", () => { - assert.equal( - resolvesToLocalProvider(undefined, { SANDBOX_AGENT_PROVIDER: "daytona" }), - false, - ); - assert.equal( - resolvesToLocalProvider(undefined, { SANDBOX_AGENT_PROVIDER: "local" }), - true, - ); - }); - - it("defaults to local when neither the request nor env specify a provider", () => { - assert.equal(resolvesToLocalProvider(undefined, {}), true); + it("falls back to the configured default when the request omits sandbox", () => { + assert.equal(resolvesToLocalProvider(undefined, "daytona"), false); + assert.equal(resolvesToLocalProvider(undefined, "local"), true); }); - it("the request value wins over the env fallback", () => { - assert.equal( - resolvesToLocalProvider("local", { SANDBOX_AGENT_PROVIDER: "daytona" }), - true, - ); - assert.equal( - resolvesToLocalProvider("daytona", { SANDBOX_AGENT_PROVIDER: "local" }), - false, - ); + it("the request value wins over the configured default", () => { + assert.equal(resolvesToLocalProvider("local", "daytona"), true); + assert.equal(resolvesToLocalProvider("daytona", "local"), false); }); }); diff --git a/web/entrypoint.sh b/web/entrypoint.sh index 62345724d7..3faba9d8ed 100755 --- a/web/entrypoint.sh +++ b/web/entrypoint.sh @@ -63,9 +63,10 @@ else export AGENTA_BILLING_ENABLED="false" fi -# Mirror AGENTA_SANDBOX_LOCAL_ALLOWED (api/oss/src/utils/env.py _TRUTHY): unset -> enabled. -case "$(printf '%s' "${AGENTA_SANDBOX_LOCAL_ALLOWED:-true}" | tr '[:upper:]' '[:lower:]')" in - true|1|t|y|yes|on|enable|enabled) export AGENTA_SANDBOX_LOCAL_ENABLED="true" ;; +# Derive the local-sandbox picker flag from the shared provider registry +# (AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS; unset -> "local"): enabled iff "local" is listed. +case ",$(printf '%s' "${AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS:-local}" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')," in + *,local,*) export AGENTA_SANDBOX_LOCAL_ENABLED="true" ;; *) export AGENTA_SANDBOX_LOCAL_ENABLED="false" ;; esac