From 3f7a6b10327a31b9f2ab4b24cce96df1adf63603 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 3 Aug 2026 00:39:33 +0200 Subject: [PATCH 01/11] feat(runner): deliver agent credentials through Daytona Secrets Recut of #5277 onto current main. Behind AGENTA_DAYTONA_OPAQUE_SECRETS=process_local, the runner creates per-sandbox Daytona Secret records and sends only Secret placeholders in the sandbox create request instead of plaintext credential env vars. The SDK resolves managed model and HTTP MCP credentials into typed wire descriptors and fails closed when resolution is incomplete. Flag off, behavior matches main. Re-expressed over main's later reworks: typed run failures, model capability catalog, current-turn/attachment delivery, Pi built-ins rework, session-storage rework, mount-credential tracking, @daytona/sdk 0.198. --- .../agenta/sdk/agents/adapters/harnesses.py | 6 +- .../sdk/agents/adapters/sandbox_agent.py | 4 - sdks/python/agenta/sdk/agents/capabilities.py | 9 +- .../agenta/sdk/agents/connections/__init__.py | 10 + .../sdk/agents/connections/endpoints.py | 144 +++++ .../agenta/sdk/agents/connections/errors.py | 25 + .../agenta/sdk/agents/connections/models.py | 122 +++- .../agenta/sdk/agents/connections/resolver.py | 49 +- sdks/python/agenta/sdk/agents/dtos.py | 89 ++- sdks/python/agenta/sdk/agents/handler.py | 135 ++-- sdks/python/agenta/sdk/agents/interfaces.py | 6 +- sdks/python/agenta/sdk/agents/mcp/__init__.py | 4 + sdks/python/agenta/sdk/agents/mcp/models.py | 76 ++- sdks/python/agenta/sdk/agents/mcp/resolver.py | 23 +- .../agenta/sdk/agents/platform/connections.py | 14 +- sdks/python/agenta/sdk/agents/utils/wire.py | 18 +- sdks/python/agenta/sdk/agents/wire_models.py | 71 ++- sdks/python/agenta/sdk/redaction/context.py | 4 +- sdks/python/agenta/sdk/redaction/seed.py | 2 +- .../agents/connections/test_dtos_model_ref.py | 65 +- .../unit/agents/connections/test_models.py | 175 +++++- .../unit/agents/connections/test_resolver.py | 63 +- .../agents/golden/run_request.attachment.json | 12 +- .../agents/golden/run_request.claude.json | 82 ++- .../agents/golden/run_request.pi_core.json | 107 +++- .../pytest/unit/agents/mcp/test_resolver.py | 54 +- .../agents/platform/test_connections_http.py | 109 +++- .../agents/test_agent_composition_seam.py | 50 +- .../unit/agents/test_harness_adapters.py | 26 +- .../unit/agents/test_redaction_scope.py | 235 +++++++ .../pytest/unit/agents/test_wire_contract.py | 168 ++++- .../pytest/unit/agents/test_wire_models.py | 32 + .../src/engines/sandbox_agent/daemon.ts | 32 +- .../sandbox_agent/daytona-secret-plan.ts | 263 ++++++++ .../sandbox_agent/daytona-secret-provider.ts | 364 +++++++++++ .../engines/sandbox_agent/daytona-secrets.ts | 153 +++++ .../src/engines/sandbox_agent/daytona.ts | 7 +- .../sandbox_agent/environment-setup.ts | 50 +- .../src/engines/sandbox_agent/environment.ts | 43 +- .../src/engines/sandbox_agent/errors.ts | 4 +- .../runner/src/engines/sandbox_agent/mcp.ts | 69 ++- .../src/engines/sandbox_agent/pi-assets.ts | 29 + .../engines/sandbox_agent/pi-model-config.ts | 61 +- .../src/engines/sandbox_agent/provider.ts | 107 +++- .../src/engines/sandbox_agent/run-plan.ts | 203 +++++- .../src/engines/sandbox_agent/run-turn.ts | 31 +- .../engines/sandbox_agent/runtime-policy.ts | 6 +- .../engines/sandbox_agent/session-identity.ts | 74 ++- services/runner/src/extensions/agenta.ts | 19 + .../src/extensions/model-provider-override.ts | 65 ++ services/runner/src/protocol.ts | 81 ++- services/runner/src/redaction.ts | 44 +- services/runner/src/server.ts | 55 +- services/runner/tests/setup/hermetic-env.ts | 1 + .../tests/unit/daytona-secret-plan.test.ts | 275 +++++++++ .../unit/daytona-secret-provider.test.ts | 579 ++++++++++++++++++ .../runner/tests/unit/daytona-secrets.test.ts | 131 ++++ .../runner/tests/unit/extension-tools.test.ts | 45 ++ .../runner/tests/unit/mcp-servers.test.ts | 160 ++++- .../runner/tests/unit/redaction-sinks.test.ts | 61 +- .../tests/unit/sandbox-agent-daemon.test.ts | 2 +- .../unit/sandbox-agent-orchestration.test.ts | 206 ++++++- .../unit/sandbox-agent-pi-assets.test.ts | 73 +++ .../sandbox-agent-pi-model-config.test.ts | 34 +- .../tests/unit/sandbox-agent-provider.test.ts | 171 +++++- .../tests/unit/sandbox-agent-run-plan.test.ts | 335 +++++++++- services/runner/tests/unit/server.test.ts | 58 +- .../unit/session-keepalive-approval.test.ts | 103 +++- .../unit/session-keepalive-dispatch.test.ts | 14 +- .../tests/unit/session-mcp-layering.test.ts | 10 +- .../runner/tests/unit/session-pool.test.ts | 179 +++++- .../runner/tests/unit/wire-contract.test.ts | 7 +- services/runner/tests/utils/qa-transcripts.ts | 14 +- 73 files changed, 5457 insertions(+), 750 deletions(-) create mode 100644 sdks/python/agenta/sdk/agents/connections/endpoints.py create mode 100644 sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py create mode 100644 services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts create mode 100644 services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts create mode 100644 services/runner/src/engines/sandbox_agent/daytona-secrets.ts create mode 100644 services/runner/src/extensions/model-provider-override.ts create mode 100644 services/runner/tests/unit/daytona-secret-plan.test.ts create mode 100644 services/runner/tests/unit/daytona-secret-provider.test.ts create mode 100644 services/runner/tests/unit/daytona-secrets.test.ts diff --git a/sdks/python/agenta/sdk/agents/adapters/harnesses.py b/sdks/python/agenta/sdk/agents/adapters/harnesses.py index ef48231210..4577d5152c 100644 --- a/sdks/python/agenta/sdk/agents/adapters/harnesses.py +++ b/sdks/python/agenta/sdk/agents/adapters/harnesses.py @@ -63,9 +63,9 @@ def _to_harness_config(self, config: SessionConfig) -> PiAgentTemplate: return PiAgentTemplate( agents_md=config.agent.instructions, model=config.agent.model, - # Thread the structured ref so the author's connection {mode, slug} reaches the /run - # wire (via wire_model_ref). Without it a named custom (OpenAI-compatible) connection - # loses its slug and the runner cannot build its models.json plan. + # Thread the structured ref so the author's connection {mode, slug} reaches the + # connection resolver. Without it a named custom (OpenAI-compatible) connection + # loses its slug and cannot be selected; only the resolved connection rides the wire. model_ref=config.agent.model_ref, resolved_connection=config.resolved_connection, tool_specs=list(config.tool_specs), diff --git a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py index 93c7844f95..6f0f3f450d 100644 --- a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py +++ b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py @@ -64,7 +64,6 @@ def __init__( config: HarnessAgentTemplate, *, harness: HarnessKind, - secrets: Optional[Mapping[str, str]], trace: Optional[TraceContext], run_context: Optional[RunContext], session_id: Optional[str], @@ -73,7 +72,6 @@ def __init__( self._sandbox = sandbox self._config = config self._harness = harness - self._secrets = dict(secrets or {}) self._trace = trace self._run_context = run_context self._session_id = session_id @@ -89,7 +87,6 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: sandbox=self._sandbox.sandbox_id, config=self._config, messages=messages, - secrets=self._secrets, trace=self._trace, run_context=self._run_context, session_id=self._session_id, @@ -170,7 +167,6 @@ async def create_session( sandbox, config, harness=harness, - secrets=secrets, trace=trace, run_context=run_context, session_id=session_id, diff --git a/sdks/python/agenta/sdk/agents/capabilities.py b/sdks/python/agenta/sdk/agents/capabilities.py index 3694448d24..d8a7bcb269 100644 --- a/sdks/python/agenta/sdk/agents/capabilities.py +++ b/sdks/python/agenta/sdk/agents/capabilities.py @@ -14,8 +14,8 @@ - **Pi** reaches eight Agenta-vault-mapped providers directly (the ones whose ``provider_key`` secret drives a Pi provider via its env-key map), plus ``openai-codex`` (OpenAI's ChatGPT/Codex - subscription), which Pi reaches through its own OAuth login rather than a vault key — usable - under ``self_managed`` (and the ``agenta`` default's ``runtime_provided`` fallback). Pi also + subscription), which Pi reaches through its own OAuth login rather than a vault key, usable + under ``self_managed``. Pi also reaches ~24 more providers that have no Agenta vault kind; those are out of scope unless a ``custom_provider`` secret is made for them, so they are not enumerated here. Pi consumes the ``direct`` deployment for all of them, plus the ``custom`` (OpenAI-compatible) deployment for @@ -63,9 +63,8 @@ # ``/login``), NOT an Agenta vault ``provider_key`` (no vault secret kind maps to it). ``self_managed`` # is broader than this one provider: it covers any way a harness signs itself in without an # Agenta-stored key, including machine credentials such as environment variables. This provider's -# on-ramp under ``self_managed`` happens to be the subscription OAuth. It is also reachable under -# the ``agenta`` default's ``runtime_provided`` fallback, so it belongs in Pi's reachable providers -# even though it carries no vault key. Its model ids are carried explicitly below because they are +# on-ramp under ``self_managed`` happens to be the subscription OAuth. Its model ids are carried +# explicitly below because they are # not in the litellm-derived ``supported_llm_models`` catalog. See # ``docs/design/agent-workflows/projects/provider-model-auth/harness-provider-matrix.md`` and the # subscription-sidecar recipe. diff --git a/sdks/python/agenta/sdk/agents/connections/__init__.py b/sdks/python/agenta/sdk/agents/connections/__init__.py index 7254684804..fe89d186aa 100644 --- a/sdks/python/agenta/sdk/agents/connections/__init__.py +++ b/sdks/python/agenta/sdk/agents/connections/__init__.py @@ -12,6 +12,8 @@ ConnectionNotFoundError, ConnectionResolutionError, EndpointResolutionError, + InvalidConnectionConfigurationError, + MissingCredentialError, MissingProviderError, ProviderMismatchError, UnsupportedConnectionModeError, @@ -22,10 +24,13 @@ from .models import ( Connection, CredentialMode, + CredentialUsage, Deployment, Endpoint, + EnvironmentCredentialBinding, ModelRef, ResolvedConnection, + ResolvedCredential, RuntimeAuthContext, ) from .resolver import EnvConnectionResolver, StaticConnectionResolver @@ -34,10 +39,13 @@ # Contracts "Connection", "Endpoint", + "EnvironmentCredentialBinding", "ModelRef", "ResolvedConnection", + "ResolvedCredential", "RuntimeAuthContext", "CredentialMode", + "CredentialUsage", "Deployment", # Port + adapters "ConnectionResolver", @@ -47,7 +55,9 @@ "AgentConnectionError", "ConnectionResolutionError", "EndpointResolutionError", + "InvalidConnectionConfigurationError", "ConnectionNotFoundError", + "MissingCredentialError", "MissingProviderError", "AmbiguousConnectionError", "ProviderMismatchError", diff --git a/sdks/python/agenta/sdk/agents/connections/endpoints.py b/sdks/python/agenta/sdk/agents/connections/endpoints.py new file mode 100644 index 0000000000..62cfa2d8b0 --- /dev/null +++ b/sdks/python/agenta/sdk/agents/connections/endpoints.py @@ -0,0 +1,144 @@ +"""Effective model routes and credential-role classification.""" + +from __future__ import annotations + +from typing import Dict, Iterable, List, Optional, Tuple +from urllib.parse import urlparse + +from .errors import InvalidConnectionConfigurationError +from .models import Endpoint, ResolvedConnection, ResolvedCredential + +_DIRECT_ENDPOINTS: Dict[str, str] = { + "openai": "https://api.openai.com/v1", + "anthropic": "https://api.anthropic.com", + "gemini": "https://generativelanguage.googleapis.com", + "mistral": "https://api.mistral.ai/v1", + "mistralai": "https://api.mistral.ai/v1", + "minimax": "https://api.minimax.io/v1", + "groq": "https://api.groq.com/openai/v1", + "together_ai": "https://api.together.xyz/v1", + "openrouter": "https://openrouter.ai/api/v1", +} +_NON_SECRET_ENV = { + "AWS_REGION", + "AWS_DEFAULT_REGION", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_LOCATION", +} +_LOCAL_USE_ENV = { + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "GOOGLE_APPLICATION_CREDENTIALS", +} + + +def effective_endpoint( + *, + provider: str, + deployment: str, + endpoint: Optional[Endpoint], + environment: Dict[str, str], +) -> Endpoint: + """Return the exact HTTPS route used by this resolved provider deployment.""" + if endpoint and endpoint.base_url: + resolved = endpoint + elif deployment == "direct" or deployment.lower() == provider.lower(): + base_url = _DIRECT_ENDPOINTS.get(provider.lower()) + if not base_url: + raise ValueError( + f"no effective endpoint is registered for provider '{provider}'" + ) + resolved = Endpoint(base_url=base_url) + elif deployment == "bedrock": + region = environment.get("AWS_REGION") or environment.get("AWS_DEFAULT_REGION") + if not region: + raise ValueError("bedrock model connection requires an AWS region") + resolved = Endpoint( + base_url=f"https://bedrock-runtime.{region}.amazonaws.com", region=region + ) + elif deployment in {"vertex", "vertex_ai"}: + location = environment.get("GOOGLE_CLOUD_LOCATION") + if not location: + raise ValueError("vertex model connection requires GOOGLE_CLOUD_LOCATION") + resolved = Endpoint( + base_url=f"https://{location}-aiplatform.googleapis.com", region=location + ) + else: + raise ValueError(f"deployment '{deployment}' requires an explicit endpoint") + + parsed = urlparse(resolved.base_url or "") + if parsed.scheme.lower() != "https" or not parsed.hostname: + raise ValueError("model connection endpoint must be an absolute HTTPS URL") + return resolved + + +def classify_environment( + values: Iterable[Tuple[str, str]], +) -> Tuple[List[ResolvedCredential], Dict[str, str]]: + """Split provider environment into secret bindings and non-secret configuration.""" + credentials: List[ResolvedCredential] = [] + environment: Dict[str, str] = {} + for name, value in values: + if not name or not value: + raise ValueError( + "model connection bindings require non-empty names and values" + ) + if name in _NON_SECRET_ENV: + environment[name] = value + continue + usage = "local_use" if name in _LOCAL_USE_ENV else "opaque_http" + credentials.append( + ResolvedCredential( + binding={"kind": "environment", "name": name}, + value=value, + usage=usage, + ) + ) + return credentials, environment + + +def build_resolved_connection( + *, + provider: str, + model: str, + deployment: str = "direct", + credential_mode: str, + values: Dict[str, str], + endpoint: Optional[Endpoint] = None, + input_modalities: Optional[List[str]] = None, +) -> ResolvedConnection: + """Build a classified connection and attach the resolver-owned effective route.""" + if deployment in {"vertex", "vertex_ai"} and values.get("GOOGLE_CLOUD_API_KEY"): + raise InvalidConnectionConfigurationError( + "Vertex API-key authentication is not supported by the agent connection contract" + ) + credentials, environment = classify_environment(values.items()) + if credential_mode == "env" and not credentials: + raise InvalidConnectionConfigurationError( + "credential_mode 'env' requires at least one usable credential" + ) + try: + route = effective_endpoint( + provider=provider, + deployment=deployment, + endpoint=endpoint, + environment=environment, + ) + except ValueError as exc: + # A runtime-owned login with no resolved credential does not need a credential host. + # Once Agenta supplies any credential, an indeterminate route is unsafe and fails loud. + if any(credential.usage == "opaque_http" for credential in credentials): + raise InvalidConnectionConfigurationError(str(exc)) from exc + route = None + return ResolvedConnection( + provider=provider, + model=model, + deployment=deployment, + credential_mode=credential_mode, + credentials=credentials, + environment=environment, + endpoint=route, + input_modalities=input_modalities, + ) diff --git a/sdks/python/agenta/sdk/agents/connections/errors.py b/sdks/python/agenta/sdk/agents/connections/errors.py index eaac35bb4a..68802e69f5 100644 --- a/sdks/python/agenta/sdk/agents/connections/errors.py +++ b/sdks/python/agenta/sdk/agents/connections/errors.py @@ -37,6 +37,31 @@ class EndpointResolutionError(ConnectionResolutionError): status_code = 422 +class MissingCredentialError(ConnectionResolutionError): + """Raised when an Agenta-managed connection has no usable credential.""" + + # The invoke remap reads `status_code` off the exception; without it this fell through to 500. + status_code = 422 + + def __init__(self, *, provider: str, slug: Optional[str] = None) -> None: + subject = ( + f"connection '{slug}'" if slug else f"provider '{provider}' connection" + ) + super().__init__( + f"{subject} has no usable credential; configure a credential or select " + "self_managed authentication" + ) + self.provider = provider + self.slug = slug + + +class InvalidConnectionConfigurationError(AgentConnectionError): + """Raised when resolved routing and credentials form an unsafe combination.""" + + # The invoke remap reads `status_code` off the exception; without it this fell through to 500. + status_code = 422 + + class ConnectionNotFoundError(ConnectionResolutionError): """Raised when a named connection (``mode == agenta`` + ``slug``) does not exist.""" diff --git a/sdks/python/agenta/sdk/agents/connections/models.py b/sdks/python/agenta/sdk/agents/connections/models.py index d29cc2b821..338c0c9a50 100644 --- a/sdks/python/agenta/sdk/agents/connections/models.py +++ b/sdks/python/agenta/sdk/agents/connections/models.py @@ -1,9 +1,8 @@ """Neutral provider / model / connection contracts for the agent runtime. These models carry *intent* (which model, which provider, where its credential comes from) -and the *resolved* least-privilege output a harness adapter applies. They are deliberately -credential-shaped only at the edges: ``ResolvedConnection.env`` is the one secret-bearing -channel; everything else (``Endpoint``, ``Connection``, ``ModelRef``) names non-secret intent. +and the *resolved* least-privilege output a harness adapter applies. They are deliberately credential-shaped only at the edges: typed credentials live under +``ResolvedConnection``; everything else (``Endpoint``, ``Connection``, ``ModelRef``) names non-secret intent. The design is in ``docs/design/agent-workflows/projects/provider-model-auth/design.md`` (Concerns 1-3). This @@ -17,6 +16,7 @@ from __future__ import annotations from typing import Any, Dict, List, Literal, Optional +from urllib.parse import urlparse from uuid import UUID from pydantic import BaseModel, Field, field_serializer, model_validator @@ -32,6 +32,7 @@ # provider's vars; ``runtime_provided`` injects nothing (the harness owns auth, e.g. an OAuth # login or a self-managed sidecar); ``none`` injects nothing and asserts no credential. CredentialMode = Literal["env", "runtime_provided", "none"] +CredentialUsage = Literal["opaque_http", "local_use"] # Which deployment surface a provider is reached through. ``direct`` is the provider's own # API; custom-provider deployments preserve the vault ``data.kind`` value (for example @@ -77,8 +78,8 @@ class Endpoint(BaseModel): """NON-secret connection config a harness applies alongside its credential. This carries only public, non-secret fields: a custom base URL, an API version, a region, - and public headers. Secret-bearing values (the api key, secret auth headers) never live - here; they ride ``ResolvedConnection.env``, the one secret channel. + and public headers. Secret-bearing values never live here; they ride typed credential bindings under the + resolved connection. """ base_url: Optional[str] = None @@ -105,6 +106,52 @@ def to_wire(self) -> Dict[str, Any]: return wire +class EnvironmentCredentialBinding(BaseModel): + """Bind one resolved credential to the harness environment protocol.""" + + kind: Literal["environment"] = "environment" + name: str + + @model_validator(mode="after") + def _require_name(self) -> "EnvironmentCredentialBinding": + if not self.name.strip(): + raise ValueError("credential environment binding requires a non-empty name") + return self + + def to_wire(self) -> Dict[str, str]: + return {"kind": self.kind, "name": self.name} + + +class ResolvedCredential(BaseModel): + """One secret value, its protocol binding, and how the consumer uses it.""" + + binding: EnvironmentCredentialBinding + value: str = Field(repr=False) + usage: CredentialUsage + + @model_validator(mode="after") + def _require_value(self) -> "ResolvedCredential": + if not self.value: + raise ValueError("resolved credential requires a non-empty value") + return self + + @field_serializer("value", when_used="always") + def _mask_value(self, value: str) -> str: + """Structural guard (F-SDK-DUMP): a dump can never carry the credential value. + + Attribute access stays plain for the legitimate consumers (:meth:`to_wire` and + ``ResolvedConnection.plaintext_environment``), which read ``self.value`` directly. + """ + return "**********" + + def to_wire(self) -> Dict[str, Any]: + return { + "binding": self.binding.to_wire(), + "value": self.value, + "usage": self.usage, + } + + class ModelRef(BaseModel): """Model intent plus the credential connection, carried in the agent config. @@ -160,47 +207,64 @@ def to_model_string(self) -> str: class ResolvedConnection(BaseModel): - """The least-privilege output a :class:`ConnectionResolver` returns for one run. + """Resolved route and credentials for one model consumer. - ``env`` is the ONLY channel that carries secret values: one provider's vars (the api key - and any secret-bearing extras). ``endpoint`` carries only non-secret connection config. - The harness adapter applies ``env`` + ``endpoint`` + ``model`` and never sees a vault, a - connection, or a slug. + Credentials stay nested under the connection they authenticate. ``environment`` carries + non-secret provider configuration such as regions and project ids, rather than disguising + those values as credentials. Credential values are secret-bearing and must not be logged. - Serialization safety: ``env`` is masked from ``repr``/``str`` AND from - ``model_dump()``/``model_dump_json()`` by construction — the values never leave this model. - Read ``env`` directly (attribute access is unmasked) to hand the credential to a harness; - :meth:`to_wire` never emits ``env`` at all. + Serialization safety: each credential ``value`` is masked from ``repr``/``str`` AND from + ``model_dump()``/``model_dump_json()`` by construction — the values never leave this model + on a dump. Attribute access (unmasked) hands the credential to a harness via + :meth:`to_wire` / :meth:`plaintext_environment`. """ provider: str - model: str # possibly rewritten for the deployment (e.g. a bedrock id) + model: str deployment: Deployment = "direct" credential_mode: CredentialMode - env: Dict[str, str] = Field( - default_factory=dict, repr=False - ) # the ONLY secret channel + credentials: List[ResolvedCredential] = Field(default_factory=list, repr=False) + environment: Dict[str, str] = Field(default_factory=dict) endpoint: Optional[Endpoint] = None # NON-secret connection config only input_modalities: Optional[List[str]] = None - @field_serializer("env", when_used="always") - def _mask_env(self, env: Dict[str, str]) -> Dict[str, str]: - """Structural guard: a dump can never carry the credential (keys survive, values do not).""" - return {key: "**********" for key in env} + @model_validator(mode="after") + def _validate_credential_route(self) -> "ResolvedConnection": + names = [item.binding.name for item in self.credentials] + if len(names) != len(set(names)) or any( + name in self.environment for name in names + ): + raise ValueError("model connection environment bindings must be unique") + if self.credential_mode == "env" and not self.credentials: + raise ValueError("credential_mode 'env' requires at least one credential") + if self.credential_mode != "env" and self.credentials: + raise ValueError("resolved credentials require credential_mode 'env'") + if any(item.usage == "opaque_http" for item in self.credentials): + base_url = self.endpoint.base_url if self.endpoint else None + parsed = urlparse(base_url or "") + if parsed.scheme.lower() != "https" or not parsed.hostname: + raise ValueError( + "opaque_http model credentials require an effective HTTPS endpoint" + ) + return self + + def plaintext_environment(self) -> Dict[str, str]: + """Materialize the validated contract at a local execution boundary.""" + values = dict(self.environment) + for credential in self.credentials: + values[credential.binding.name] = credential.value + return values def to_wire(self) -> Dict[str, Any]: - """The NON-secret camelCase fields for the wire. Never emits ``env``. - - ``env`` is the secret channel and rides the existing ``secrets`` wire field during the - transition (Slice 1); only the non-secret descriptor is serialized here so a trace or - an echoed payload never carries credentials. - """ + """Serialize the consumer-owned model connection onto the trusted internal wire.""" wire: Dict[str, Any] = { "provider": self.provider, - "model": self.model, "deployment": self.deployment, "credentialMode": self.credential_mode, + "credentials": [item.to_wire() for item in self.credentials], } + if self.environment: + wire["environment"] = dict(self.environment) if self.endpoint is not None: endpoint_wire = self.endpoint.to_wire() if endpoint_wire: diff --git a/sdks/python/agenta/sdk/agents/connections/resolver.py b/sdks/python/agenta/sdk/agents/connections/resolver.py index 9c519b9ce3..05206c88f7 100644 --- a/sdks/python/agenta/sdk/agents/connections/resolver.py +++ b/sdks/python/agenta/sdk/agents/connections/resolver.py @@ -19,7 +19,8 @@ from ..capabilities import PROVIDER_ENV_VARS from ..model_catalog import model_input_modalities -from .errors import UnsupportedProviderError +from .endpoints import build_resolved_connection +from .errors import MissingCredentialError, UnsupportedProviderError from .models import ( Endpoint, ModelRef, @@ -46,8 +47,8 @@ class EnvConnectionResolver: - ``agenta`` (the default mode, with or without a slug) -> infer the provider (from ``ModelRef.provider``, else error), look up its env var, and: - present -> ``credential_mode = env`` carrying exactly that one var; - - absent -> ``credential_mode = runtime_provided`` with empty ``env`` (absence is - valid; the harness falls back to its own login, matching today's semantics). + - absent -> fail closed. Only an explicit ``self_managed`` connection may let the + harness own authentication. The model passes through unchanged. Offline, no vault, no network. """ @@ -64,11 +65,11 @@ async def resolve( ) -> ResolvedConnection: if model.connection.mode == "self_managed": provider = model.provider or "" - return ResolvedConnection( + return build_resolved_connection( provider=provider, model=model.model, credential_mode="runtime_provided", - env={}, + values={}, input_modalities=_input_modalities( context, provider=provider, model=model.model ), @@ -84,24 +85,18 @@ async def resolve( env_var = _PROVIDER_ENV_VARS.get(provider.lower()) key = self._env.get(env_var) if env_var else None if env_var and key: - return ResolvedConnection( + return build_resolved_connection( provider=provider, model=model.model, credential_mode="env", - env={env_var: key}, + values={env_var: key}, input_modalities=_input_modalities( context, provider=provider, model=model.model ), ) - # Absence is valid: inject nothing and let the harness use its own login/OAuth. - return ResolvedConnection( + raise MissingCredentialError( provider=provider, - model=model.model, - credential_mode="runtime_provided", - env={}, - input_modalities=_input_modalities( - context, provider=provider, model=model.model - ), + slug=model.connection.slug, ) @@ -146,18 +141,34 @@ async def resolve( context: RuntimeAuthContext, ) -> ResolvedConnection: provider = self._provider or model.provider or "" + if model.connection.mode == "self_managed": + return build_resolved_connection( + provider=provider, + model=model.model, + deployment=self._deployment, + credential_mode="runtime_provided", + values={}, + input_modalities=_input_modalities( + context, provider=provider, model=model.model + ), + ) env: Dict[str, str] = {} if self._api_key: env_var = self._env_var or _PROVIDER_ENV_VARS.get(provider.lower()) if env_var: env[env_var] = self._api_key endpoint = Endpoint(base_url=self._base_url) if self._base_url else None - return ResolvedConnection( + if not env: + raise MissingCredentialError( + provider=provider, + slug=model.connection.slug, + ) + return build_resolved_connection( provider=provider, model=model.model, - deployment=self._deployment, # type: ignore[arg-type] - credential_mode="env" if env else "runtime_provided", - env=env, + deployment=self._deployment, + credential_mode="env", + values=env, endpoint=endpoint, input_modalities=_input_modalities( context, provider=provider, model=model.model diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index cb5859d79f..b4bba70382 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -708,17 +708,12 @@ class HarnessAgentTemplate(BaseModel): harness: ClassVar[HarnessKind] agents_md: Optional[str] = None - # ``model`` stays the back-compat plain string the adapter hands to the harness. - # ``model_ref`` carries the structured ref when one is supplied; it is populated only from - # structured input (a dict / a ``ModelRef``), so a plain-string ``model`` leaves it - # ``None`` and the wire is unchanged. See :meth:`wire_model_ref`. + # ``model`` stays the plain string handed to the harness. ``model_ref`` carries author + # intent for resolution; only the resolved connection crosses the runner boundary. model: Optional[str] = None model_ref: Optional[ModelRef] = None - # ``resolved_connection`` carries the least-privilege output of a ``ConnectionResolver`` - # (threaded down from ``SessionConfig``). It is the authoritative source of the non-secret - # provider/model descriptor on the wire when present; unset leaves the wire unchanged (the - # golden contract). Its ``env`` is the secret channel and never reaches the wire here (it - # rides ``secrets``). See :meth:`wire_resolved_connection`. + # ``resolved_connection`` carries the route and typed credential bindings produced by the + # resolver. It serializes as one consumer-owned ``modelConnection`` object. resolved_connection: Optional[ResolvedConnection] = None tool_callback: Optional[ToolCallback] = None mcp_servers: List[ResolvedMCPServer] = Field(default_factory=list) @@ -809,48 +804,22 @@ def wire_harness_files(self) -> Dict[str, Any]: no harness knowledge.""" return {} - def wire_model_ref(self) -> Dict[str, Any]: - """The non-secret provider/connection fields for the ``/run`` payload. + def wire_model_connection(self) -> Dict[str, Any]: + """The resolved model route and credentials, grouped under their consumer. - Empty when ``model_ref`` is unset, so a string-only config's payload is byte-identical - to before (the golden wire contract). When a structured ref is present this emits only - the fields known at config-build time: ``provider`` (when set) and ``connection`` (when - it carries non-default info). ``deployment`` / ``endpoint`` / ``credentialMode`` come - from a :class:`ResolvedConnection`, which Slice 1 does not yet thread, so they are not - emitted here. The plain ``model`` string still rides the wire separately for back-compat. + ``modelCapabilities`` (resolved input modalities) is hoisted to the top level of the + request: the runner's attachment-delivery chain reads ``request.modelCapabilities``, + not the connection object. """ - if self.model_ref is None: - return {} - out: Dict[str, Any] = {} - if self.model_ref.provider: - out["provider"] = self.model_ref.provider - connection = self.model_ref.connection - # Two modes only: the project default is ``agenta`` with no slug and carries no info - # beyond the model, so it is omitted (byte-identical wire). Emit the connection only when - # it is ``self_managed`` or names a slug. - is_default = connection.mode == "agenta" and connection.slug is None - if not is_default: - wire_connection: Dict[str, Any] = {"mode": connection.mode} - if connection.slug is not None: - wire_connection["slug"] = connection.slug - out["connection"] = wire_connection - return out - - def wire_resolved_connection(self) -> Dict[str, Any]: - """The non-secret resolved-connection descriptor for the ``/run`` payload. - - Empty when ``resolved_connection`` is unset, so a config without a resolved connection - is byte-identical to before (the golden wire contract). When a resolved connection is - present this is the AUTHORITATIVE source of the provider/model descriptor: it emits - ``provider``, ``model`` (the resolved exact model), ``deployment``, ``credentialMode``, - and ``endpoint`` (via :meth:`ResolvedConnection.to_wire`, which NEVER emits ``env``). It - is spread AFTER the base ``model`` and after :meth:`wire_model_ref` in - ``request_to_wire``, so the resolved ``provider``/``model`` win over the config-build - values while ``connection`` (the author's ``{mode, slug}`` intent) is preserved. The - secret ``env`` rides the existing ``secrets`` wire field, never here.""" if self.resolved_connection is None: return {} - return self.resolved_connection.to_wire() + connection_wire = self.resolved_connection.to_wire() + out: Dict[str, Any] = {"model": self.resolved_connection.model} + capabilities = connection_wire.pop("modelCapabilities", None) + if capabilities is not None: + out["modelCapabilities"] = capabilities + out["modelConnection"] = connection_wire + return out class PiAgentTemplate(HarnessAgentTemplate): @@ -869,6 +838,23 @@ class PiAgentTemplate(HarnessAgentTemplate): harness: ClassVar[HarnessKind] = HarnessKind.PI + def wire_model_connection(self) -> Dict[str, Any]: + """Keep Pi's selector provider-qualified so equal model suffixes cannot misroute. + + ``modelConnection.provider`` describes credential ownership, but Pi routes on the + top-level model string. Agenta inherits this behavior; Claude keeps its bare aliases. + """ + wire = super().wire_model_connection() + if not wire or self.resolved_connection is None: + return wire + provider = self.resolved_connection.provider + model = self.resolved_connection.model + prefix = f"{provider}/" if provider else "" + wire["model"] = ( + model if not prefix or model.startswith(prefix) else f"{prefix}{model}" + ) + return wire + tool_specs: List[ToolSpec] = Field( default_factory=list, validation_alias=AliasChoices("tool_specs", "custom_tools"), @@ -978,8 +964,8 @@ class AgentaAgentTemplate(PiAgentTemplate): class SessionConfig(BaseModel): """Everything one run needs except where it runs. - ``agent`` is the agent definition. ``secrets`` are provider keys injected as harness - env, never written to the agent filesystem. The ``custom_tools`` / ``tool_callback`` pair + ``agent`` is the agent definition. Model routing and credentials are carried by + ``resolved_connection``. The ``custom_tools`` / ``tool_callback`` pair is the resolved tool delivery (Agenta produces it server-side; empty for a bare standalone run); built-in tools are not part of it, the runner activates them. The agent config's ``sandbox`` field is a @@ -989,10 +975,7 @@ class SessionConfig(BaseModel): model_config = ConfigDict(populate_by_name=True) agent: AgentTemplate - secrets: Dict[str, str] = Field(default_factory=dict, repr=False) - # ``resolved_connection`` carries the least-privilege output of a ``ConnectionResolver``. - # ``secrets`` is the compatibility alias for ``resolved_connection.env`` during the - # transition: Slice 1 still ships the credential through ``secrets`` on the wire. + # ``resolved_connection`` is the single source of model routing and credentials. resolved_connection: Optional[ResolvedConnection] = None permission_default: PermissionMode = "allow_reads" trace: Optional[TraceContext] = None diff --git a/sdks/python/agenta/sdk/agents/handler.py b/sdks/python/agenta/sdk/agents/handler.py index 044bb77e4d..40a7993c81 100644 --- a/sdks/python/agenta/sdk/agents/handler.py +++ b/sdks/python/agenta/sdk/agents/handler.py @@ -2,7 +2,7 @@ Owns stream/trim/force; composition (template, tool/MCP/connection resolvers, backend selector) is injectable via `AgentComposition`, defaulting to env-driven SDK behavior. The -default composition also owns capability gating and degradation policy: +default composition also owns fail-closed capability/connection gating: these are protocol-level safety behaviors, not service-specific, so a bare `agent_v0` (no composition override) gets them for free instead of a permissive fallback. """ @@ -22,8 +22,6 @@ harness_allows_provider, ) from agenta.sdk.agents.connections import ( - ConnectionResolutionError, - MissingProviderError, ModelRef, ResolvedConnection, RuntimeAuthContext, @@ -51,6 +49,9 @@ from agenta.sdk.agents.dtos import RunContext, RunContextRun from agenta.sdk.engines.running.errors import ForceNotSupportedV0Error +from agenta.sdk.redaction.context import get_active_redactor, redaction_context +from agenta.sdk.redaction.redactor import Redactor +from agenta.sdk.redaction.seed import seed_from_request from agenta.sdk.models.workflows import ( WorkflowInvokeRequestFlags, WorkflowServiceRequest, @@ -160,50 +161,14 @@ async def _default_resolve_session_connection( *, resolve_connection: ResolveConnectionFn = _default_resolve_connection, ) -> ResolvedConnection: - """Resolve one least-privilege connection for the run, with graceful degradation. + """Resolve and capability-check one least-privilege connection for the run. - Provider + mode are rejected BEFORE the vault resolve (known from the config), the resolved - deployment is rejected AFTER (only known once the vault picks the secret). - - An EXPLICIT named ``agenta`` connection (``slug`` set) fails loud on a resolution failure: the - user named a connection, so a missing/ambiguous one is a real error they must fix. - - A project-default connection (``agenta`` with no slug) or a ``self_managed`` connection is - TOLERANT of a resolution failure: most projects have no configured connection for the default - model and rely on the harness's own login / a self-managed sidecar, so a failed resolve - (including a network/HTTP error) degrades to an empty ``runtime_provided`` plan and the run - still works. A capability reject is NEVER tolerated — it is a misconfiguration the user must - fix, not a missing credential. + Resolution failures are fail-closed. A caller that wants harness-owned authentication must + select ``self_managed`` explicitly; a vault outage, missing key, invalid route, or ambiguous + connection must never become an implicit runtime-provided fallback. """ _check_harness_pre_resolve(model_ref, context.harness) - - connection = model_ref.connection - is_named = connection.mode == "agenta" and bool( - connection.slug and connection.slug.strip() - ) - if is_named: - resolved = await resolve_connection(model=model_ref, context=context) - _check_harness_post_resolve(resolved, context.harness) - return resolved - try: - resolved = await resolve_connection(model=model_ref, context=context) - except MissingProviderError: - # A bare model id with no provider is an underspecified config, not a missing - # credential, so it fails loud even on a default connection. - raise - except ConnectionResolutionError: - log.warning( - "agent: no connection resolved for provider %r (mode=%s); " - "running with no injected credential (harness login / self-managed)", - model_ref.provider, - connection.mode, - ) - return ResolvedConnection( - provider=model_ref.provider or "", - model=model_ref.model, - credential_mode="runtime_provided", - env={}, - ) + resolved = await resolve_connection(model=model_ref, context=context) _check_harness_post_resolve(resolved, context.harness) return resolved @@ -218,15 +183,15 @@ class AgentComposition: ``None``/no-op when a run has no such state, so a bare ``agent_v0`` behaves correctly in any process without composition. - ``resolve_session_connection`` defaults to the SAFE behavior (capability-gated + degrading - connection resolve) rather than a bare fallback, so a composition-free ``agent_v0`` is not - the permissive copy.""" + ``resolve_session_connection`` defaults to the SAFE behavior (capability-gated + + fail-closed connection resolve) rather than a bare fallback, so a composition-free + ``agent_v0`` is not the permissive copy.""" default_template: DefaultTemplateFn = field(default=_default_template) resolve_tools: ResolveToolsFn = field(default=_default_resolve_tools) resolve_mcp_servers: ResolveMCPFn = field(default=_default_resolve_mcp_servers) resolve_connection: ResolveConnectionFn = field(default=_default_resolve_connection) - # capability gating + degradation policy; override to replace, not just add to. + # capability gating + fail-closed resolution policy; override to replace, not just add to. resolve_session_connection: Optional[ResolveSessionConnectionFn] = field( default=None ) @@ -279,12 +244,11 @@ async def _agent( model_ref = _agent_model_ref(agent_template) resolved_connection: Optional[ResolvedConnection] = None - secrets: Dict[str, str] = {} if model_ref is not None: ctx = RuntimeAuthContext( harness=agent_template.harness, backend=agent_template.sandbox ) - # Default is the gated+degrading resolve, bound to comp.resolve_connection + # Default is the gated+fail-closed resolve, bound to comp.resolve_connection # so an override of the plain resolver still flows through the capability check. resolve_session_connection = comp.resolve_session_connection or ( lambda m, c: _default_resolve_session_connection( @@ -292,7 +256,29 @@ async def _agent( ) ) resolved_connection = await resolve_session_connection(model_ref, ctx) - secrets = resolved_connection.env + + # Seed a FRESH per-run redactor immediately after trusted resolution and before + # transport, trace, event, error, or result sinks can observe an echoed credential. + # The redactor is installed into the ambient context for exactly this run's scope — + # the batch call below, or the returned stream's lifetime — and the prior redactor is + # restored on exit (`redaction_context`), so sequential runs sharing one task/context + # never accumulate each other's deny-set values and a finished run's secrets do not + # linger in the ambient contextvar. + redactor = seed_from_request( + extra_values=[ + *( + credential.value + for credential in ( + resolved_connection.credentials if resolved_connection else [] + ) + ), + *( + credential.value + for server in resolved_mcp + for credential in server.credentials + ), + ] + ) # run_kind rides the wire on `request.meta`: a wire-supplied run_kind must not # be silently dropped, so it layers onto whatever run_context composition supplies. @@ -305,7 +291,6 @@ async def _agent( session_config = SessionConfig( agent=agent_template, - secrets=secrets, resolved_connection=resolved_connection, permission_default=agent_template.permission_default, trace=comp.trace_context(), @@ -317,20 +302,38 @@ async def _agent( ) if stream: - return agent_event_stream( - harness, session_config, msgs, record_usage=comp.record_usage + return _stream_in_redaction_scope( + redactor, + agent_event_stream( + harness, session_config, msgs, record_usage=comp.record_usage + ), + ) + with redaction_context(redactor): + return await agent_batch( + harness, + session_config, + msgs, + trim=flags.trim, + record_usage=comp.record_usage, ) - return await agent_batch( - harness, - session_config, - msgs, - trim=flags.trim, - record_usage=comp.record_usage, - ) return _agent +async def _stream_in_redaction_scope(redactor: Redactor, events): + """Iterate `events` with `redactor` installed as the ambient per-run redactor. + + An async generator frame shares its caller's context (PEP 567 gives generators no context + of their own), so the install on first `__anext__` is visible to every sink that runs while + a chunk is produced, and the `finally` inside `redaction_context` restores the caller's + redactor once the stream is exhausted or closed — the same per-run boundary the batch path + gets from wrapping `agent_batch`. + """ + with redaction_context(redactor): + async for event in events: + yield event + + async def agent_event_stream( harness, session_config, msgs, *, record_usage: RecordUsageFn = ambient_record_usage ): @@ -342,7 +345,9 @@ async def agent_event_stream( async for event in run: if event.type == "done": event_stop_reason = (event.data or {}).get("stopReason") - yield {"type": event.type, "data": event.data} + yield get_active_redactor().redact_json( + {"type": event.type, "data": event.data}, sink="agent_event" + ) # The terminal result's stop_reason is authoritative: the runner's `done` event carries # no stopReason for a HITL pause (the engine settles paused-vs-ended after the event # stream closes, onto the terminal result only). When it disagrees with the streamed @@ -379,7 +384,11 @@ async def agent_batch( try: run = await harness.stream(session_config, msgs) async for event in run: - events.append({"type": event.type, "data": event.data}) + events.append( + get_active_redactor().redact_json( + {"type": event.type, "data": event.data}, sink="agent_event" + ) + ) result = run.result() finally: await harness.cleanup() diff --git a/sdks/python/agenta/sdk/agents/interfaces.py b/sdks/python/agenta/sdk/agents/interfaces.py index dce5efa88a..804ed1ed6d 100644 --- a/sdks/python/agenta/sdk/agents/interfaces.py +++ b/sdks/python/agenta/sdk/agents/interfaces.py @@ -190,7 +190,11 @@ async def create_session( sandbox, config, harness=harness, - secrets=session_config.secrets, + secrets=( + session_config.resolved_connection.plaintext_environment() + if session_config.resolved_connection + else {} + ), trace=session_config.trace, run_context=session_config.run_context, session_id=session_config.session_id, diff --git a/sdks/python/agenta/sdk/agents/mcp/__init__.py b/sdks/python/agenta/sdk/agents/mcp/__init__.py index c6c8c5e6b8..d46e032c7e 100644 --- a/sdks/python/agenta/sdk/agents/mcp/__init__.py +++ b/sdks/python/agenta/sdk/agents/mcp/__init__.py @@ -8,12 +8,14 @@ ) from .interfaces import MCPSecretProvider from .models import ( + HeaderCredentialBinding, MCPConnection, MCPHeaderSecretRefs, MCPPolicy, MCPServerConfig, MCPToolPolicy, NoMCPCredentials, + ResolvedMCPCredential, ResolvedMCPServer, ) from .parsing import parse_mcp_server_config, parse_mcp_server_configs @@ -27,6 +29,8 @@ "MCPPolicy", "MCPToolPolicy", "NoMCPCredentials", + "HeaderCredentialBinding", + "ResolvedMCPCredential", "ResolvedMCPServer", "MCPSecretProvider", "MCPResolver", diff --git a/sdks/python/agenta/sdk/agents/mcp/models.py b/sdks/python/agenta/sdk/agents/mcp/models.py index 159abc8752..efd8e93207 100644 --- a/sdks/python/agenta/sdk/agents/mcp/models.py +++ b/sdks/python/agenta/sdk/agents/mcp/models.py @@ -4,7 +4,14 @@ from typing import Annotated, Any, Dict, List, Literal, Optional, Union -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_serializer, + field_validator, + model_validator, +) Permission = Literal["allow", "ask", "deny"] @@ -39,6 +46,22 @@ class MCPConnection(BaseModel): headers: Dict[str, str] = Field(default_factory=dict) credentials: MCPCredentials = Field(default_factory=NoMCPCredentials) + @model_validator(mode="after") + def _validate_header_roles(self) -> "MCPConnection": + secret_refs = ( + self.credentials.headers + if isinstance(self.credentials, MCPHeaderSecretRefs) + else {} + ) + for name, value in {**self.headers, **secret_refs}.items(): + if not name.strip() or not value: + raise ValueError("MCP bindings require non-empty names and values") + if {name.lower() for name in self.headers} & { + name.lower() for name in secret_refs + }: + raise ValueError("HTTP MCP public and credential headers must be unique") + return self + class MCPToolPolicy(BaseModel): model_config = ConfigDict(extra="forbid") @@ -79,16 +102,61 @@ def _reject_reserved_name(cls, value: str) -> str: return value +class HeaderCredentialBinding(BaseModel): + kind: Literal["header"] = "header" + name: str = Field(min_length=1) + + +class ResolvedMCPCredential(BaseModel): + binding: HeaderCredentialBinding + value: str = Field(min_length=1, repr=False) + usage: Literal["opaque_http"] = "opaque_http" + + @field_serializer("value", when_used="always") + def _mask_value(self, value: str) -> str: + """Structural guard (F-SDK-DUMP): a dump can never carry the credential value. + + Attribute access stays plain for the legitimate consumer (:meth:`to_wire`), which + reads ``self.value`` directly. Mirrors ``ResolvedCredential`` in + ``connections/models.py``. + """ + return "**********" + + def to_wire(self) -> Dict[str, Any]: + return { + "binding": self.binding.model_dump(), + "value": self.value, + "usage": self.usage, + } + + class ResolvedMCPServer(BaseModel): - """Per-run delivery config. Headers may contain resolved secret values.""" + """Per-run delivery config. + + Public HTTP headers and secret header credentials stay separate by protocol role: + ``headers`` carries only public values; each resolved secret rides ``credentials`` as a + typed header binding whose value must never be logged. + """ model_config = ConfigDict(extra="forbid", frozen=True) name: str url: str - headers: Dict[str, str] = Field(default_factory=dict, repr=False) + headers: Dict[str, str] = Field(default_factory=dict) # public headers only + credentials: List[ResolvedMCPCredential] = Field(default_factory=list, repr=False) policy: MCPPolicy = Field(default_factory=MCPPolicy) + @model_validator(mode="after") + def _validate_header_roles(self) -> "ResolvedMCPServer": + for name, value in self.headers.items(): + if not name.strip() or not value: + raise ValueError("MCP bindings require non-empty names and values") + names = [credential.binding.name.lower() for credential in self.credentials] + public_names = {name.lower() for name in self.headers} + if len(names) != len(set(names)) or any(name in public_names for name in names): + raise ValueError("http MCP header bindings must be unique") + return self + def to_wire(self) -> Dict[str, Any]: connection: Dict[str, Any] = { "type": "http", @@ -96,6 +164,8 @@ def to_wire(self) -> Dict[str, Any]: } if self.headers: connection["headers"] = dict(self.headers) + if self.credentials: + connection["credentials"] = [item.to_wire() for item in self.credentials] wire: Dict[str, Any] = { "name": self.name, diff --git a/sdks/python/agenta/sdk/agents/mcp/resolver.py b/sdks/python/agenta/sdk/agents/mcp/resolver.py index 57c2a67fb1..5058a12d25 100644 --- a/sdks/python/agenta/sdk/agents/mcp/resolver.py +++ b/sdks/python/agenta/sdk/agents/mcp/resolver.py @@ -46,10 +46,11 @@ async def resolve( if isinstance(credentials, MCPHeaderSecretRefs) else {} ) + # An empty resolved value is as unusable as an absent one, so both are missing. missing = [ secret_name for secret_name in secret_refs.values() - if secret_name not in secret_values + if not secret_values.get(secret_name) ] if missing and self._missing_secret_policy == MissingSecretPolicy.ERROR: raise MissingMCPSecretError( @@ -57,11 +58,6 @@ async def resolve( secret_names=missing, ) - headers = dict(server_config.connection.headers) - for header_name, secret_name in secret_refs.items(): - if secret_name in secret_values: - headers[header_name] = secret_values[secret_name] - if server_config.connection.url: try: assert_endpoint_url_allowed(server_config.connection.url) @@ -71,11 +67,24 @@ async def resolve( url=server_config.connection.url, ) from exc + # Public headers and secret header credentials stay separate by protocol role: + # each resolved secret becomes a typed header binding, never a merged header. + credentials = [ + { + "binding": {"kind": "header", "name": header_name}, + "value": secret_values[secret_name], + "usage": "opaque_http", + } + for header_name, secret_name in secret_refs.items() + if secret_values.get(secret_name) + ] + resolved.append( ResolvedMCPServer( name=server_config.name, url=server_config.connection.url, - headers=headers, + headers=dict(server_config.connection.headers), + credentials=credentials, policy=server_config.policy, ) ) diff --git a/sdks/python/agenta/sdk/agents/platform/connections.py b/sdks/python/agenta/sdk/agents/platform/connections.py index b7a95bc7e3..bdf16d50e9 100644 --- a/sdks/python/agenta/sdk/agents/platform/connections.py +++ b/sdks/python/agenta/sdk/agents/platform/connections.py @@ -24,12 +24,14 @@ HARNESS_CONNECTION_CAPABILITIES, PROVIDER_ENV_VARS, ) +from ..connections.endpoints import build_resolved_connection from ..connections import ( AmbiguousConnectionError, ConnectionNotFoundError, ConnectionResolutionError, EndpointResolutionError, Endpoint, + MissingCredentialError, MissingProviderError, ModelRef, ProviderMismatchError, @@ -500,11 +502,11 @@ def _resolve_from_secrets( model = model.model_copy(update={"provider": inferred}) if connection.mode == "self_managed": provider = model.provider or "" - return ResolvedConnection( + return build_resolved_connection( provider=provider, model=model.model, credential_mode="runtime_provided", - env={}, + values={}, # A miss means workspace-only downstream; do not guess. input_modalities=model_input_modalities( harness, model.model, provider=provider or None @@ -531,12 +533,14 @@ def _resolve_from_secrets( raise chosen.endpoint_resolution_error() env = chosen.resolved_env(provider) resolved_model = chosen.selected_model_id(model) - return ResolvedConnection( + if not env: + raise MissingCredentialError(provider=provider, slug=chosen.slug) + return build_resolved_connection( provider=provider, model=resolved_model, deployment=chosen.deployment, - credential_mode="env" if env else "runtime_provided", - env=env, + credential_mode="env", + values=env, endpoint=chosen.endpoint, # A miss means workspace-only downstream; do not guess. input_modalities=model_input_modalities( diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index ec89819547..d6e0cc3752 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -86,7 +86,6 @@ def request_to_wire( sandbox: str, config: HarnessAgentTemplate, messages: Sequence[Message], - secrets: Optional[Dict[str, str]] = None, trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, @@ -104,15 +103,9 @@ def request_to_wire( packages, likewise omitted when there are none (skills ride their own seam, not the tool wire). ``config.wire_sandbox_permission()`` adds the declared sandbox security boundary, omitted when unset (plumbing only; the runner does not enforce it yet). - ``config.wire_model_ref()`` adds the non-secret provider/connection fields, omitted when no - structured ``model_ref`` is set so a string-only config's payload is unchanged (the secret - still rides ``secrets``; ``model`` stays the plain string). - ``config.wire_resolved_connection()`` adds the resolved-connection descriptor - (``provider`` / ``model`` / ``deployment`` / ``credentialMode`` / ``endpoint``), omitted when - no ``resolved_connection`` is threaded so a config without one is unchanged. It is spread - LAST among the model fields so the resolved ``provider``/``model`` override the base ``model`` - and ``wire_model_ref``'s ``provider`` (its ``env`` never reaches the wire; the secret rides - ``secrets``). + ``config.wire_model_connection()`` adds the resolved model route and typed credentials as one + consumer-owned object. It is omitted when no connection was resolved and overrides the base + model id with the exact resolved model. ``config.wire_harness_files()`` adds the generic ``harnessFiles`` array: files the active harness's config rendered from its own ``permissions`` / ``extras`` slice, to materialize in the session cwd before the session starts (``path`` relative to cwd, ``content`` the file text). Omitted @@ -132,7 +125,6 @@ def request_to_wire( "agentsMd": config.agents_md, "model": config.model, "messages": [message.to_wire() for message in messages], - "secrets": dict(secrets or {}), # The run's tracing inputs ride the wire grouped by role (see the trace/telemetry interface # restructure): `context.propagation` carries the per-call W3C trace-context headers, and # `telemetry` carries the operator-owned exporter config + capture policy. Both come from the @@ -145,8 +137,7 @@ def request_to_wire( **config.wire_mcp(), **config.wire_skills(), **config.wire_sandbox_permission(), - **config.wire_model_ref(), - **config.wire_resolved_connection(), + **config.wire_model_connection(), **config.wire_harness_files(), } if run_context is not None: @@ -167,6 +158,7 @@ def result_from_wire(data: Dict[str, Any]) -> AgentResult: stable code and a clear message rather than an empty reply. The runner ``error`` is sanitized at this boundary (one clean line, no stack/path leak); the full detail is logged. """ + data = get_active_redactor().redact_json(data, sink="runner_result") if not data.get("ok"): raise AgentRunFailed(sanitize_runner_error(data.get("error"))) diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index fc22f188c5..0621497a44 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -76,11 +76,32 @@ class WireEndpoint(_WireModel): headers: Optional[Dict[str, str]] = None -class WireConnection(_WireModel): - """The author's credential-connection intent (``{mode, slug?}``).""" +class WireCredentialBinding(_WireModel): + """Protocol location where the model client consumes one credential.""" + + kind: Literal["environment"] + name: str + + +class WireCredential(_WireModel): + """One model credential, its binding, and its consumer usage contract.""" + + binding: WireCredentialBinding + value: str + usage: Literal["opaque_http", "local_use"] - mode: Literal["agenta", "self_managed"] = "agenta" - slug: Optional[str] = None + +class WireModelConnection(_WireModel): + """Resolved model routing, non-secret environment, and credentials for one run.""" + + provider: str + deployment: str + endpoint: Optional[WireEndpoint] = None + credential_mode: Literal["env", "runtime_provided", "none"] = Field( + alias="credentialMode" + ) + environment: Optional[Dict[str, str]] = None + credentials: List[WireCredential] = Field(default_factory=list) class WireModelCapabilities(_WireModel): @@ -293,11 +314,35 @@ class WirePermissions(_WireModel): rules: Optional[List[WirePermissionRule]] = None +class WireMcpCredentialBinding(_WireModel): + kind: Literal["header"] + name: str + + +class WireMcpCredential(_WireModel): + binding: WireMcpCredentialBinding + value: str + usage: Literal["opaque_http"] + + +class WireMcpConnection(_WireModel): + """How the runner reaches one external HTTP MCP server. + + Public ``headers`` and secret header ``credentials`` stay separate by protocol role + (mirrors ``ResolvedMCPServer.to_wire``'s ``connection`` object). + """ + + type: Optional[str] = None + url: Optional[str] = None + headers: Optional[Dict[str, str]] = None + credentials: Optional[List[WireMcpCredential]] = None + + class WireMcpServer(_WireModel): - """A resolved external HTTP MCP server, mirrors ``mcp_servers_to_wire``.""" + """A resolved external HTTP MCP server, mirrors ``ResolvedMCPServer.to_wire``.""" name: str - connection: Dict[str, Any] + connection: WireMcpConnection policy: Dict[str, Any] @@ -418,21 +463,17 @@ class WireRunRequest(_WireModel): turn_id: Optional[str] = Field(default=None, alias="turnId") project_id: Optional[str] = Field(default=None, alias="projectId") agents_md: Optional[str] = Field(default=None, alias="agentsMd") - # Model + connection. ``model`` stays a plain string; the structured provider/connection - # fields ride alongside only when a resolved connection / model ref is present. + # Model id stays scalar; resolved routing and credentials are one consumer-owned object. model: Optional[str] = None - provider: Optional[str] = None - connection: Optional[WireConnection] = None - deployment: Optional[str] = None - endpoint: Optional[WireEndpoint] = None - credential_mode: Optional[str] = Field(default=None, alias="credentialMode") + model_connection: Optional[WireModelConnection] = Field( + default=None, alias="modelConnection" + ) + # Resolved model input modalities. Omitted when the resolver cannot determine them. model_capabilities: Optional[WireModelCapabilities] = Field( default=None, alias="modelCapabilities" ) # Turn. messages: Optional[List[WireChatMessage]] = None - # Secrets injected as harness env (provider keys); never written to the agent filesystem. - secrets: Optional[Dict[str, str]] = None # Tracing inputs, grouped by role (see the trace/telemetry interface restructure): ``context`` # carries the per-call W3C trace-context propagation, ``telemetry`` the operator-owned exporter # config + capture policy. Both come from the single service-side trace capture. diff --git a/sdks/python/agenta/sdk/redaction/context.py b/sdks/python/agenta/sdk/redaction/context.py index 769251dc94..078230f6f2 100644 --- a/sdks/python/agenta/sdk/redaction/context.py +++ b/sdks/python/agenta/sdk/redaction/context.py @@ -21,7 +21,9 @@ def get_active_redactor() -> Redactor: try: return _redactor_context.get() except LookupError: - return Redactor() + redactor = Redactor() + _redactor_context.set(redactor) + return redactor def set_active_redactor(redactor: Redactor) -> Token: diff --git a/sdks/python/agenta/sdk/redaction/seed.py b/sdks/python/agenta/sdk/redaction/seed.py index 0c11c52787..19a1fa0bf1 100644 --- a/sdks/python/agenta/sdk/redaction/seed.py +++ b/sdks/python/agenta/sdk/redaction/seed.py @@ -1,7 +1,7 @@ """Deny-set seeding: known-value redaction only ever registers VALUES, never key names. Sources per request/run: - 1. resolved connection/tool/mcp secrets (``ResolvedConnection.env``, ``ResolvedMCPServer.env``, + 1. resolved connection/tool/mcp secrets (``ResolvedConnection.credentials``, ``ResolvedMCPServer.env``, tool-spec secrets) — the values the platform just resolved for this run; 2. the request/run credential (the caller's Agenta API key); 3. the VALUE of any process env var selected by the name matchers below. diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py index 0ff60ae8a6..7da22b9328 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py @@ -1,9 +1,7 @@ """``ModelRef`` wiring into the config DTOs (no behavior change for string-only configs). -The Slice-1 contract: a structured ``model`` (dict / ``ModelRef``) populates ``model_ref`` and -projects ``model`` to its plain string; a plain-string ``model`` leaves ``model_ref`` unset so -the wire is byte-identical. ``wire_model_ref`` emits the non-secret provider/connection fields -only for a structured ref. +A structured ``model`` populates resolver intent and projects to a plain model string. Author +connection intent never crosses the runner boundary; only a resolved ``modelConnection`` does. """ from __future__ import annotations @@ -58,64 +56,40 @@ def test_explicit_model_ref_is_respected(): assert config.model_ref.provider == "openai" -# ------------------------------------------------------------- wire_model_ref / wire +# ------------------------------------------------------ resolved model connection wire -def test_wire_model_ref_empty_for_string_only_config(): - config = PiAgentTemplate(model="openai-codex/gpt-5.5") - assert config.wire_model_ref() == {} - - -def test_wire_model_ref_emits_provider_and_connection_for_structured(): - config = PiAgentTemplate( - model={ +def test_wire_model_connection_empty_before_resolution(): + for model in ( + "openai-codex/gpt-5.5", + {"provider": "openai", "model": "gpt-5.5"}, + { "provider": "openai", "model": "gpt-5.5", "connection": {"mode": "agenta", "slug": "openai-prod"}, - } - ) - assert config.wire_model_ref() == { - "provider": "openai", - "connection": {"mode": "agenta", "slug": "openai-prod"}, - } - - -def test_wire_model_ref_omits_default_connection(): - config = PiAgentTemplate( - model={"provider": "openai", "model": "gpt-5.5"}, - ) - # Default connection carries no non-default info, so only the provider rides the wire. - assert config.wire_model_ref() == {"provider": "openai"} - - -def test_wire_model_ref_emits_self_managed_connection_without_slug(): - config = PiAgentTemplate( - model={ + }, + { "provider": "openai", "model": "gpt-5.5", "connection": {"mode": "self_managed"}, - } - ) - assert config.wire_model_ref() == { - "provider": "openai", - "connection": {"mode": "self_managed"}, - } + }, + ): + config = PiAgentTemplate(model=model) + assert config.wire_model_connection() == {} -def test_string_only_config_wire_has_no_new_keys(): - # The whole point of Slice 1: a string-only config's payload gains no new keys. +def test_string_only_config_wire_has_no_model_connection(): payload = request_to_wire( harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(model="openai-codex/gpt-5.5"), messages=[Message(role="user", content="hi")], ) - assert "provider" not in payload - assert "connection" not in payload + assert "modelConnection" not in payload assert payload["model"] == "openai-codex/gpt-5.5" -def test_structured_config_wire_carries_provider_and_connection(): +def test_structured_author_intent_does_not_cross_runner_boundary(): payload = request_to_wire( harness=HarnessKind.PI, sandbox="local", @@ -129,8 +103,9 @@ def test_structured_config_wire_carries_provider_and_connection(): messages=[Message(role="user", content="hi")], ) assert payload["model"] == "openai/gpt-5.5" - assert payload["provider"] == "openai" - assert payload["connection"] == {"mode": "agenta", "slug": "openai-prod"} + assert "modelConnection" not in payload + for removed in ("provider", "connection", "secrets"): + assert removed not in payload def test_default_connection_equality(): diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py index c3ebc8c423..b61442d817 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py @@ -3,7 +3,7 @@ Locks the three model-string shapes the design promises (``"openai/gpt-5.5"``, ``"gpt-5.5"``, a full object with a connection), the first-slash split (so a custom ``my-gw/llama-3`` parses correctly and a provider slug is never re-split), the ``Connection`` validity rules, and the -secret hygiene of ``ResolvedConnection.to_wire()`` (it never emits ``env``). +secret hygiene of ``ResolvedConnection`` (typed credentials, masked dumps). """ from __future__ import annotations @@ -17,6 +17,7 @@ ModelRef, ResolvedConnection, ) +from agenta.sdk.agents.connections.endpoints import build_resolved_connection # ----------------------------------------------------------------- ModelRef.coerce @@ -134,22 +135,31 @@ def test_no_default_mode(): # --------------------------------------------------- ResolvedConnection / Endpoint shape -def test_resolved_connection_to_wire_excludes_env(): +def test_resolved_connection_to_wire_nests_typed_credentials(): resolved = ResolvedConnection( provider="openai", model="gpt-5.5", credential_mode="env", - env={"OPENAI_API_KEY": "sk-secret"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-secret", + "usage": "opaque_http", + } + ], endpoint=Endpoint(base_url="https://gw.example/v1"), ) - wire = resolved.to_wire() - assert "env" not in wire - assert "sk-secret" not in repr(wire) - assert wire == { + assert resolved.to_wire() == { "provider": "openai", - "model": "gpt-5.5", "deployment": "direct", "credentialMode": "env", + "credentials": [ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-secret", + "usage": "opaque_http", + } + ], "endpoint": {"baseUrl": "https://gw.example/v1"}, } @@ -176,35 +186,71 @@ def test_resolved_connection_to_wire_omits_endpoint_when_absent(): wire = resolved.to_wire() assert "endpoint" not in wire assert "modelCapabilities" not in wire + assert wire["credentials"] == [] assert wire["credentialMode"] == "runtime_provided" -def test_resolved_connection_env_is_hidden_from_repr(): +def test_local_use_credentials_do_not_require_an_http_endpoint(): + resolved = build_resolved_connection( + provider="bedrock", + model="anthropic.claude-x", + deployment="bedrock", + credential_mode="env", + values={"AWS_PROFILE": "profile"}, + ) + assert resolved.endpoint is None + assert resolved.credential_mode == "env" + assert [credential.usage for credential in resolved.credentials] == ["local_use"] + + +def test_resolved_connection_credential_is_hidden_from_repr(): resolved = ResolvedConnection( provider="openai", model="gpt-5.5", credential_mode="env", - env={"OPENAI_API_KEY": "do-not-print"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "do-not-print", + "usage": "opaque_http", + } + ], + endpoint=Endpoint(base_url="https://api.openai.com/v1"), ) assert "do-not-print" not in repr(resolved) -def test_resolved_connection_env_is_masked_from_model_dump(): +def _env_credentials(env: dict[str, str]) -> list[dict]: + return [ + { + "binding": {"kind": "environment", "name": name}, + "value": value, + "usage": "opaque_http", + } + for name, value in env.items() + ] + + +def test_resolved_connection_credential_is_masked_from_model_dump(): # F-SDK-DUMP: repr hiding is not enough — a dump must not carry the credential either. resolved = ResolvedConnection( provider="openai", model="gpt-5.5", credential_mode="env", - env={"OPENAI_API_KEY": "do-not-dump"}, + credentials=_env_credentials({"OPENAI_API_KEY": "do-not-dump"}), + endpoint=Endpoint(base_url="https://api.openai.com/v1"), ) assert "do-not-dump" not in str(resolved.model_dump()) assert "do-not-dump" not in resolved.model_dump_json() - assert resolved.model_dump()["env"] == {"OPENAI_API_KEY": "**********"} + assert resolved.model_dump()["credentials"][0]["value"] == "**********" # The credential is still readable through attribute access (the harness path). - assert resolved.env["OPENAI_API_KEY"] == "do-not-dump" + assert resolved.credentials[0].value == "do-not-dump" + # ... and through the wire/local-materialization consumers. + assert resolved.to_wire()["credentials"][0]["value"] == "do-not-dump" + assert resolved.plaintext_environment() == {"OPENAI_API_KEY": "do-not-dump"} -def test_session_config_dump_does_not_leak_resolved_connection_env(): +def test_session_config_dump_does_not_leak_resolved_connection_credential(): # The nested case: a SessionConfig dump must not surface the connection's credential. from agenta.sdk.agents.dtos import AgentTemplate, SessionConfig @@ -214,7 +260,104 @@ def test_session_config_dump_does_not_leak_resolved_connection_env(): provider="openai", model="gpt-5.5", credential_mode="env", - env={"OPENAI_API_KEY": "do-not-dump"}, + credentials=_env_credentials({"OPENAI_API_KEY": "do-not-dump"}), + endpoint=Endpoint(base_url="https://api.openai.com/v1"), ), ) assert "do-not-dump" not in config.model_dump_json() + + +@pytest.mark.parametrize( + "credentials, endpoint, mode", + [ + ( + [ + { + "binding": {"kind": "environment", "name": ""}, + "value": "key", + "usage": "opaque_http", + } + ], + Endpoint(base_url="https://api.example"), + "env", + ), + ( + [ + { + "binding": {"kind": "environment", "name": "KEY"}, + "value": "", + "usage": "opaque_http", + } + ], + Endpoint(base_url="https://api.example"), + "env", + ), + ( + [ + { + "binding": {"kind": "environment", "name": "KEY"}, + "value": "key", + "usage": "opaque_http", + } + ], + None, + "env", + ), + ( + [ + { + "binding": {"kind": "environment", "name": "KEY"}, + "value": "key", + "usage": "opaque_http", + } + ], + Endpoint(base_url="http://api.example"), + "env", + ), + ([], Endpoint(base_url="https://api.example"), "env"), + ( + [ + { + "binding": {"kind": "environment", "name": "KEY"}, + "value": "key", + "usage": "local_use", + } + ], + Endpoint(base_url="https://api.example"), + "runtime_provided", + ), + ], +) +def test_resolved_connection_rejects_invalid_credential_combinations( + credentials, endpoint, mode +): + with pytest.raises(ValidationError): + ResolvedConnection( + provider="test", + model="m", + credential_mode=mode, + credentials=credentials, + endpoint=endpoint, + ) + + +def test_plaintext_environment_materializes_only_at_local_boundary(): + resolved = ResolvedConnection( + provider="anthropic", + model="claude", + deployment="bedrock", + credential_mode="env", + environment={"AWS_REGION": "us-east-1"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "AWS_ACCESS_KEY_ID"}, + "value": "AKIA", + "usage": "local_use", + } + ], + endpoint=Endpoint(base_url="https://bedrock-runtime.us-east-1.amazonaws.com"), + ) + assert resolved.plaintext_environment() == { + "AWS_REGION": "us-east-1", + "AWS_ACCESS_KEY_ID": "AKIA", + } diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py index 75a89c0dd2..4f98f805cf 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py @@ -1,8 +1,7 @@ """The offline SDK-default resolvers: ``EnvConnectionResolver`` / ``StaticConnectionResolver``. -Locks the least-privilege contract: the env resolver returns exactly the one provider var when -present, ``runtime_provided`` (empty env) when absent or self-managed, and the static resolver -builds a resolved connection from a user-supplied credential. +Locks the least-privilege contract: managed resolvers return exactly the requested credential or +fail closed. Only an explicit self-managed connection yields ``runtime_provided`` with no env. """ from __future__ import annotations @@ -12,12 +11,18 @@ from agenta.sdk.agents.connections import ( Connection, EnvConnectionResolver, + MissingCredentialError, ModelRef, RuntimeAuthContext, StaticConnectionResolver, UnsupportedProviderError, ) + +def _credential_environment(resolved) -> dict[str, str]: + return {item.binding.name: item.value for item in resolved.credentials} + + _CTX = RuntimeAuthContext(harness="pi_core") @@ -34,9 +39,11 @@ async def test_env_resolver_returns_only_the_requested_provider_var(): ) assert resolved.credential_mode == "env" # Least privilege: exactly the one var, never the other provider's key. - assert resolved.env == {"OPENAI_API_KEY": "sk-openai"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-openai"} assert resolved.model == "gpt-5.5" assert resolved.provider == "openai" + assert resolved.endpoint.base_url == "https://api.openai.com/v1" + assert [item.usage for item in resolved.credentials] == ["opaque_http"] assert resolved.input_modalities == ["text", "image"] @@ -60,19 +67,16 @@ async def test_env_resolver_reads_the_live_process_env(monkeypatch): context=_CTX, ) assert resolved.credential_mode == "env" - assert resolved.env == {"OPENAI_API_KEY": "sk-from-env"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-from-env"} -async def test_env_resolver_absent_key_is_runtime_provided(): +async def test_env_resolver_absent_key_fails_closed(): resolver = EnvConnectionResolver(env={}) - resolved = await resolver.resolve( - model=ModelRef(provider="openai", model="gpt-5.5"), - context=_CTX, - ) - # Absence is valid: inject nothing, harness falls back to its own login. - assert resolved.credential_mode == "runtime_provided" - assert resolved.env == {} - assert resolved.model == "gpt-5.5" + with pytest.raises(MissingCredentialError, match="self_managed"): + await resolver.resolve( + model=ModelRef(provider="openai", model="gpt-5.5"), + context=_CTX, + ) async def test_env_resolver_self_managed_is_runtime_provided(): @@ -87,7 +91,7 @@ async def test_env_resolver_self_managed_is_runtime_provided(): ) # Self-managed injects nothing even when a key is in the env. assert resolved.credential_mode == "runtime_provided" - assert resolved.env == {} + assert _credential_environment(resolved) == {} async def test_env_resolver_errors_without_a_provider(): @@ -106,7 +110,7 @@ async def test_static_resolver_builds_from_an_api_key(): context=_CTX, ) assert resolved.credential_mode == "env" - assert resolved.env == {"OPENAI_API_KEY": "sk-static"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-static"} assert resolved.provider == "openai" assert resolved.model == "gpt-5.5" @@ -115,26 +119,39 @@ async def test_static_resolver_carries_a_base_url_into_the_endpoint(): resolver = StaticConnectionResolver( provider="openai", api_key="sk-static", - base_url="https://gw.example/v1", + base_url="https://gw.example:8443/v1", ) resolved = await resolver.resolve( model=ModelRef(provider="openai", model="gpt-5.5"), context=_CTX, ) assert resolved.endpoint is not None - assert resolved.endpoint.base_url == "https://gw.example/v1" + assert resolved.endpoint.base_url == "https://gw.example:8443/v1" # The base URL is non-secret and must not leak into env. - assert resolved.env == {"OPENAI_API_KEY": "sk-static"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-static"} -async def test_static_resolver_without_a_key_is_runtime_provided(): +async def test_static_resolver_without_a_key_fails_closed(): + resolver = StaticConnectionResolver(provider="openai") + with pytest.raises(MissingCredentialError, match="self_managed"): + await resolver.resolve( + model=ModelRef(provider="openai", model="gpt-5.5"), + context=_CTX, + ) + + +async def test_static_resolver_self_managed_without_a_key_is_runtime_provided(): resolver = StaticConnectionResolver(provider="openai") resolved = await resolver.resolve( - model=ModelRef(provider="openai", model="gpt-5.5"), + model=ModelRef( + provider="openai", + model="gpt-5.5", + connection=Connection(mode="self_managed"), + ), context=_CTX, ) assert resolved.credential_mode == "runtime_provided" - assert resolved.env == {} + assert _credential_environment(resolved) == {} async def test_static_resolver_from_dict(): @@ -145,5 +162,5 @@ async def test_static_resolver_from_dict(): model=ModelRef(provider="anthropic", model="claude-opus-4-8"), context=_CTX, ) - assert resolved.env == {"ANTHROPIC_API_KEY": "sk-ant"} + assert _credential_environment(resolved) == {"ANTHROPIC_API_KEY": "sk-ant"} assert resolved.provider == "anthropic" diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json index a47eb0e97a..8fe424302d 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json @@ -3,7 +3,7 @@ "sandbox": "local", "sessionId": "sess-attachment", "agentsMd": "Use the attached file.", - "model": "claude-sonnet-4-6", + "model": "anthropic/claude-sonnet-4-6", "messages": [ { "role": "user", @@ -22,7 +22,6 @@ ] } ], - "secrets": {}, "context": null, "telemetry": null, "tools": ["read", "bash", "edit", "write", "grep", "find", "ls"], @@ -31,13 +30,16 @@ "permissions": { "default": "allow_reads" }, - "provider": "anthropic", - "deployment": "direct", - "credentialMode": "runtime_provided", "modelCapabilities": { "inputModalities": [ "text", "image" ] + }, + "modelConnection": { + "provider": "anthropic", + "deployment": "direct", + "credentialMode": "runtime_provided", + "credentials": [] } } diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json index 48ac69c51a..b52afc65a7 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json @@ -5,25 +5,29 @@ "agentsMd": "You are a helpful assistant.", "model": "claude-sonnet-4-6", "messages": [ - {"role": "user", "content": "hi"} + { + "role": "user", + "content": "hi" + } ], - "secrets": {"ANTHROPIC_API_KEY": "sk-ant"}, "context": null, "telemetry": null, - "runContext": { - "run": {"kind": "test"} - }, "tools": [], "customTools": [ { "name": "get_user", "description": "Get a user", - "inputSchema": {"type": "object", "properties": {}}, - "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", + "inputSchema": { + "type": "object", + "properties": {} + }, + "readOnly": true, "kind": "callback", - "contextBindings": {"target.workflow_variant_id": "$ctx.workflow.variant.id"}, - "timeoutMs": 120000, - "readOnly": true + "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", + "contextBindings": { + "target.workflow_variant_id": "$ctx.workflow.variant.id" + }, + "timeoutMs": 120000 } ], "toolCallback": { @@ -33,27 +37,63 @@ "permissions": { "default": "deny", "rules": [ - {"pattern": "WebFetch", "permission": "deny"}, - {"pattern": "Read", "permission": "allow"}, - {"pattern": "Bash(npm run:*)", "permission": "allow"} + { + "pattern": "WebFetch", + "permission": "deny" + }, + { + "pattern": "Read", + "permission": "allow" + }, + { + "pattern": "Bash(npm run:*)", + "permission": "allow" + } ] }, - "harnessFiles": [ - { - "path": ".claude/settings.json", - "content": "{\n \"permissions\": {\n \"defaultMode\": \"acceptEdits\",\n \"allow\": [\n \"Read\",\n \"Bash(npm run:*)\"\n ],\n \"deny\": [\n \"WebFetch\",\n \"mcp__agenta-tools__get_user\"\n ]\n }\n}" - } - ], "skills": [ { "name": "release-notes", "description": "Draft release notes from a changelog.", "body": "Read the changelog, then write release notes.", "files": [ - {"path": "scripts/draft.py", "content": "print('draft')", "executable": true} + { + "path": "scripts/draft.py", + "content": "print('draft')", + "executable": true + } ], "disableModelInvocation": true, "allowExecutableFiles": true } - ] + ], + "modelConnection": { + "provider": "anthropic", + "deployment": "direct", + "credentialMode": "env", + "credentials": [ + { + "binding": { + "kind": "environment", + "name": "ANTHROPIC_API_KEY" + }, + "value": "sk-ant", + "usage": "opaque_http" + } + ], + "endpoint": { + "baseUrl": "https://api.anthropic.com" + } + }, + "harnessFiles": [ + { + "path": ".claude/settings.json", + "content": "{\n \"permissions\": {\n \"defaultMode\": \"acceptEdits\",\n \"allow\": [\n \"Read\",\n \"Bash(npm run:*)\"\n ],\n \"deny\": [\n \"WebFetch\",\n \"mcp__agenta-tools__get_user\"\n ]\n }\n}" + } + ], + "runContext": { + "run": { + "kind": "test" + } + } } diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json index 39fe25b522..1363f84b52 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json @@ -5,9 +5,11 @@ "agentsMd": "You are a helpful assistant.", "model": "openai-codex/gpt-5.5", "messages": [ - {"role": "user", "content": "hi"} + { + "role": "user", + "content": "hi" + } ], - "secrets": {"OPENAI_API_KEY": "sk-test"}, "context": { "propagation": { "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", @@ -29,40 +31,45 @@ } } }, - "runContext": { - "run": {"kind": "test"}, - "workflow": { - "artifact": {"id": "wf_abc"}, - "variant": {"id": "var_abc", "slug": "weather-agent"}, - "revision": {"id": "rev_abc123", "version": "3"}, - "is_draft": false - }, - "trace": { - "trace_id": "0af7651916cd43dd8448eb211c80319c", - "span_id": "b7ad6b7169203331" - } - }, "tools": ["read", "bash", "edit", "write", "grep", "find", "ls"], "customTools": [ { "name": "get_user", "description": "Get a user", - "inputSchema": {"type": "object", "properties": {}}, - "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", + "inputSchema": { + "type": "object", + "properties": {} + }, + "readOnly": true, "kind": "callback", - "contextBindings": {"target.workflow_variant_id": "$ctx.workflow.variant.id"}, - "timeoutMs": 120000, - "readOnly": true + "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", + "contextBindings": { + "target.workflow_variant_id": "$ctx.workflow.variant.id" + }, + "timeoutMs": 120000 }, { "name": "get_weather", "description": "Look up weather for a city", - "inputSchema": {"type": "object", "properties": {"city": {"type": "string"}}}, + "inputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + } + }, "kind": "callback", "call": { "method": "POST", "path": "/api/workflows/invoke", - "body": {"references": {"workflow_revision": {"id": "rev_abc123"}}}, + "body": { + "references": { + "workflow_revision": { + "id": "rev_abc123" + } + } + }, "args_into": "data.inputs" } } @@ -71,7 +78,9 @@ "endpoint": "https://api.example/tools/call", "authorization": "Access tok-123" }, - "permissions": {"default": "allow_reads"}, + "permissions": { + "default": "allow_reads" + }, "systemPrompt": "You are Pi.", "appendSystemPrompt": "Be terse.", "skills": [ @@ -80,14 +89,62 @@ "description": "Draft release notes from a changelog.", "body": "Read the changelog, then write release notes.", "files": [ - {"path": "scripts/draft.py", "content": "print('draft')", "executable": true} + { + "path": "scripts/draft.py", + "content": "print('draft')", + "executable": true + } ], "disableModelInvocation": true, "allowExecutableFiles": true } ], "sandboxPermission": { - "network": {"mode": "off", "allowlist": []}, + "network": { + "mode": "off", + "allowlist": [] + }, "enforcement": "strict" + }, + "modelConnection": { + "provider": "openai-codex", + "deployment": "direct", + "credentialMode": "env", + "credentials": [ + { + "binding": { + "kind": "environment", + "name": "OPENAI_API_KEY" + }, + "value": "sk-test", + "usage": "opaque_http" + } + ], + "endpoint": { + "baseUrl": "https://api.openai.com/v1" + } + }, + "runContext": { + "run": { + "kind": "test" + }, + "workflow": { + "artifact": { + "id": "wf_abc" + }, + "variant": { + "id": "var_abc", + "slug": "weather-agent" + }, + "revision": { + "id": "rev_abc123", + "version": "3" + }, + "is_draft": false + }, + "trace": { + "trace_id": "0af7651916cd43dd8448eb211c80319c", + "span_id": "b7ad6b7169203331" + } } } diff --git a/sdks/python/oss/tests/pytest/unit/agents/mcp/test_resolver.py b/sdks/python/oss/tests/pytest/unit/agents/mcp/test_resolver.py index bbf44c4034..b715da4b63 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/mcp/test_resolver.py +++ b/sdks/python/oss/tests/pytest/unit/agents/mcp/test_resolver.py @@ -59,7 +59,18 @@ def test_server_name_must_be_a_runtime_safe_identifier(name): server(name=name) -async def test_resolves_public_and_secret_headers(): +def test_public_and_secret_header_names_must_be_unique(): + # One header name cannot be both a public value and a secret credential. + with pytest.raises(ValidationError, match="must be unique"): + MCPConnection( + type="http", + url=PUBLIC_MCP_URL, + headers={"Authorization": "public"}, + credentials=MCPHeaderSecretRefs(headers={"authorization": "token_ref"}), + ) + + +async def test_resolves_public_headers_and_typed_secret_credentials(): resolved = await MCPResolver( secret_provider=DictSecretProvider({"memory_token": "secret-value"}) ).resolve( @@ -76,17 +87,30 @@ async def test_resolves_public_and_secret_headers(): ) ] ) + # Public headers and secret credentials stay separate by protocol role: the resolved + # secret rides a typed header binding, never a merged header value. assert resolved[0].to_wire()["connection"] == { "type": "http", "url": PUBLIC_MCP_URL, - "headers": { - "X-Workspace": "demo", - "Authorization": "secret-value", - }, + "headers": {"X-Workspace": "demo"}, + "credentials": [ + { + "binding": {"kind": "header", "name": "Authorization"}, + "value": "secret-value", + "usage": "opaque_http", + } + ], } + assert "secret-value" not in repr(resolved[0]) + # Structural dump guard (F-SDK-DUMP), mirroring ResolvedCredential: a model_dump can never + # carry the credential value — only to_wire/attribute access hands it to the runner wire. + assert "secret-value" not in str(resolved[0].model_dump()) + assert "secret-value" not in resolved[0].model_dump_json() + dumped = resolved[0].model_dump() + assert dumped["credentials"][0]["value"] == "**********" -async def test_missing_mcp_secret_is_explicit(): +async def test_missing_http_mcp_secret_is_explicit(): with pytest.raises(MissingMCPSecretError): await MCPResolver(secret_provider=DictSecretProvider({})).resolve( [ @@ -103,6 +127,23 @@ async def test_missing_mcp_secret_is_explicit(): ) +async def test_empty_secret_value_is_treated_as_missing(): + with pytest.raises(MissingMCPSecretError): + await MCPResolver(secret_provider=DictSecretProvider({"token": ""})).resolve( + [ + server( + connection=MCPConnection( + type="http", + url=PUBLIC_MCP_URL, + credentials=MCPHeaderSecretRefs( + headers={"Authorization": "token"} + ), + ) + ) + ] + ) + + async def test_policy_rides_the_wire(): resolved = await MCPResolver(secret_provider=DictSecretProvider({})).resolve( [ @@ -167,3 +208,4 @@ async def test_omit_missing_secret_keeps_public_headers_only(): ] ) assert resolved[0].to_wire()["connection"]["headers"] == {"X-Workspace": "demo"} + assert "credentials" not in resolved[0].to_wire()["connection"] diff --git a/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py b/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py index 60cdb2a18d..0ddf231b6c 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py +++ b/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py @@ -8,6 +8,8 @@ AmbiguousConnectionError, ConnectionNotFoundError, ConnectionResolutionError, + InvalidConnectionConfigurationError, + MissingCredentialError, MissingProviderError, ModelRef, ProviderMismatchError, @@ -17,6 +19,10 @@ from agenta.sdk.agents.platform import connections +def _credential_environment(resolved) -> dict[str, str]: + return {item.binding.name: item.value for item in resolved.credentials} + + def _model( slug: str | None = "openai", provider: str = "openai", model: str = "gpt-5.5" ) -> ModelRef: @@ -84,7 +90,7 @@ async def test_resolve_fetches_secrets_and_selects_one_key(fake_http, connection assert resolved.model == "gpt-5.5" assert resolved.deployment == "direct" assert resolved.credential_mode == "env" - assert resolved.env == {"OPENAI_API_KEY": "sk-prod"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-prod"} assert resolved.input_modalities == ["text", "image"] assert capture["method"] == "GET" assert capture["url"] == "https://api.x/api/secrets/" @@ -100,7 +106,7 @@ async def test_self_managed_short_circuits_without_api_base(fake_http): context=_context(), ) assert resolved.credential_mode == "runtime_provided" - assert resolved.env == {} + assert _credential_environment(resolved) == {} assert resolved.input_modalities == ["text", "image"] @@ -109,7 +115,15 @@ async def test_default_connection_requires_unique_provider_match(fake_http, conn resolved = await VaultConnectionResolver(connection).resolve( model=_model(slug=None), context=_context() ) - assert resolved.env == {"OPENAI_API_KEY": "sk-default"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-default"} + + +async def test_managed_connection_with_empty_key_fails_closed(fake_http, connection): + fake_http(connections, payload=[_provider_key("default", "openai", "")]) + with pytest.raises(MissingCredentialError, match="self_managed"): + await VaultConnectionResolver(connection).resolve( + model=_model(slug=None), context=_context() + ) async def test_default_connection_ambiguous(fake_http, connection): @@ -148,7 +162,7 @@ async def test_bare_catalog_model_infers_provider(fake_http, connection): model=ModelRef.coerce("gpt-4o-mini"), context=_context() ) assert resolved.provider == "openai" - assert resolved.env == {"OPENAI_API_KEY": "sk-prod"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-prod"} async def test_missing_provider_hint_is_harness_correct_for_claude( @@ -184,7 +198,9 @@ async def test_bare_claude_alias_resolves_to_anthropic(fake_http, connection): ) assert resolved.provider == "anthropic", alias assert resolved.model == alias, alias - assert resolved.env == {"ANTHROPIC_API_KEY": "sk-ant"}, alias + assert _credential_environment(resolved) == {"ANTHROPIC_API_KEY": "sk-ant"}, ( + alias + ) assert resolved.input_modalities == ["text", "image"], alias @@ -308,15 +324,48 @@ async def test_custom_provider_snake_case_extras_normalize_for_bedrock( assert resolved.provider == "anthropic" assert resolved.model == "anthropic.claude-3-5-sonnet" assert resolved.deployment == "bedrock" - assert resolved.env == { - "AWS_REGION": "us-east-1", + assert _credential_environment(resolved) == { "AWS_ACCESS_KEY_ID": "AKIA", "AWS_SECRET_ACCESS_KEY": "secret", "AWS_SESSION_TOKEN": "token", } + assert resolved.environment == {"AWS_REGION": "us-east-1"} + assert {item.usage for item in resolved.credentials} == {"local_use"} assert resolved.endpoint.region == "us-east-1" +async def test_bedrock_bearer_is_opaque_http_with_regional_endpoint( + fake_http, connection +): + fake_http( + connections, + payload=[ + _custom_provider( + "my-bedrock", + "bedrock", + extras={ + "aws_region_name": "eu-west-1", + "aws_bearer_token_bedrock": "bearer-token", + }, + models=["anthropic.claude-3-5-sonnet"], + ) + ], + ) + resolved = await VaultConnectionResolver(connection).resolve( + model=_model( + "my-bedrock", provider="anthropic", model="anthropic.claude-3-5-sonnet" + ), + context=RuntimeAuthContext(harness="claude"), + ) + assert resolved.endpoint.base_url == ( + "https://bedrock-runtime.eu-west-1.amazonaws.com" + ) + assert _credential_environment(resolved) == { + "AWS_BEARER_TOKEN_BEDROCK": "bearer-token" + } + assert [item.usage for item in resolved.credentials] == ["opaque_http"] + + async def test_custom_provider_vertex_snake_case_extras(fake_http, connection): fake_http( connections, @@ -338,11 +387,38 @@ async def test_custom_provider_vertex_snake_case_extras(fake_http, connection): context=RuntimeAuthContext(harness="claude"), ) assert resolved.deployment == "vertex_ai" - assert resolved.env == { + assert _credential_environment(resolved) == { + "GOOGLE_APPLICATION_CREDENTIALS": "/adc.json", + } + assert resolved.environment == { "GOOGLE_CLOUD_PROJECT": "proj", "GOOGLE_CLOUD_LOCATION": "us-central1", - "GOOGLE_APPLICATION_CREDENTIALS": "/adc.json", } + assert [item.usage for item in resolved.credentials] == ["local_use"] + + +async def test_vertex_api_key_mode_is_rejected_as_out_of_scope(fake_http, connection): + fake_http( + connections, + payload=[ + _custom_provider( + "vertex-key", + "vertex_ai", + extras={ + "vertex_ai_location": "us-central1", + "GOOGLE_CLOUD_API_KEY": "vertex-key-value", + }, + models=["gemini-model"], + ) + ], + ) + with pytest.raises( + InvalidConnectionConfigurationError, match="Vertex API-key authentication" + ): + await VaultConnectionResolver(connection).resolve( + model=_model("vertex-key", provider="gemini", model="gemini-model"), + context=RuntimeAuthContext(harness="pi_core"), + ) async def test_custom_gateway_api_key_from_extras_and_endpoint(fake_http, connection): @@ -364,7 +440,7 @@ async def test_custom_gateway_api_key_from_extras_and_endpoint(fake_http, connec context=RuntimeAuthContext(harness="claude"), ) assert resolved.deployment == "custom" - assert resolved.env == {"ANTHROPIC_API_KEY": "sk-gw"} + assert _credential_environment(resolved) == {"ANTHROPIC_API_KEY": "sk-gw"} assert resolved.endpoint.base_url == "https://93.184.216.34/v1" @@ -479,7 +555,7 @@ async def test_openai_compatible_custom_normalizes_to_openai(fake_http, connecti assert resolved.model == model_id assert resolved.endpoint.base_url == endpoint assert resolved.credential_mode == "env" - assert resolved.env == {"OPENAI_API_KEY": "sk-oai-compatible"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-oai-compatible"} async def test_openai_compatible_custom_missing_url_fails_loud(fake_http, connection): @@ -516,7 +592,16 @@ async def test_full_custom_model_key_selects_and_strips_to_backend_model( fake_http( connections, payload=[ - _custom_provider("my-bedrock", "bedrock", models=["anthropic.claude-x"]) + _custom_provider( + "my-bedrock", + "bedrock", + extras={ + "aws_access_key_id": "AKIA", + "aws_secret_access_key": "secret", + "aws_region_name": "us-east-1", + }, + models=["anthropic.claude-x"], + ) ], ) resolved = await VaultConnectionResolver(connection).resolve( 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 0a48ef377b..ab956aca25 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 @@ -104,7 +104,7 @@ async def create_session( def _no_connection_result() -> ResolvedConnection: return ResolvedConnection( - provider="openai", model="m", credential_mode="runtime_provided", env={} + provider="openai", model="m", credential_mode="runtime_provided" ) @@ -169,7 +169,7 @@ async def test_absent_run_kind_leaves_composition_run_context_untouched(): # --------------------------------------------------------------------------- # -# Drift 1 + 2: capability gating and degradation policy are the SEAM DEFAULT now +# Drift 1 + 2: capability and fail-closed resolution policy are the SEAM DEFAULT now # (previously a bare fallback in handler.py with neither). # --------------------------------------------------------------------------- # async def test_default_composition_rejects_unsupported_provider_pre_resolve(): @@ -205,7 +205,13 @@ async def _resolve(*, model, context): model="anthropic.claude-x", deployment="bedrock", credential_mode="env", - env={"AWS_ACCESS_KEY_ID": "AKIA"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "AWS_ACCESS_KEY_ID"}, + "value": "AKIA", + "usage": "local_use", + } + ], ) comp = AgentComposition( @@ -238,7 +244,13 @@ async def _resolve(*, model, context): model="qwen2.5-coder:7b", deployment="custom", credential_mode="env", - env={"OPENAI_API_KEY": "sk-oai"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-oai", + "usage": "opaque_http", + } + ], endpoint={"base_url": "https://93.184.216.34/v1"}, ) @@ -276,7 +288,13 @@ async def _resolve(*, model, context): model="some-model", deployment="custom", credential_mode="env", - env={"ANTHROPIC_API_KEY": "sk-ant"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "ANTHROPIC_API_KEY"}, + "value": "sk-ant", + "usage": "opaque_http", + } + ], endpoint={"base_url": "https://93.184.216.34/v1"}, ) @@ -301,10 +319,9 @@ async def _resolve(*, model, context): ) -async def test_default_composition_degrades_default_connection_failure(): - """An unconfigured default-mode connection degrades to runtime_provided, no raise -- - even with NO composition override (the SDK default now has the degradation policy - the old bare fallback lacked).""" +async def test_default_composition_fails_closed_on_connection_resolution_failure(): + """A connection resolution failure fails closed, even with NO composition override + (the SDK default never degrades to an implicit runtime-provided fallback).""" backend = _FakeBackend(output="echo") async def _resolve(*, model, context): @@ -316,13 +333,14 @@ async def _resolve(*, model, context): ) handler = make_agent_handler(comp) - result = await handler( - request=_request(), - messages=[{"role": "user", "content": "hi"}], - parameters=_params("pi_core", model={"provider": "openai", "model": "gpt-5.5"}), - ) - - assert result == {"messages": [{"role": "assistant", "content": "echo"}]} + with pytest.raises(ConnectionResolutionError, match="network unreachable"): + await handler( + request=_request(), + messages=[{"role": "user", "content": "hi"}], + parameters=_params( + "pi_core", model={"provider": "openai", "model": "gpt-5.5"} + ), + ) async def test_composition_override_replaces_default_gating(): diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py index acda6eaf55..05a45192c7 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py @@ -63,13 +63,14 @@ def test_pi_keeps_native_tools(make_env): assert result.model == "m" -def test_pi_threads_model_ref_so_connection_reaches_wire(make_env): - """Regression: a named custom connection's ``{mode, slug}`` must reach the ``/run`` wire. +def test_pi_threads_model_ref_so_connection_reaches_resolver(make_env): + """Regression: a named custom connection's ``{mode, slug}`` must survive the adapter. ``_to_harness_config`` builds the wire-producing harness template. If it drops - ``model_ref`` (passing only the plain ``model`` string), ``wire_model_ref`` returns ``{}`` - and the runner never sees the connection slug, so it cannot build its ``models.json`` plan - for an OpenAI-compatible custom connection (the run then falls back to a default provider). + ``model_ref`` (passing only the plain ``model`` string), the connection resolver never + sees the slug, so it cannot select the OpenAI-compatible custom connection (the run then + falls back to a default provider). Author intent itself no longer rides the ``/run`` + wire — only the resolved ``modelConnection`` (route + typed credentials) does. """ harness = PiHarness(make_env(supported=[HarnessKind.PI])) agent = AgentTemplate( @@ -84,14 +85,11 @@ def test_pi_threads_model_ref_so_connection_reaches_wire(make_env): assert result.model_ref is not None assert result.model_ref.connection.slug == "my-compat" - # The connection intent reaches the /run wire so the runner can register the provider. - assert result.wire_model_ref().get("connection") == { - "mode": "agenta", - "slug": "my-compat", - } + # Unresolved author intent never rides the wire; only a resolved connection would. + assert result.wire_model_connection() == {} -def test_agenta_threads_model_ref_so_connection_reaches_wire(make_env): +def test_agenta_threads_model_ref_so_connection_reaches_resolver(make_env): """Same guarantee as Pi for the ``pi_agenta`` harness (it also runs Pi).""" harness = AgentaHarness(make_env(supported=[HarnessKind.AGENTA])) agent = AgentTemplate( @@ -105,10 +103,8 @@ def test_agenta_threads_model_ref_so_connection_reaches_wire(make_env): result = harness._to_harness_config(_session_config(agent=agent)) assert result.model_ref is not None - assert result.wire_model_ref().get("connection") == { - "mode": "agenta", - "slug": "my-compat", - } + assert result.model_ref.connection.slug == "my-compat" + assert result.wire_model_connection() == {} def test_pi_reads_its_harness_extras_slice(make_env): diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py b/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py new file mode 100644 index 0000000000..de4dbbc6c5 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py @@ -0,0 +1,235 @@ +"""Per-run redaction scoping in the agent handler. + +The handler seeds each run's deny-set from that run's resolved credentials. The seed must go +into a FRESH `Redactor` installed for exactly the run's scope (batch call / stream lifetime) +and restored on exit — never mutated into a long-lived ambient redactor. Otherwise sequential +runs sharing one task/context accumulate each other's secret values: the second run's sinks +would hold (and its lifetime would extend) the first run's live credentials. + +These tests drive `make_agent_handler` with the same fakes shape as +`test_agent_composition_seam.py` and observe the ambient redactor from inside the run (the +backend's `create_session` runs within the scope every sink shares). +""" + +from __future__ import annotations + +import asyncio +from typing import Any, AsyncIterator, Dict, List, Optional + +from agenta.sdk.agents import AgentResult, HarnessKind +from agenta.sdk.agents.connections import ResolvedConnection +from agenta.sdk.agents.handler import AgentComposition, make_agent_handler +from agenta.sdk.agents.interfaces import Backend, Sandbox, Session +from agenta.sdk.agents.streaming import AgentStream +from agenta.sdk.models.workflows import WorkflowServiceRequest +from agenta.sdk.redaction.context import get_active_redactor +from agenta.sdk.redaction.redactor import Redactor + +SECRET_A = "sk-run-a-fake-secret-aaaa1111aaaa1111" +SECRET_B = "sk-run-b-fake-secret-bbbb2222bbbb2222" + + +# --------------------------------------------------------------------------- # +# Fakes (mirrors test_agent_composition_seam.py's shape) +# --------------------------------------------------------------------------- # +class _FakeSandbox(Sandbox): + async def add_files(self, files) -> None: + return None + + async def destroy(self) -> None: + return None + + +class _FakeSession(Session): + def __init__(self, result: AgentResult) -> None: + self._result = result + + @property + def id(self) -> Optional[str]: + return self._result.session_id + + async def prompt(self, messages, *, on_event=None) -> AgentResult: + return self._result + + def stream(self, messages) -> AgentStream: + result = self._result + + async def _records() -> AsyncIterator[Dict[str, Any]]: + yield { + "kind": "event", + "event": {"type": "message", "text": result.output}, + } + yield { + "kind": "result", + "result": { + "ok": True, + "output": result.output, + "usage": result.usage, + "sessionId": result.session_id, + }, + } + + return AgentStream(_records()) + + async def destroy(self) -> None: + return None + + +class _CapturingBackend(Backend): + """Records the AMBIENT redactor observed inside each run (at session creation time).""" + + supported_harnesses = frozenset({HarnessKind.PI, HarnessKind.CLAUDE}) + + def __init__(self, *, output: str = "ok") -> None: + self._output = output + self.captured_redactors: List[Redactor] = [] + + async def create_sandbox(self) -> _FakeSandbox: + return _FakeSandbox() + + async def create_session( + self, + sandbox, + config, + *, + harness, + secrets=None, + trace=None, + run_context=None, + session_id=None, + ) -> _FakeSession: + self.captured_redactors.append(get_active_redactor()) + return _FakeSession(AgentResult(output=self._output, events=[], usage={})) + + +def _make_handler(secret: str, backend: _CapturingBackend): + async def _resolve(*, model, context): + return ResolvedConnection( + provider="openai", + model="qwen2.5-coder:7b", + deployment="custom", + credential_mode="env", + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": secret, + "usage": "opaque_http", + } + ], + endpoint={"base_url": "https://93.184.216.34/v1"}, + ) + + comp = AgentComposition( + select_backend=lambda template: backend, + resolve_connection=_resolve, + ) + return make_agent_handler(comp) + + +def _params() -> Dict[str, Any]: + return { + "agent": { + "harness": {"kind": "pi_core"}, + "llm": {"provider": "openai", "model": "qwen2.5-coder:7b"}, + } + } + + +def _messages() -> List[Dict[str, str]]: + return [{"role": "user", "content": "hi"}] + + +def _knows(redactor: Redactor, secret: str) -> bool: + """True when `secret` is in the redactor's deny-set (it gets scrubbed).""" + return secret not in (redactor.redact_string(f"x {secret}", sink="test") or "") + + +# --------------------------------------------------------------------------- # +# Sequential runs in ONE task/context: no accumulation, ambient restored +# --------------------------------------------------------------------------- # +async def test_sequential_batch_runs_do_not_accumulate_deny_set(): + backend = _CapturingBackend() + baseline = get_active_redactor() # the ambient redactor of this task, pre-run + + await _make_handler(SECRET_A, backend)( + request=WorkflowServiceRequest(), + messages=_messages(), + parameters=_params(), + ) + first = backend.captured_redactors[0] + assert _knows(first, SECRET_A), "run 1's redactor is seeded with run 1's secret" + # The run's scope closed: the ambient redactor is the pre-run one again, unseeded. + assert get_active_redactor() is baseline + assert not _knows(baseline, SECRET_A) + + await _make_handler(SECRET_B, backend)( + request=WorkflowServiceRequest(), + messages=_messages(), + parameters=_params(), + ) + second = backend.captured_redactors[1] + assert second is not first, "each run gets a FRESH redactor" + assert _knows(second, SECRET_B) + # The core isolation property: run 2's deny-set does NOT hold run 1's secret. + assert not _knows(second, SECRET_A) + assert get_active_redactor() is baseline + + +async def test_sequential_streaming_runs_scope_and_restore(): + backend = _CapturingBackend(output=f"leak {SECRET_A}") + baseline = get_active_redactor() + + stream = await _make_handler(SECRET_A, backend)( + request=WorkflowServiceRequest(flags={"stream": True}), + messages=_messages(), + parameters=_params(), + ) + events = [event async for event in stream] + # The seeded scope was active during iteration: the echoed secret is scrubbed from the + # live event wire. + assert events, "the stream produced events" + assert SECRET_A not in str(events) + assert "[ag:redacted" in str(events) + first = backend.captured_redactors[0] + assert _knows(first, SECRET_A) + # Exhausting the stream closed the scope: the ambient redactor is restored, unseeded. + assert get_active_redactor() is baseline + assert not _knows(baseline, SECRET_A) + + stream_b = await _make_handler(SECRET_B, backend)( + request=WorkflowServiceRequest(flags={"stream": True}), + messages=_messages(), + parameters=_params(), + ) + async for _ in stream_b: + pass + second = backend.captured_redactors[1] + assert _knows(second, SECRET_B) + assert not _knows(second, SECRET_A), "no accumulation across streamed runs" + assert get_active_redactor() is baseline + + +# --------------------------------------------------------------------------- # +# Concurrent runs: each task's scope holds only its own secret +# --------------------------------------------------------------------------- # +async def test_concurrent_runs_have_isolated_deny_sets(): + backend_a = _CapturingBackend() + backend_b = _CapturingBackend() + + await asyncio.gather( + _make_handler(SECRET_A, backend_a)( + request=WorkflowServiceRequest(), + messages=_messages(), + parameters=_params(), + ), + _make_handler(SECRET_B, backend_b)( + request=WorkflowServiceRequest(), + messages=_messages(), + parameters=_params(), + ), + ) + + run_a = backend_a.captured_redactors[0] + run_b = backend_b.captured_redactors[0] + assert _knows(run_a, SECRET_A) and not _knows(run_a, SECRET_B) + assert _knows(run_b, SECRET_B) and not _knows(run_b, SECRET_A) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index b8ce46839a..6b42f97292 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py @@ -19,6 +19,9 @@ import pytest +from agenta.sdk.redaction.context import redaction_context +from agenta.sdk.redaction.redactor import Redactor + from agenta.sdk.agents import ( AgentaAgentTemplate, AgentTemplate, @@ -60,13 +63,8 @@ "agentsMd", "model", "modelCapabilities", - "provider", - "connection", - "deployment", - "endpoint", - "credentialMode", + "modelConnection", "messages", - "secrets", "context", "telemetry", "runContext", @@ -130,6 +128,20 @@ def _pi_payload(): config = PiAgentTemplate( agents_md="You are a helpful assistant.", model="openai-codex/gpt-5.5", + resolved_connection=ResolvedConnection( + provider="openai-codex", + model="gpt-5.5", + deployment="direct", + credential_mode="env", + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-test", + "usage": "opaque_http", + } + ], + endpoint=Endpoint(base_url="https://api.openai.com/v1"), + ), custom_tools=[dict(_CUSTOM_TOOL), dict(_DIRECT_CALL_TOOL)], tool_callback=_CALLBACK, skills=[dict(_SKILL)], @@ -142,7 +154,6 @@ def _pi_payload(): sandbox="local", config=config, messages=[Message(role="user", content="hi")], - secrets={"OPENAI_API_KEY": "sk-test"}, trace=TraceContext( traceparent="00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", endpoint="https://otlp.example/v1/traces", @@ -175,6 +186,23 @@ def _claude_payload(): config = ClaudeAgentTemplate( agents_md="You are a helpful assistant.", model="claude-sonnet-4-6", + resolved_connection=ResolvedConnection( + provider="anthropic", + model="claude-sonnet-4-6", + deployment="direct", + credential_mode="env", + credentials=[ + { + "binding": { + "kind": "environment", + "name": "ANTHROPIC_API_KEY", + }, + "value": "sk-ant", + "usage": "opaque_http", + } + ], + endpoint=Endpoint(base_url="https://api.anthropic.com"), + ), custom_tools=[dict(_CUSTOM_TOOL)], tool_callback=_CALLBACK, permission_default="deny", @@ -190,7 +218,6 @@ def _claude_payload(): sandbox="local", config=config, messages=[Message(role="user", content="hi")], - secrets={"ANTHROPIC_API_KEY": "sk-ant"}, trace=None, run_context=RunContext(run=RunContextRun(kind="test")), session_id=None, @@ -578,19 +605,21 @@ def test_request_to_wire_emits_only_known_keys(): assert {"systemPrompt", "appendSystemPrompt"} <= set(pi) -def test_request_to_wire_carries_resolved_connection_non_secret_descriptor(): - # A threaded resolved connection is the authoritative provider/model descriptor: the - # resolved `model` overrides the config-build `model`, `provider`/`deployment`/ - # `credentialMode`/`endpoint.baseUrl` ride the wire, and the secret `key` NEVER does (it - # rides `secrets`; `env` is masked from the wire by `ResolvedConnection.to_wire`). +def test_request_to_wire_carries_consumer_owned_model_connection(): config = PiAgentTemplate( - model="openai/gpt-5.5", # the config-build model + model="openai/gpt-5.5", resolved_connection=ResolvedConnection( provider="openai", - model="gpt-5.5-2026", # the resolved EXACT model, wins over `model` + model="gpt-5.5-2026", deployment="custom", credential_mode="env", - env={"OPENAI_API_KEY": "sk-secret"}, # secret channel; never on the wire + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-secret", + "usage": "opaque_http", + } + ], endpoint=Endpoint(base_url="https://gw.example/v1"), ), ) @@ -599,21 +628,77 @@ def test_request_to_wire_carries_resolved_connection_non_secret_descriptor(): sandbox="local", config=config, messages=[Message(role="user", content="hi")], - secrets={"OPENAI_API_KEY": "sk-secret"}, # the secret rides here, by design ) assert set(payload) <= KNOWN_REQUEST_KEYS - assert payload["provider"] == "openai" - assert payload["credentialMode"] == "env" - assert payload["deployment"] == "custom" - assert payload["endpoint"] == {"baseUrl": "https://gw.example/v1"} - # Exactly one `model` key, and it is the resolved exact model (last spread wins). - assert payload["model"] == "gpt-5.5-2026" - # The secret only rides `secrets`; `env` is never serialized onto the wire. - assert payload["secrets"] == {"OPENAI_API_KEY": "sk-secret"} - assert "env" not in payload - assert ( - "sk-secret" not in {k: v for k, v in payload.items() if k != "secrets"}.values() + assert payload["model"] == "openai/gpt-5.5-2026" + assert payload["modelConnection"] == { + "provider": "openai", + "deployment": "custom", + "credentialMode": "env", + "credentials": [ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-secret", + "usage": "opaque_http", + } + ], + "endpoint": {"baseUrl": "https://gw.example/v1"}, + } + for removed in ( + "secrets", + "provider", + "connection", + "deployment", + "endpoint", + "credentialMode", + ): + assert removed not in payload + + +@pytest.mark.parametrize( + ("provider", "model", "expected"), + [ + ("openai", "shared-model", "openai/shared-model"), + ("openrouter", "shared-model", "openrouter/shared-model"), + ("openrouter", "meta-llama/llama-3", "openrouter/meta-llama/llama-3"), + ], +) +def test_pi_wire_model_preserves_resolved_provider(provider, model, expected): + config = PiAgentTemplate( + model=model, + resolved_connection=ResolvedConnection( + provider=provider, + model=model, + deployment="direct", + credential_mode="runtime_provided", + ), ) + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=config, + messages=[Message(role="user", content="hi")], + ) + assert payload["model"] == expected + + +def test_claude_wire_model_keeps_bare_alias(): + config = ClaudeAgentTemplate( + model="sonnet", + resolved_connection=ResolvedConnection( + provider="anthropic", + model="sonnet", + deployment="direct", + credential_mode="runtime_provided", + ), + ) + payload = request_to_wire( + harness=HarnessKind.CLAUDE, + sandbox="local", + config=config, + messages=[Message(role="user", content="hi")], + ) + assert payload["model"] == "sonnet" def test_request_to_wire_omits_resolved_connection_when_none(): @@ -626,11 +711,8 @@ def test_request_to_wire_omits_resolved_connection_when_none(): config=config, messages=[Message(role="user", content="hi")], ) - assert config.wire_resolved_connection() == {} - assert "provider" not in payload - assert "credentialMode" not in payload - assert "deployment" not in payload - assert "endpoint" not in payload + assert config.wire_model_connection() == {} + assert "modelConnection" not in payload assert payload["model"] == "gpt-5.5" @@ -901,3 +983,23 @@ def test_permission_policy_absent_from_serialized_session_config(): claude_payload = _claude_payload() assert "permissionPolicy" not in json.dumps(pi_payload) assert "permissionPolicy" not in json.dumps(claude_payload) + + +def test_result_from_wire_redacts_seeded_credential_from_output_events_and_errors(): + marker = "sk-live-marker-12345678" + redactor = Redactor().with_known_secrets([marker]) + with redaction_context(redactor): + result = result_from_wire( + { + "ok": True, + "output": f"echo {marker}", + "messages": [{"role": "assistant", "content": marker}], + "events": [{"type": "message", "content": marker}], + } + ) + assert marker not in result.output + assert marker not in repr(result.messages) + assert marker not in repr(result.events) + with pytest.raises(RuntimeError) as exc: + result_from_wire({"ok": False, "error": f"provider rejected {marker}"}) + assert marker not in str(exc.value) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py index 0142713d5c..4c7eeb9d31 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py @@ -24,8 +24,10 @@ import jsonschema import pytest +from pydantic import ValidationError from agenta.sdk.agents.wire_models import ( + WireMcpServer, WireRunRequest, WireRunResult, run_contract_schemas, @@ -121,3 +123,33 @@ def test_minimal_result_validates(): payload = {"ok": True} jsonschema.validate(payload, CATALOG_TYPES["run_result"]) assert WireRunResult.model_validate(payload).ok is True + + +def test_mcp_wire_schema_separates_public_headers_and_credentials(): + # Public headers and secret header credentials stay separate by protocol role inside the + # server's `connection` object. + server = WireMcpServer.model_validate( + { + "name": "linear", + "connection": { + "type": "http", + "url": "https://mcp.linear.app/sse", + "headers": {"X-Client": "agenta"}, + "credentials": [ + { + "binding": {"kind": "header", "name": "Authorization"}, + "value": "secret-marker", + "usage": "opaque_http", + } + ], + }, + "policy": {"tools": {"mode": "all"}}, + } + ) + assert server.connection.credentials + assert server.connection.credentials[0].binding.name == "Authorization" + # The retired flat stdio shape (mixed secret env) is not part of the schema anymore. + with pytest.raises(ValidationError): + WireMcpServer.model_validate( + {"name": "legacy", "transport": "stdio", "env": {"TOKEN": "secret"}} + ) diff --git a/services/runner/src/engines/sandbox_agent/daemon.ts b/services/runner/src/engines/sandbox_agent/daemon.ts index c118513b13..52f85e9dc4 100644 --- a/services/runner/src/engines/sandbox_agent/daemon.ts +++ b/services/runner/src/engines/sandbox_agent/daemon.ts @@ -201,16 +201,16 @@ export function inheritableProviderEnvVars( export interface BuildDaemonEnvOptions { /** - * Clear-then-apply (Security rule 5): on a MANAGED run (`credentialMode === "env"`) the - * resolved `secrets` are the sole authority, so the daemon must NOT inherit the sidecar's own - * provider keys (the caller applies only `plan.secrets`). When true, no `KNOWN_PROVIDER_ENV_VARS` - * are copied. When false (a `runtime_provided` / `none` run), the daemon keeps the inherited - * provider/auth keys so the harness's own login still works. + * Clear-then-apply (Security rule 5): on a managed (`credentialMode "env"`) or credential-less + * (`"none"`) run the resolved model environment is the sole authority, so the daemon must NOT + * inherit the sidecar's own provider keys (the caller applies only `plan.modelEnvironment`). + * When true, no `KNOWN_PROVIDER_ENV_VARS` are copied. When false (a `runtime_provided` run), + * the daemon keeps the inherited provider/auth keys so the harness's own login still works. */ clearProviderEnv?: boolean; - /** The run's resolved provider family (`request.provider`); narrows the inherited key set. */ + /** The run's resolved provider family (`request.modelConnection.provider`); narrows the inherited key set. */ provider?: string; - /** The run's resolved deployment surface (`request.deployment`); adds its cloud cred group. */ + /** The run's resolved deployment surface (`request.modelConnection.deployment`); adds its cloud cred group. */ deployment?: string; /** * Escape hatch: inherit EVERY `KNOWN_PROVIDER_ENV_VARS` entry instead of just the declared @@ -235,12 +235,14 @@ export function inheritAllProviderKeys( * launch variables and (for non-managed runs) known provider auth, not the full sidecar * environment. * - * Clear-then-apply (Security rule 5 in the provider-model-auth design): on a managed run - * (`clearProviderEnv`) this copies NONE of `KNOWN_PROVIDER_ENV_VARS`, so the only provider env - * the daemon ever sees is what the caller applies from `plan.secrets`. An inherited - * `ANTHROPIC_API_KEY` can therefore not leak into a resolved OpenAI run. For a `runtime_provided` - * / `none` run the harness uses its own login, so the inherited keys are kept — but only the ones - * the run's DECLARED provider/deployment needs (RUN-SEC-1), not every configured provider key. + * Clear-then-apply (Security rule 5 in the provider-model-auth design): on a managed (`"env"`) + * or credential-less (`"none"`) run (`clearProviderEnv`) this copies NONE of + * `KNOWN_PROVIDER_ENV_VARS`, so the only provider env the daemon ever sees is what the caller + * applies from `plan.modelEnvironment`. An inherited `ANTHROPIC_API_KEY` can therefore not leak + * into a resolved OpenAI run, and a `"none"` run (which asserts no credential) inherits nothing. + * For a `runtime_provided` run the harness uses its own login, so the inherited keys are kept — + * but only the ones the run's DECLARED provider/deployment needs (RUN-SEC-1), not every + * configured provider key. */ export function buildDaemonEnv( _harness: string, @@ -274,8 +276,8 @@ export function buildDaemonEnv( for (const key of KNOWN_SANDBOX_ENV_VARS) env[key] = ""; // Managed run: clear (inherit no provider keys); the caller applies only the resolved - // `plan.secrets`. Non-managed run: keep only the DECLARED provider's own keys so its login - // works without handing the harness every other provider's credential. + // `plan.modelEnvironment`. Non-managed run: keep only the DECLARED provider's own keys so its + // login works without handing the harness every other provider's credential. if (!clearProviderEnv) { const inheritable = inheritAllProviderEnv ? KNOWN_PROVIDER_ENV_VARS diff --git a/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts b/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts new file mode 100644 index 0000000000..edcd0d2df2 --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts @@ -0,0 +1,263 @@ +import { isIP } from "node:net"; + +import type { McpServerConfig, ModelConnection } from "../../protocol.ts"; + +export interface DaytonaSecretCandidate { + ordinal: number; + consumer: { kind: "model" } | { kind: "http_mcp"; server: string }; + binding: { kind: "environment" | "header"; name: string }; + allowedHost: string; + value: string; +} + +export interface DaytonaSecretPlan { + candidates: DaytonaSecretCandidate[]; + /** Non-secret config and local-use credentials that Daytona may receive directly. */ + environment: Record; +} + +const PROHIBITED_BINDINGS = new Set([ + "AGENTA_API_KEY", + "AGENTA_AUTH_KEY", + "AGENTA_RUNNER_TOKEN", + "DAYTONA_API_KEY", + "DAYTONA_API_URL", + "OTEL_EXPORTER_OTLP_HEADERS", +]); + +// Keep this runner-side boundary aligned with the resolver-owned contract in +// sdks/python/agenta/sdk/agents/connections/endpoints.py. Environment is public config; +// every other provider value must arrive as a typed credential. +const PUBLIC_MODEL_ENVIRONMENT_BINDINGS = new Set([ + "AWS_REGION", + "AWS_DEFAULT_REGION", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_LOCATION", +]); + +// These credentials must be read locally by the provider SDK and therefore cannot use +// Daytona's outbound HTTP substitution. No opaque provider key belongs in this allowlist. +const LOCAL_USE_MODEL_CREDENTIAL_BINDINGS = new Set([ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "GOOGLE_APPLICATION_CREDENTIALS", +]); + +function fail(message: string): never { + throw new Error(`Invalid Daytona secret plan: ${message}`); +} + +function assertBinding(name: string): void { + if ( + !name || + name.includes("=") || + name.startsWith("AGENTA_") || + name.startsWith("DAYTONA_") || + PROHIBITED_BINDINGS.has(name) + ) { + fail(`credential binding '${name}' is reserved`); + } +} + +function assertPublicEnvironmentBinding(name: string): void { + assertBinding(name); + if (!PUBLIC_MODEL_ENVIRONMENT_BINDINGS.has(name)) { + fail( + `model environment binding '${name}' is not approved public config; send credentials through modelConnection.credentials`, + ); + } +} + +function assertLocalUseBinding(name: string): void { + assertBinding(name); + if (!LOCAL_USE_MODEL_CREDENTIAL_BINDINGS.has(name)) { + fail( + `local_use credential binding '${name}' is not approved for local provider-SDK use`, + ); + } +} + +/** Return the exact HTTPS hostname accepted by Daytona's outbound Secret restriction. */ +export function exactHttpsHost(rawUrl: string): string { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return fail("credential endpoint is not a valid URL"); + } + if (url.protocol !== "https:") fail("credential endpoint must use HTTPS"); + if (url.username || url.password || url.hash) { + fail("credential endpoint contains prohibited URL components"); + } + if (url.port && url.port !== "443") { + fail("explicit non-default ports are not supported"); + } + const host = url.hostname.toLowerCase().replace(/\.$/, ""); + const ipCandidate = + host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; + if ( + !host || + host === "localhost" || + host.endsWith(".localhost") || + host.endsWith(".local") || + host.endsWith(".internal") || + host.endsWith(".home") || + host.endsWith(".lan") || + host.includes("*") + ) { + fail("credential endpoint host is prohibited"); + } + // Daytona host restrictions are DNS names. Reject every literal, including public IPv4 and + // bracketed IPv6, so an author cannot bypass hostname-scoped substitution with a raw address. + if (isIP(ipCandidate) !== 0) { + fail( + "credential endpoint must use a public DNS hostname, not an IP literal", + ); + } + const labels = host.split("."); + if ( + host.length > 253 || + labels.length < 2 || + labels.some( + (label) => + label.length === 0 || + label.length > 63 || + !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label), + ) + ) { + fail("credential endpoint must use a normalized fully qualified DNS name"); + } + return host; +} + +/** Split remote opaque credentials from values that remain safe to pass directly at create. */ +export function buildDaytonaSecretPlan(input: { + modelConnection?: ModelConnection; + mcpServers?: McpServerConfig[]; +}): DaytonaSecretPlan { + const environment: Record = {}; + const candidates: DaytonaSecretCandidate[] = []; + const seen = new Set(); + const directBindings = new Set(); + + for (const [name, value] of Object.entries( + input.modelConnection?.environment ?? {}, + )) { + assertPublicEnvironmentBinding(name); + if (!value) fail(`model environment binding '${name}' is empty`); + const normalized = name.toLowerCase(); + if (directBindings.has(normalized)) { + fail(`duplicate direct environment binding '${name}'`); + } + directBindings.add(normalized); + environment[name] = value; + } + + const add = (candidate: Omit): void => { + assertBinding(candidate.binding.name); + const consumerKey = + candidate.consumer.kind === "model" ? "model" : candidate.consumer.server; + const key = `${candidate.consumer.kind}:${consumerKey}:${candidate.binding.kind}:${candidate.binding.name.toLowerCase()}`; + if (seen.has(key)) { + fail(`duplicate credential binding '${candidate.binding.name}'`); + } + seen.add(key); + candidates.push({ ...candidate, ordinal: candidates.length }); + }; + + const connection = input.modelConnection; + if (connection) { + const opaqueCredentials = (connection.credentials ?? []).filter( + (credential) => credential.usage === "opaque_http", + ); + const host = + opaqueCredentials.length > 0 && connection.endpoint?.baseUrl + ? exactHttpsHost(connection.endpoint.baseUrl) + : undefined; + for (const credential of connection.credentials ?? []) { + if (credential.usage === "local_use") { + assertLocalUseBinding(credential.binding.name); + const normalized = credential.binding.name.toLowerCase(); + if (directBindings.has(normalized)) { + fail( + `duplicate direct environment binding '${credential.binding.name}'`, + ); + } + directBindings.add(normalized); + environment[credential.binding.name] = credential.value; + continue; + } + if (!host) { + fail( + "opaque model credentials require endpoint.baseUrl for exact-host restriction", + ); + } + add({ + consumer: { kind: "model" }, + binding: credential.binding, + allowedHost: host, + value: credential.value, + }); + } + } + + for (const server of input.mcpServers ?? []) { + const headers = server.connection?.headers ?? {}; + const credentials = server.connection?.credentials ?? []; + const hasHeaders = + Object.keys(headers).length > 0 || credentials.length > 0; + if (hasHeaders && !server.connection?.url) { + fail(`headers on MCP server '${server.name}' require a URL`); + } + const host = hasHeaders + ? exactHttpsHost(server.connection!.url) + : undefined; + for (const [name, value] of Object.entries(headers)) { + if (!name.trim() || !value) { + fail( + `HTTP MCP header on server '${server.name}' requires a non-empty name and value`, + ); + } + add({ + consumer: { kind: "http_mcp", server: server.name }, + binding: { kind: "header", name }, + allowedHost: host!, + value, + }); + } + for (const credential of credentials) { + if (credential.usage !== "opaque_http") { + fail("HTTP MCP credentials must use opaque_http"); + } + add({ + consumer: { kind: "http_mcp", server: server.name }, + binding: credential.binding, + allowedHost: host!, + value: credential.value, + }); + } + } + + return { candidates, environment }; +} + +export function daytonaOpaqueSecretsEnabled( + value: string | undefined = process.env.AGENTA_DAYTONA_OPAQUE_SECRETS, +): boolean { + return value === "process_local"; +} + +export function assertDaytonaOpaqueSecretsEnabled( + plan: DaytonaSecretPlan, + value?: string, +): void { + if (plan.candidates.length > 0 && !daytonaOpaqueSecretsEnabled(value)) { + throw new Error( + "Daytona opaque credentials are disabled. Set " + + "AGENTA_DAYTONA_OPAQUE_SECRETS=process_local to enable process-local Secret cleanup; " + + "plaintext fallback is not allowed.", + ); + } +} diff --git a/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts b/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts new file mode 100644 index 0000000000..a05ae5e087 --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts @@ -0,0 +1,364 @@ +import { DaytonaNotFoundError } from "@daytonaio/sdk"; + +import type { McpServerConfig } from "../../protocol.ts"; +import { DaytonaReconnectTerminalError } from "./daytona-provider.ts"; +import type { DaytonaSecretPlan } from "./daytona-secret-plan.ts"; +import { + allocateDaytonaSecrets, + deleteDaytonaSecrets, + type DaytonaSecretAllocation, + type DaytonaSecretApi, +} from "./daytona-secrets.ts"; + +export interface DaytonaProviderLike { + name: string; + create(...args: unknown[]): Promise; + destroy(sandboxId: string): Promise; + reconnect?(sandboxId: string): Promise; + pause?(sandboxId: string): Promise; +} + +interface RegistryEntry { + allocation: DaytonaSecretAllocation; + plan: DaytonaSecretPlan; + createFingerprint: string; + generation: number; + operation: Promise; + cleanupTimer?: ReturnType; +} + +export interface ProcessLocalDaytonaSecretProvider extends DaytonaProviderLike { + materializeMcpServers( + servers: McpServerConfig[] | undefined, + ): McpServerConfig[] | undefined; +} + +export interface ProcessLocalSecretDependencies { + registry?: Map; + /** Hash of all create-time routing, environment, and sandbox config. */ + createFingerprint?: string; + cleanupDelayMilliseconds: number; + setCleanupTimer?: typeof setTimeout; + clearCleanupTimer?: typeof clearTimeout; + log?: (message: string) => void; +} + +const processLocalRegistry = new Map(); + +function plansMatch(entry: RegistryEntry, createFingerprint: string): boolean { + return entry.createFingerprint === createFingerprint; +} + +function isNotFound(error: unknown): boolean { + return ( + error instanceof DaytonaNotFoundError || + (typeof error === "object" && + error !== null && + "statusCode" in error && + error.statusCode === 404) + ); +} + +async function destroySandboxIdempotently( + provider: DaytonaProviderLike, + sandboxId: string, +): Promise { + try { + await provider.destroy(sandboxId); + } catch (error) { + if (!isNotFound(error)) throw error; + } +} + +/** Serialize lifecycle side effects for one sandbox allocation without poisoning later calls. */ +function serialize( + entry: RegistryEntry, + operation: () => Promise, +): Promise { + const result = entry.operation.catch(() => {}).then(operation); + entry.operation = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +function withMcpPlaceholders( + servers: McpServerConfig[] | undefined, + allocation: DaytonaSecretAllocation | undefined, +): McpServerConfig[] | undefined { + if (!allocation || !servers) return servers; + return servers.map((server) => { + const placeholders = allocation.mcpHeaderPlaceholders[server.name]; + const headers = server.connection?.headers; + const credentials = server.connection?.credentials; + if ( + Object.keys(headers ?? {}).length === 0 && + (credentials?.length ?? 0) === 0 + ) { + return server; + } + if (!placeholders) { + throw new Error( + `Daytona Secret allocation is missing MCP placeholders for '${server.name}'.`, + ); + } + return { + ...server, + connection: { + ...server.connection, + headers: headers + ? Object.fromEntries( + Object.keys(headers).map((name) => { + const placeholder = placeholders[name]; + if (!placeholder) { + throw new Error( + `Daytona Secret allocation is missing MCP placeholder '${name}'.`, + ); + } + return [name, placeholder]; + }), + ) + : undefined, + credentials: credentials?.map((credential) => { + const placeholder = placeholders[credential.binding.name]; + if (!placeholder) { + throw new Error( + `Daytona Secret allocation is missing MCP placeholder '${credential.binding.name}'.`, + ); + } + return { ...credential, value: placeholder }; + }), + }, + }; + }); +} + +/** + * Wrap Daytona provisioning with process-local Secret allocation. + * + * A parked sandbox and its allocation live in the registry together. Secret ownership is + * process-local BY DESIGN: the registry dies with the runner process, so a hard crash can + * orphan a Daytona Secret (until Daytona's auto-delete backstop reaps the sandbox). This is an + * accepted limit of the process_local mode, not a bug to patch here — durable reconciliation + * is an explicit follow-up (PR B of the Daytona-Secrets design; see PR #5278). + */ +export function daytonaWithProcessLocalSecrets( + buildProvider: (attachments: Record) => T, + plan: DaytonaSecretPlan, + api: DaytonaSecretApi, + dependencies: ProcessLocalSecretDependencies, +): T & ProcessLocalDaytonaSecretProvider { + const registry = dependencies.registry ?? processLocalRegistry; + const schedule = dependencies.setCleanupTimer ?? setTimeout; + const cancel = dependencies.clearCleanupTimer ?? clearTimeout; + const log = dependencies.log ?? (() => {}); + const createFingerprint = + dependencies.createFingerprint ?? JSON.stringify(plan); + let provider: T | undefined; + let currentAllocation: DaytonaSecretAllocation | undefined; + + const providerFor = (attachments: Record): T => { + provider ??= buildProvider(attachments); + return provider; + }; + + const cleanupAfterSandbox = async ( + sandboxId: string, + entry: RegistryEntry, + activeProvider: T, + ): Promise => { + // A Secret remains mounted until Daytona confirms the sandbox is absent. Never reverse this + // order, including timer cleanup and create compensation after an id was returned. + await destroySandboxIdempotently(activeProvider, sandboxId); + await deleteDaytonaSecrets(entry.allocation, api); + if (registry.get(sandboxId) === entry) registry.delete(sandboxId); + if (currentAllocation === entry.allocation) currentAllocation = undefined; + }; + + const facade: ProcessLocalDaytonaSecretProvider = { + name: "daytona", + async create(...args: unknown[]): Promise { + const allocation = await allocateDaytonaSecrets(plan, api); + try { + provider = buildProvider(allocation.attachments); + } catch (cause) { + // buildProvider is synchronous and failed before any remote create call, so absence is + // proven and compensation may safely remove the newly allocated Secrets. + try { + await deleteDaytonaSecrets(allocation, api); + } catch (cleanupError) { + throw new AggregateError( + [cause, cleanupError], + "Daytona provider construction failed and Secret cleanup was incomplete.", + ); + } + throw cause; + } + try { + const sandboxId = await provider.create(...args); + const entry: RegistryEntry = { + allocation, + plan, + createFingerprint, + generation: 0, + operation: Promise.resolve(), + }; + registry.set(sandboxId, entry); + currentAllocation = allocation; + return sandboxId; + } catch (cause) { + // The vendored provider creates the remote sandbox before it starts the daemon and only + // returns the id after both succeed. A rejection therefore cannot prove remote absence. + // Retain Secrets rather than deleting records that a partially-created sandbox may mount. + if (allocation.created.length > 0) { + log( + "Daytona create failed before remote absence could be confirmed; retaining " + + `${allocation.created.length} Secret allocation(s) for safety.`, + ); + } + throw cause; + } + }, + async reconnect(sandboxId: string): Promise { + const entry = registry.get(sandboxId); + const activeProvider = providerFor({}); + if (!entry) { + // The runner restarted or lost ownership. It cannot prove which Secrets back the parked + // sandbox, so delete the sandbox and force the caller onto a fresh create. + await destroySandboxIdempotently(activeProvider, sandboxId); + throw new DaytonaReconnectTerminalError( + sandboxId, + "missing-process-local-secret-allocation", + ); + } + if (entry.cleanupTimer) { + cancel(entry.cleanupTimer); + entry.cleanupTimer = undefined; + } + // Invalidate a timer callback that fired but has not entered its serialized operation yet. + // If cleanup already owns the operation, reconnect waits and observes the deleted entry. + entry.generation += 1; + await serialize(entry, async () => { + if (registry.get(sandboxId) !== entry) { + throw new DaytonaReconnectTerminalError( + sandboxId, + "missing-process-local-secret-allocation", + ); + } + if (!plansMatch(entry, createFingerprint)) { + await cleanupAfterSandbox(sandboxId, entry, activeProvider); + throw new DaytonaReconnectTerminalError( + sandboxId, + "process-local-secret-allocation-mismatch", + ); + } + currentAllocation = entry.allocation; + try { + await activeProvider.reconnect?.(sandboxId); + } catch (cause) { + try { + await cleanupAfterSandbox(sandboxId, entry, activeProvider); + } catch (cleanupError) { + throw new AggregateError( + [cause, cleanupError], + "Daytona reconnect failed and process-local cleanup was incomplete.", + ); + } + throw cause; + } + }); + }, + async pause(sandboxId: string): Promise { + const activeProvider = providerFor({}); + const entry = registry.get(sandboxId); + if (!entry) { + await activeProvider.pause?.(sandboxId); + return; + } + if (entry.cleanupTimer) cancel(entry.cleanupTimer); + entry.cleanupTimer = undefined; + entry.generation += 1; + await serialize(entry, async () => { + if (registry.get(sandboxId) !== entry) return; + await activeProvider.pause?.(sandboxId); + const scheduledGeneration = entry.generation; + entry.cleanupTimer = schedule(() => { + void serialize(entry, async () => { + if ( + registry.get(sandboxId) !== entry || + entry.generation !== scheduledGeneration + ) { + return; + } + entry.cleanupTimer = undefined; + await cleanupAfterSandbox(sandboxId, entry, activeProvider); + }).catch((error) => { + log( + `process-local Daytona Secret cleanup failed sandbox=${sandboxId}: ${String( + error instanceof Error ? error.message : error, + ).slice(0, 200)}`, + ); + }); + }, dependencies.cleanupDelayMilliseconds); + entry.cleanupTimer.unref?.(); + }); + }, + async destroy(sandboxId: string): Promise { + const activeProvider = providerFor({}); + const entry = registry.get(sandboxId); + if (!entry) { + await destroySandboxIdempotently(activeProvider, sandboxId); + return; + } + if (entry.cleanupTimer) { + cancel(entry.cleanupTimer); + entry.cleanupTimer = undefined; + } + entry.generation += 1; + await serialize(entry, async () => { + if (registry.get(sandboxId) !== entry) return; + await cleanupAfterSandbox(sandboxId, entry, activeProvider); + }); + }, + materializeMcpServers(servers) { + if ( + !currentAllocation && + plan.candidates.some( + (candidate) => candidate.consumer.kind === "http_mcp", + ) + ) { + throw new Error( + "Daytona MCP credentials cannot be materialized without the process-local Secret allocation.", + ); + } + return withMcpPlaceholders(servers, currentAllocation); + }, + }; + + return new Proxy(facade as T & ProcessLocalDaytonaSecretProvider, { + get(target, property, receiver) { + if (Reflect.has(target, property)) { + return Reflect.get(target, property, receiver); + } + const activeProvider = providerFor({}); + const value = Reflect.get(activeProvider, property); + return typeof value === "function" ? value.bind(activeProvider) : value; + }, + }); +} + +export function materializeDaytonaMcpServers( + provider: unknown, + servers: McpServerConfig[] | undefined, +): McpServerConfig[] | undefined { + if ( + typeof provider === "object" && + provider !== null && + "materializeMcpServers" in provider && + typeof provider.materializeMcpServers === "function" + ) { + return provider.materializeMcpServers(servers); + } + return servers; +} diff --git a/services/runner/src/engines/sandbox_agent/daytona-secrets.ts b/services/runner/src/engines/sandbox_agent/daytona-secrets.ts new file mode 100644 index 0000000000..4780b6176b --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/daytona-secrets.ts @@ -0,0 +1,153 @@ +import { randomBytes } from "node:crypto"; + +import type { + DaytonaSecretCandidate, + DaytonaSecretPlan, +} from "./daytona-secret-plan.ts"; + +export interface DaytonaSecretRecord { + id: string; + name: string; + placeholder: string; + hosts?: string[]; +} + +export interface DaytonaSecretApi { + create(input: { + name: string; + value: string; + description?: string; + hosts: string[]; + }): Promise; + delete(id: string): Promise; +} + +export interface DaytonaSecretAllocation { + attachments: Record; + mcpHeaderPlaceholders: Record>; + created: DaytonaSecretRecord[]; +} + +function isNotFound(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "statusCode" in error && + error.statusCode === 404 + ); +} + +async function deleteIdempotently( + api: DaytonaSecretApi, + id: string, +): Promise { + try { + await api.delete(id); + } catch (error) { + if (!isNotFound(error)) throw error; + } +} + +function assertCreatedSecret( + secret: DaytonaSecretRecord, + expectedName: string, + candidate: DaytonaSecretCandidate, +): DaytonaSecretRecord { + if (secret.name !== expectedName) { + throw new Error("Daytona Secret has an unexpected generated name."); + } + if ( + !secret.hosts || + secret.hosts.length !== 1 || + secret.hosts[0] !== candidate.allowedHost + ) { + throw new Error("Daytona Secret has an unexpected host restriction."); + } + if ( + !secret.id || + !secret.placeholder || + !secret.placeholder.startsWith("dtn_secret_") || + secret.placeholder === candidate.value + ) { + throw new Error( + "Daytona did not return a valid opaque Secret placeholder.", + ); + } + return secret; +} + +function generatedName(candidate: DaytonaSecretCandidate): string { + return `agenta_${randomBytes(18).toString("hex")}_${candidate.ordinal}`; +} + +/** Allocate every Secret before sandbox create, compensating in reverse order on any failure. */ +export async function allocateDaytonaSecrets( + plan: DaytonaSecretPlan, + api: DaytonaSecretApi, + nameFor: (candidate: DaytonaSecretCandidate) => string = generatedName, +): Promise { + const created: DaytonaSecretRecord[] = []; + const attachments: Record = {}; + const mcpHeaderPlaceholders: Record> = {}; + try { + for (const candidate of plan.candidates) { + const name = nameFor(candidate); + const rawSecret = await api.create({ + name, + value: candidate.value, + description: "Agenta process-local sandbox credential", + hosts: [candidate.allowedHost], + }); + // Track the provider record before validating returned metadata. If the provider returns a + // malformed placeholder or host list, compensation must still delete the record it made. + if (rawSecret.id) created.push(rawSecret); + const secret = assertCreatedSecret(rawSecret, name, candidate); + if (candidate.consumer.kind === "model") { + attachments[candidate.binding.name] = secret.name; + } else { + attachments[`AGENTA_MCP_SECRET_${candidate.ordinal}`] = secret.name; + (mcpHeaderPlaceholders[candidate.consumer.server] ??= {})[ + candidate.binding.name + ] = secret.placeholder; + } + } + return { attachments, mcpHeaderPlaceholders, created }; + } catch (cause) { + const cleanupFailures: unknown[] = []; + for (const secret of [...created].reverse()) { + try { + await deleteIdempotently(api, secret.id); + } catch (error) { + cleanupFailures.push(error); + } + } + if (cleanupFailures.length > 0) { + throw new AggregateError( + [cause, ...cleanupFailures], + "Daytona Secret allocation failed and compensation was incomplete.", + ); + } + throw cause; + } +} + +/** Delete one allocation in reverse creation order. Missing provider records are success. */ +export async function deleteDaytonaSecrets( + allocation: DaytonaSecretAllocation, + api: DaytonaSecretApi, +): Promise { + const failures: unknown[] = []; + for (const secret of [...allocation.created].reverse()) { + try { + await deleteIdempotently(api, secret.id); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) { + throw new AggregateError( + failures, + "Daytona Secret cleanup was incomplete.", + ); + } +} diff --git a/services/runner/src/engines/sandbox_agent/daytona.ts b/services/runner/src/engines/sandbox_agent/daytona.ts index 1746236914..d4fb4c6e9e 100644 --- a/services/runner/src/engines/sandbox_agent/daytona.ts +++ b/services/runner/src/engines/sandbox_agent/daytona.ts @@ -34,7 +34,7 @@ export const DAYTONA_PI_COMMAND = `${DAYTONA_PI_INSTALL_DIR}/node_modules/.bin/p */ export function daytonaEnvVars( piExtEnv: Record, - secrets: Record, + environment: Record, ): Record { return { PI_CODING_AGENT_DIR: DAYTONA_PI_DIR, @@ -42,8 +42,9 @@ export function daytonaEnvVars( // 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, + // Non-secret config and explicitly local-use values. Opaque HTTP credentials attach through + // Daytona's `secrets` create field and never enter this plaintext environment map. + ...environment, }; } diff --git a/services/runner/src/engines/sandbox_agent/environment-setup.ts b/services/runner/src/engines/sandbox_agent/environment-setup.ts index 6e2a9d7c6f..b56417348c 100644 --- a/services/runner/src/engines/sandbox_agent/environment-setup.ts +++ b/services/runner/src/engines/sandbox_agent/environment-setup.ts @@ -19,6 +19,7 @@ import { signSessionMountCredentials, type MountCredentials, } from "./mount.ts"; +import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../extensions/model-provider-override.ts"; import { buildPiExtensionEnv, configurePiSessionWorkspace, @@ -164,16 +165,19 @@ export async function prepareEnvironmentSetup( const agentMountDir = agentMountCreds ? agentMountPath(plan.cwd) : undefined; // Clear-then-apply (Security rule 5): on a managed run (credentialMode "env") the daemon - // inherits NONE of the sidecar's own provider keys, so only the resolved `plan.secrets` are - // present and an inherited key for another provider cannot leak. For runtime_provided/none/ - // un-migrated runs the harness uses its own login, so the inherited keys stay. - const clearProviderEnv = plan.credentialMode === "env"; + // inherits NONE of the sidecar's own provider keys, so only the resolved + // `plan.modelEnvironment` is present and an inherited key for another provider cannot leak. + // "none" asserts NO credential (connections/models.py), so it clears too — otherwise the + // daemon would inherit the declared provider's keys (e.g. OPENAI_API_KEY) from the sidecar. + // Only runtime_provided keeps the inherited keys: the harness uses its own login there. + const clearProviderEnv = + plan.credentialMode === "env" || plan.credentialMode === "none"; const env = (deps.buildDaemonEnv ?? buildDaemonEnv)(plan.acpAgent, { clearProviderEnv, - provider: request.provider, - deployment: request.deployment, + provider: request.modelConnection?.provider, + deployment: request.modelConnection?.deployment, }); - Object.assign(env, plan.secrets); // apply only the resolved provider keys + Object.assign(env, plan.modelEnvironment); // apply only the resolved provider keys applyClaudeConnectionEnv(env, request, plan.acpAgent, logger); const piSessionDir = configurePiSessionWorkspace(plan, env); configurePiSkillSnapshot(piSkillSnapshot, env); @@ -233,7 +237,18 @@ export async function prepareEnvironmentSetup( let piModelConfigError: Error | undefined; if (plan.isPi) { try { - piModelConfig = buildPiModelConfigPlan(request, plan.secrets); + // The presence check consults the FULL materialized model environment: on a Daytona + // Secrets run the opaque key left `plan.modelEnvironment` for the secret plan, but the + // sandbox still receives its binding (as a Daytona Secret attachment). + const fullModelEnvironment: Record = { + ...plan.modelEnvironment, + }; + for (const candidate of plan.daytonaSecretPlan?.candidates ?? []) { + if (candidate.consumer.kind === "model") { + fullModelEnvironment[candidate.binding.name] = candidate.value; + } + } + piModelConfig = buildPiModelConfigPlan(request, fullModelEnvironment); } catch (err) { piModelConfigError = err as Error; } @@ -272,6 +287,15 @@ export async function prepareEnvironmentSetup( !plan.isDaytona && plan.builtinGatingActive && !localPiAssets.extensionInstalled; + // Fail closed: a Pi run whose provider routing rides the extension's model endpoint override + // (`model-provider-override.ts`, set in `buildPiExtensionEnv`) cannot run without the + // extension — the harness would silently call the provider's default endpoint. Recorded here + // and thrown inside the engine try, like the two gates above. + const localModelOverrideUnenforceable = + plan.isPi && + !plan.isDaytona && + piExtEnv[PI_MODEL_PROVIDER_OVERRIDE_ENV] !== undefined && + !localPiAssets.extensionInstalled; // A local Claude subscription run reads and writes the operator's read-write mounted login // DIRECTLY: `buildDaemonEnv` already carried `CLAUDE_CONFIG_DIR` (the mount) into the daemon env, @@ -286,10 +310,13 @@ export async function prepareEnvironmentSetup( // The resolved model ref as it reaches the runner (key NAMES only, never values) — the one // line that answers "what model/provider/deployment/credential did this run actually use". logger( - `resolved model=${request.model ?? ""} provider=${request.provider ?? ""} ` + - `deployment=${request.deployment ?? ""} ` + + `resolved model=${request.model ?? ""} provider=${request.modelConnection?.provider ?? ""} ` + + `deployment=${request.modelConnection?.deployment ?? ""} ` + `connection=${request.connection ? `${request.connection.mode}:${request.connection.slug ?? "-"}` : ""} ` + - `secretKeys=[${Object.keys(request.secrets ?? {}).join(",")}]`, + `credentialMode=${request.modelConnection?.credentialMode ?? ""} ` + + `credentialBindings=[${(request.modelConnection?.credentials ?? []) + .map((credential) => credential.binding.name) + .join(",")}]`, ); // The shared client-tool relay reference (the deferred ref baked into the MCP server reads it; @@ -372,6 +399,7 @@ export async function prepareEnvironmentSetup( localBuiltinGatingUnenforceable, logger, localModelConfigUnwritable, + localModelOverrideUnenforceable, mcpAbort, piExtEnv, piModelConfig, diff --git a/services/runner/src/engines/sandbox_agent/environment.ts b/services/runner/src/engines/sandbox_agent/environment.ts index 75e86424b7..5c0531f553 100644 --- a/services/runner/src/engines/sandbox_agent/environment.ts +++ b/services/runner/src/engines/sandbox_agent/environment.ts @@ -59,7 +59,12 @@ import { prepareDaytonaPiAssets, } from "./daytona.ts"; import { conciseError } from "./errors.ts"; -import { buildSessionMcpServers } from "./mcp.ts"; +import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../extensions/model-provider-override.ts"; +import { materializeDaytonaMcpServers } from "./daytona-secret-provider.ts"; +import { + buildSessionMcpServers, + validateUserMcpServers, +} from "./mcp.ts"; import { applyModel } from "./model.ts"; import { discoverTunnelEndpoint, @@ -74,6 +79,7 @@ import { } from "./mount.ts"; import { PI_MODEL_CONFIG_WRITE_FAILED_MESSAGE, + PI_MODEL_OVERRIDE_EXTENSION_UNAVAILABLE_MESSAGE, PI_PERMISSION_EXTENSION_UNAVAILABLE_MESSAGE, prepareLocalPiAssets, uploadSystemPromptToSandbox, @@ -264,6 +270,7 @@ export async function acquireEnvironment( environment, localBuiltinGatingUnenforceable, localModelConfigUnwritable, + localModelOverrideUnenforceable, logger, mcpAbort, piExtEnv, @@ -583,6 +590,15 @@ export async function acquireEnvironment( if (localModelConfigUnwritable) { throw new Error(PI_MODEL_CONFIG_WRITE_FAILED_MESSAGE); } + // Fail closed: a Pi run that routes its provider through the extension's model endpoint + // override cannot run without the extension — the model would silently hit the default + // endpoint with the wrong credentials. + if (localModelOverrideUnenforceable) { + throw new Error(PI_MODEL_OVERRIDE_EXTENSION_UNAVAILABLE_MESSAGE); + } + // Structural + SSRF validation of user MCP servers BEFORE any sandbox (or Daytona Secret) is + // created, so an invalid credentialed server never triggers remote side effects. + await validateUserMcpServers(request.mcpServers); // Persist events in-process so a follow-up turn can resume by session id. const persist = deps.createPersist?.() ?? new InMemorySessionPersistDriver(); @@ -604,8 +620,9 @@ export async function acquireEnvironment( env, binaryPath, piExtEnv, - plan.secrets, + plan.modelEnvironment, plan.sandboxPermission, + plan.daytonaSecretPlan, ); const startOptions = { sandbox: sandboxProvider, @@ -693,6 +710,15 @@ export async function acquireEnvironment( if (plan.isPi && plan.builtinGatingActive && !daytonaExtensionInstalled) { throw new Error(PI_PERMISSION_EXTENSION_UNAVAILABLE_MESSAGE); } + // Fail closed: the Pi model endpoint override rides the extension; without it the model + // would silently hit the default endpoint with the wrong credentials. + if ( + plan.isPi && + piExtEnv[PI_MODEL_PROVIDER_OVERRIDE_ENV] !== undefined && + !daytonaExtensionInstalled + ) { + throw new Error(PI_MODEL_OVERRIDE_EXTENSION_UNAVAILABLE_MESSAGE); + } if (!plan.isPi && plan.toolSpecs.length > 0) { // Advertise the FULL tool set to the shim, client tools included: a parked client tool // resolves through the relay's paused answer (see startToolRelay / tool-mcp-stdio.ts). @@ -895,7 +921,12 @@ export async function acquireEnvironment( harness: plan.harness, isDaytona: plan.isDaytona, toolSpecs: plan.toolSpecs, - userMcpServers: request.mcpServers, + // On a Daytona Secrets run the provider swaps each MCP credential value for its Daytona + // Secret placeholder, so no plaintext secret rides the sandbox-bound session config. + userMcpServers: materializeDaytonaMcpServers( + sandboxProvider, + request.mcpServers, + ), relayDir: plan.relayDir, clientToolRelay: deferredClientToolRelay, signal: mcpAbort.signal, @@ -1057,7 +1088,11 @@ export async function acquireEnvironment( timingLog("acquire_total", acquireStartedAt); return { ok: true, env: environment }; } catch (err) { - const error = conciseError(err, plan.harness, request.provider); + const error = conciseError( + err, + plan.harness, + request.modelConnection?.provider, + ); // Mirror today's shared teardown: no otel exists yet during acquire, so there is no partial // trace to flush — just run the incrementally-registered finalizers and surface the error. await environment.destroy({ reason: "failed-turn" }); diff --git a/services/runner/src/engines/sandbox_agent/errors.ts b/services/runner/src/engines/sandbox_agent/errors.ts index ff3f1ca160..ea1e55def4 100644 --- a/services/runner/src/engines/sandbox_agent/errors.ts +++ b/services/runner/src/engines/sandbox_agent/errors.ts @@ -17,8 +17,8 @@ const PROVIDER_KEY_LABELS: Record = { * harness name (`pi_core`/`claude`) is not the provider, so deriving the hint from it mislabels * every cross-provider run (e.g. Pi + Anthropic wrongly read "check the project's OpenAI key"). * - * `provider` is the resolved provider the runner already knows (`request.provider`, from the - * resolved connection). When it is absent (un-migrated caller) fall back to the harness default + * `provider` is the resolved provider the runner already knows (`request.modelConnection.provider`, from the + * resolved connection). When it is absent, fall back to the harness default * — Claude is always Anthropic; every other harness defaults to OpenAI, matching the old * behavior for that path only. */ diff --git a/services/runner/src/engines/sandbox_agent/mcp.ts b/services/runner/src/engines/sandbox_agent/mcp.ts index dc4af26917..c56d4c4136 100644 --- a/services/runner/src/engines/sandbox_agent/mcp.ts +++ b/services/runner/src/engines/sandbox_agent/mcp.ts @@ -192,7 +192,58 @@ export async function validateUserMcpUrl( return undefined; } -/** Convert external HTTP MCP servers into Claude ACP session entries. */ +/** + * Structural validation of user-declared HTTP MCP servers: public headers and typed secret + * header credentials must be well-formed (non-empty names and values, `header` bindings with + * `opaque_http` usage, no duplicate header name across the public and secret sets), and the + * URL must pass the SSRF guard. Called early in acquire — BEFORE any sandbox or Daytona Secret + * is created — and again per server at ACP materialization (`toAcpMcpServers`). + */ +export async function validateUserMcpServers( + servers: McpServerConfig[] | undefined, +): Promise { + for (const server of servers ?? []) { + const url = server.connection?.url; + if (!url) throw new Error("http MCP server requires url"); + const names = new Set(); + for (const [name, value] of Object.entries( + server.connection.headers ?? {}, + )) { + if (!name.trim() || !value) { + throw new Error("HTTP MCP headers require non-empty names and values"); + } + names.add(name.toLowerCase()); + } + for (const credential of server.connection.credentials ?? []) { + const name = credential?.binding?.name; + if ( + credential?.binding?.kind !== "header" || + !name?.trim() || + !credential.value || + credential.usage !== "opaque_http" + ) { + throw new Error( + "HTTP MCP credential binding, value, or usage is invalid", + ); + } + const normalized = name.toLowerCase(); + if (names.has(normalized)) { + throw new Error(`duplicate HTTP MCP header binding '${name}'`); + } + names.add(normalized); + } + const urlError = await validateUserMcpUrl(url); + if (urlError) throw new Error(urlError); + } +} + +/** + * Convert external HTTP MCP servers into Claude ACP session entries. Public headers and typed + * secret header credentials stay separate until this final ACP materialization boundary, where + * each credential is emitted as a request header. On a Daytona run the caller has already + * swapped credential values for Daytona Secret placeholders (`materializeDaytonaMcpServers`), + * so no plaintext secret reaches the sandbox creation request there. + */ export async function toAcpMcpServers( servers: McpServerConfig[] | undefined, log: Log = () => {}, @@ -204,15 +255,21 @@ export async function toAcpMcpServers( log(`skipping HTTP MCP server '${s?.name ?? "?"}' (no URL)`); continue; } - const urlError = await validateUserMcpUrl(url); - if (urlError) throw new Error(urlError); + await validateUserMcpServers([s]); out.push({ type: "http", name: s.name, url, - headers: Object.entries(s.connection.headers ?? {}).map( - ([name, value]) => ({ name, value }), - ), + headers: [ + ...Object.entries(s.connection.headers ?? {}).map(([name, value]) => ({ + name, + value, + })), + ...(s.connection.credentials ?? []).map((credential) => ({ + name: credential.binding.name, + value: credential.value, + })), + ], }); } return out; diff --git a/services/runner/src/engines/sandbox_agent/pi-assets.ts b/services/runner/src/engines/sandbox_agent/pi-assets.ts index 07cec962f0..d57804fa1a 100644 --- a/services/runner/src/engines/sandbox_agent/pi-assets.ts +++ b/services/runner/src/engines/sandbox_agent/pi-assets.ts @@ -16,10 +16,15 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import type { AgentRunRequest, ResolvedToolSpec } from "../../protocol.ts"; +import { + encodePiModelProviderOverride, + PI_MODEL_PROVIDER_OVERRIDE_ENV, +} from "../../extensions/model-provider-override.ts"; import { advertisedToolSpecs } from "../../tools/public-spec.ts"; import type { MaterializedSkill } from "../skills.ts"; import { PKG_ROOT } from "./daemon.ts"; import { + isPiModelConfigApplicable, PI_MODELS_JSON_FILENAME, serializePiModelsJson, type PiModelConfigPlan, @@ -286,6 +291,18 @@ export const PI_MODEL_CONFIG_WRITE_FAILED_MESSAGE = "custom provider could not be registered. The run was stopped rather than fall back to a " + "default provider. Ask your deployment operator to make the runner's Pi agent directory writable."; +/** + * Thrown (via the engine's named-message pattern) when the run routes its model provider through + * the extension's endpoint override (`model-provider-override.ts`) but the Agenta extension could + * not be installed. Fail closed: without the extension the harness would silently call the + * provider's DEFAULT endpoint with credentials resolved for the custom one. Single line so + * `conciseError` surfaces it verbatim. + */ +export const PI_MODEL_OVERRIDE_EXTENSION_UNAVAILABLE_MESSAGE = + "The agent could not apply its custom model endpoint: the Agenta extension failed to install, " + + "so the provider override could not be registered. The run was stopped rather than call the " + + "default endpoint. Ask your deployment operator to rebuild and republish the runner image."; + /** * Write the Pi `models.json` into a local (throwaway) agent dir with mode `0600` via an atomic * temp-file-plus-rename. THROWS on failure so the caller can make materialization terminal — a @@ -349,6 +366,18 @@ export function buildPiExtensionEnv( if (telemetry && opts.skills && opts.skills.length > 0) env.AGENTA_AGENT_SKILLS_LOADED = JSON.stringify(opts.skills); + // Point Pi's built-in provider at the resolved custom base URL via the Agenta extension + // (`model-provider-override.ts`). Skipped when the managed OpenAI-compatible custom path + // already routes this run through its own `models.json` provider (`pi-model-config.ts`) — + // two competing registrations for the same run would race for the provider. + const modelBaseUrl = request.modelConnection?.endpoint?.baseUrl; + if (modelBaseUrl !== undefined && !isPiModelConfigApplicable(request)) { + env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = encodePiModelProviderOverride({ + provider: request.modelConnection?.provider, + baseUrl: modelBaseUrl, + }); + } + const specs = advertisedToolSpecs( (request.customTools as ResolvedToolSpec[]) ?? [], ); diff --git a/services/runner/src/engines/sandbox_agent/pi-model-config.ts b/services/runner/src/engines/sandbox_agent/pi-model-config.ts index 61f42b58b8..bc9781a4c4 100644 --- a/services/runner/src/engines/sandbox_agent/pi-model-config.ts +++ b/services/runner/src/engines/sandbox_agent/pi-model-config.ts @@ -63,21 +63,39 @@ function isPiHarness(harness: string | undefined): boolean { } /** - * Build the Pi model-config plan from the neutral run request and the resolved secrets, or return - * `undefined` when the request is not a managed OpenAI-compatible custom Pi run (current behavior). - * - * Applicability (which KIND of run this is) — ALL must hold, else no plan: + * Applicability (which KIND of run this is) — ALL must hold, else this builder does not apply: * - the harness is Pi; - * - the provider family is "openai"; - * - the deployment is "custom"; + * - the resolved provider family is "openai"; + * - the resolved deployment is "custom"; * - the connection is a named Agenta connection (`mode === "agenta"`). * + * Exported so `buildPiExtensionEnv` can skip the generic Pi provider-override env for runs this + * models.json path already routes (two competing registrations would race for the provider). + */ +export function isPiModelConfigApplicable(request: AgentRunRequest): boolean { + return ( + isPiHarness(request.harness) && + request.modelConnection?.provider === "openai" && + request.modelConnection?.deployment === "custom" && + request.connection?.mode === "agenta" + ); +} + +/** + * Build the Pi model-config plan from the neutral run request and the materialized model + * environment, or return `undefined` when the request is not a managed OpenAI-compatible custom + * Pi run (current behavior). + * + * Applicability is `isPiModelConfigApplicable` above. + * * Completeness (the applicable run has everything it needs) — once applicable, ALL must hold or * the request is INCOMPLETE and throws `PiModelConfigError`: * - a non-empty connection slug; * - an endpoint base URL; * - credential mode "env"; - * - `OPENAI_API_KEY` present in the resolved secrets; + * - `OPENAI_API_KEY` present in the materialized model environment (`secrets` — on a Daytona + * Secrets run this includes the opaque credential BINDINGS, whose in-sandbox value is the + * Daytona placeholder); * - a model id. * * The plan holds only the env var NAME; the raw key never enters it. @@ -86,25 +104,19 @@ export function buildPiModelConfigPlan( request: AgentRunRequest, secrets: Record, ): PiModelConfigPlan | undefined { - const applicable = - isPiHarness(request.harness) && - request.provider === "openai" && - request.deployment === "custom" && - request.connection?.mode === "agenta"; - if (!applicable) return undefined; + if (!isPiModelConfigApplicable(request)) return undefined; + const credentialMode = request.modelConnection?.credentialMode; const slug = request.connection?.slug?.trim(); - const baseUrl = request.endpoint?.baseUrl?.trim(); + const baseUrl = request.modelConnection?.endpoint?.baseUrl?.trim(); const model = request.model?.trim(); const hasKey = !!secrets[OPENAI_API_KEY_ENV]?.trim(); const missing: string[] = []; if (!slug) missing.push("a connection slug"); if (!baseUrl) missing.push("an endpoint base URL"); - if (request.credentialMode !== "env") - missing.push( - `credential mode "env" (got "${request.credentialMode ?? "none"}")`, - ); + if (credentialMode !== "env") + missing.push(`credential mode "env" (got "${credentialMode ?? "none"}")`); if (!hasKey) missing.push(`${OPENAI_API_KEY_ENV} in the resolved secrets`); if (!model) missing.push("a model id"); @@ -116,13 +128,24 @@ export function buildPiModelConfigPlan( ); } + // The wire `model` may already be provider-qualified ("openai/gpt-x"). The catalog registers + // the model UNDER the slug provider, and `environment.ts` requests the fully qualified + // `/` — so strip the exact declared provider prefix here, otherwise the + // requested id becomes the double-prefixed "/openai/gpt-x" and never matches. + const declaredProvider = request.modelConnection?.provider; + const stripped = + declaredProvider && (model as string).startsWith(`${declaredProvider}/`) + ? (model as string).slice(declaredProvider.length + 1) + : (model as string); + const modelId = stripped || (model as string); + return { providerId: slug as string, providerFamily: "openai", api: "openai-completions", baseUrl: baseUrl as string, apiKeyEnv: OPENAI_API_KEY_ENV, - models: [{ id: model as string }], + models: [{ id: modelId }], }; } diff --git a/services/runner/src/engines/sandbox_agent/provider.ts b/services/runner/src/engines/sandbox_agent/provider.ts index 10f46b4c7d..57e908d902 100644 --- a/services/runner/src/engines/sandbox_agent/provider.ts +++ b/services/runner/src/engines/sandbox_agent/provider.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { local } from "sandbox-agent/local"; import type { SandboxPermission } from "../../protocol.ts"; @@ -15,6 +17,11 @@ import { buildDaytonaClient, daytonaWithLifecycle, } from "./daytona-provider.ts"; +import { daytonaWithProcessLocalSecrets } from "./daytona-secret-provider.ts"; +import { + assertDaytonaOpaqueSecretsEnabled, + type DaytonaSecretPlan, +} from "./daytona-secret-plan.ts"; /** * Translate the Layer 2 network policy into Daytona create fields. Daytona enforces egress @@ -57,8 +64,9 @@ export function daytonaNetworkFields( export function buildDaytonaCreate( daytona: RunnerDaytonaConfig, piExtEnv: Record, - secrets: Record, + environment: Record, sandboxPermission: SandboxPermission | undefined, + secretAttachments: Record = {}, ): Record { const snapshot = daytona.image ? undefined @@ -71,7 +79,10 @@ export function buildDaytonaCreate( ...(snapshot ? { snapshot, image: undefined } : {}), ...(target ? { target } : {}), ...daytonaNetworkFields(sandboxPermission), - envVars: daytonaEnvVars(piExtEnv, secrets), + envVars: daytonaEnvVars(piExtEnv, environment), + ...(Object.keys(secretAttachments).length > 0 + ? { secrets: secretAttachments } + : {}), // `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. @@ -81,6 +92,28 @@ export function buildDaytonaCreate( }; } +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + return `{${entries + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(",")}}`; +} + +/** Opaque comparison key for every field baked into a parked Daytona sandbox at create time. */ +export function daytonaCreateFingerprint(input: { + image?: string; + create: Record; + secretPlan: DaytonaSecretPlan; +}): string { + return createHash("sha256").update(canonicalJson(input)).digest("hex"); +} + /** Recognized ids that are planned but not yet provisionable (fail with a specific message). */ export const PLANNED_SANDBOX_IDS = ["e2b"] as const; @@ -99,8 +132,9 @@ export function buildSandboxProvider( env: Record, binaryPath: string | undefined, piExtEnv: Record, - secrets: Record, + modelEnvironment: Record, sandboxPermission?: SandboxPermission, + daytonaSecretPlan?: DaytonaSecretPlan, config: RunnerConfig = loadRunnerConfig(), ) { if ( @@ -118,18 +152,63 @@ export function buildSandboxProvider( // `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) }, + const createFields = buildDaytonaCreate( + config.daytona, + piExtEnv, + modelEnvironment, + sandboxPermission, ); + const buildDaytona = (secretAttachments: Record) => + daytonaWithLifecycle( + { + ...(image ? { image } : {}), + create: { + ...createFields, + ...(Object.keys(secretAttachments).length > 0 + ? { secrets: secretAttachments } + : {}), + } as any, + }, + { client: buildDaytonaClient(config.daytona) }, + ); + // The process-local Secret wrapper applies to EVERY plan-bearing Daytona run + // (`buildRunPlan` builds a plan only when AGENTA_DAYTONA_OPAQUE_SECRETS=process_local is + // enabled), INCLUDING a zero-candidate plan: the wrapper then allocates no Secrets and + // attaches nothing, but its create-fingerprint check still governs reconnects, so a parked + // sandbox holding plaintext local_use credentials (AWS/GCP) is rebuilt — never reconnected + // with stale values — after those credentials rotate. Every flag-off run carries no plan and + // takes the plain plaintext-env provider below, unchanged from the pre-feature behavior. + // The assert is defense-in-depth against a direct caller handing a candidate-bearing plan + // while the flag is off: that plan's environment already dropped the opaque values, so + // proceeding unwrapped would silently run without credentials. + // + // Accepted design limit: Secret ownership is PROCESS-LOCAL. The wrapper's registry dies + // with the runner process, so a hard crash can orphan a Daytona Secret until Daytona's own + // auto-delete backstop fires. Durable reconciliation is an explicit follow-up (PR B of the + // Daytona-Secrets design; see PR #5278) — do not add recovery machinery here. + if (daytonaSecretPlan) { + assertDaytonaOpaqueSecretsEnabled(daytonaSecretPlan); + const client = buildDaytonaClient(config.daytona); + const createFingerprint = daytonaCreateFingerprint({ + image, + create: createFields, + secretPlan: daytonaSecretPlan, + }); + return daytonaWithProcessLocalSecrets( + buildDaytona, + daytonaSecretPlan, + client.secret, + { + createFingerprint, + // Run slightly after Daytona's own auto-delete backstop. The timer first issues an + // idempotent sandbox delete, then removes Secrets, preserving the hard deletion order. + cleanupDelayMilliseconds: + config.daytona.autodeleteMinutes * 60_000 + 5_000, + log: (message) => process.stderr.write(`[daytona] ${message}\n`), + }, + ); + } + return buildDaytona({}); } if ((PLANNED_SANDBOX_IDS as readonly string[]).includes(sandboxId)) { diff --git a/services/runner/src/engines/sandbox_agent/run-plan.ts b/services/runner/src/engines/sandbox_agent/run-plan.ts index 6251fd72a6..fff880b122 100644 --- a/services/runner/src/engines/sandbox_agent/run-plan.ts +++ b/services/runner/src/engines/sandbox_agent/run-plan.ts @@ -36,6 +36,11 @@ import { loadRunnerConfig, type SandboxProviderId, } from "../../config/runner-config.ts"; +import { + buildDaytonaSecretPlan, + daytonaOpaqueSecretsEnabled, + type DaytonaSecretPlan, +} from "./daytona-secret-plan.ts"; type Log = (message: string) => void; @@ -102,20 +107,29 @@ export interface RunPlan { prompt: string; turnText: string; agentsMd?: string; - secrets: Record; + /** Final plaintext model environment, after validating modelConnection. */ + modelEnvironment: Record; + /** + * Process-local opaque credential plan. Present for every Daytona run when + * AGENTA_DAYTONA_OPAQUE_SECRETS=process_local is enabled — even with zero candidates, so the + * Secret provider wrapper (and its create-fingerprint rotation check) governs every flag-on + * reconnect. Absent when the flag is off, so that path is the plain plaintext-env provider + * with no wrapper, unchanged from the pre-feature runner. + */ + daytonaSecretPlan?: DaytonaSecretPlan; /** * The provider api-key env var name the harness would read by default (`ANTHROPIC_API_KEY` for * Claude, `OPENAI_API_KEY` otherwise). It does not choose the provider; it only names the key - * whose presence sets `hasApiKey`. + * whose presence — in the materialized model environment — sets `hasApiKey`. */ - legacyHarnessApiKeyVar: string; - /** Whether the resolved `secrets` already carry `legacyHarnessApiKeyVar`. */ + harnessApiKeyVar: string; + /** Whether the materialized model environment already carries `harnessApiKeyVar`. */ hasApiKey: boolean; /** * How the credential is delivered: "env" (managed, resolved key) | "runtime_provided" (the * harness owns its login) | "none". From the resolved connection (provider-model-auth design, - * Concern 3). `undefined` when an un-migrated caller sends no credentialMode. Drives - * clear-then-apply env (Security rule 5). + * Concern 3). `undefined` only when a direct runner request has no resolved modelConnection; + * that request may still use the harness login. Drives clear-then-apply env (Security rule 5). */ credentialMode?: string; cwd: string; @@ -168,6 +182,27 @@ export interface RunPlan { export type BuildRunPlanResult = { ok: true; plan: RunPlan } | { ok: false; error: string }; +// Retired flat model-credential fields. `connection` ({mode, slug}) is NOT here: it is the +// author's non-secret connection intent, still on the wire (pi-model-config keys the Pi custom +// provider off its slug). +const LEGACY_MODEL_CREDENTIAL_FIELDS = [ + "secrets", + "provider", + "deployment", + "credentialMode", + "endpoint", +] as const; + +// Always scans the raw request, `modelConnection` present or not: a caller sending BOTH the +// typed shape and a retired flat field is confused about the contract, and silently ignoring +// the legacy half could mask a credential it expected to apply. +function legacyModelCredentialFields(request: AgentRunRequest): string[] { + const raw = request as unknown as Record; + return LEGACY_MODEL_CREDENTIAL_FIELDS.filter( + (field) => Object.hasOwn(raw, field) && raw[field] !== undefined, + ); +} + export interface BuildRunPlanDeps { sandboxProvider?: string; /** Providers this deployment enables; a request for anything outside this set is rejected. */ @@ -227,6 +262,99 @@ function defaultDaytonaCwd(durableCwd?: string): string { return durableCwd ?? `/home/sandbox/agenta-${randomBytes(6).toString("hex")}`; } +export function materializeModelEnvironment( + request: AgentRunRequest, +): + | { ok: true; environment: Record; credentialMode?: string } + | { ok: false; error: string } { + const connection = request.modelConnection; + if (!connection) return { ok: true, environment: {} }; + if (!connection.provider?.trim() || !connection.deployment?.trim()) { + return { + ok: false, + error: "modelConnection requires provider and deployment", + }; + } + + const environment: Record = {}; + for (const [name, value] of Object.entries(connection.environment ?? {})) { + if (!name.trim() || typeof value !== "string" || !value) { + return { + ok: false, + error: + "modelConnection environment requires non-empty names and values", + }; + } + environment[name] = value; + } + + const credentials = Array.isArray(connection.credentials) + ? connection.credentials + : []; + if (connection.credentialMode === "env" && credentials.length === 0) { + return { + ok: false, + error: "modelConnection credentialMode env requires credentials", + }; + } + if (connection.credentialMode !== "env" && credentials.length > 0) { + return { + ok: false, + error: "modelConnection credentials require credentialMode env", + }; + } + + for (const credential of credentials) { + const name = credential?.binding?.name; + if ( + credential?.binding?.kind !== "environment" || + !name?.trim() || + !credential.value + ) { + return { + ok: false, + error: "modelConnection credential binding and value must be non-empty", + }; + } + if ( + credential.usage !== "opaque_http" && + credential.usage !== "local_use" + ) { + return { + ok: false, + error: "modelConnection credential usage is invalid", + }; + } + if (credential.usage === "opaque_http") { + try { + const endpoint = new URL(connection.endpoint?.baseUrl ?? ""); + if (endpoint.protocol !== "https:" || !endpoint.hostname) { + throw new Error("invalid endpoint"); + } + } catch { + return { + ok: false, + error: + "opaque_http model credentials require an effective HTTPS endpoint", + }; + } + } + if (Object.hasOwn(environment, name)) { + return { + ok: false, + error: `duplicate modelConnection environment binding '${name}'`, + }; + } + environment[name] = credential.value; + } + + return { + ok: true, + environment, + credentialMode: connection.credentialMode, + }; +} + export function buildRunPlan( request: AgentRunRequest, { @@ -260,6 +388,18 @@ export function buildRunPlan( }; } + // Model routing and credentials arrive grouped under `modelConnection`; the retired flat + // fields are rejected loudly so a stale caller cannot silently run without credentials. + const legacyFields = legacyModelCredentialFields(request); + if (legacyFields.length > 0) { + return { + ok: false, + error: + `Legacy top-level model credential fields are not supported (${legacyFields.join(", ")}); ` + + "send the resolved modelConnection object.", + }; + } + // 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 // run on the `pi` ACP agent; `claude` runs on the `claude` ACP agent. `harness` remains the @@ -310,7 +450,8 @@ export function buildRunPlan( // its login on a read-write mount that lives in the runner container and is never shipped to a // third-party sandbox. Reject Daytona + runtime_provided here, before any sandbox is created, // rather than silently falling back to an unauthenticated remote run (interface.md sections 5-6). - if (isDaytona && request.credentialMode === "runtime_provided") { + const requestCredentialMode = request.modelConnection?.credentialMode; + if (isDaytona && requestCredentialMode === "runtime_provided") { return { ok: false, error: DAYTONA_SUBSCRIPTION_UNSUPPORTED_MESSAGE }; } @@ -318,7 +459,7 @@ export function buildRunPlan( // harness config var is unset there is no mount to read, so fail up front with an actionable // message rather than letting the harness fall back to discovering the runner's own home dir // (interface.md section 6). Managed ("env") / "none" runs are unaffected. - if (!isDaytona && request.credentialMode === "runtime_provided") { + if (!isDaytona && requestCredentialMode === "runtime_provided") { const subscriptionEnvVar = acpAgent === "claude" ? "CLAUDE_CONFIG_DIR" : "PI_CODING_AGENT_DIR"; if (!process.env[subscriptionEnvVar]) { @@ -326,8 +467,39 @@ export function buildRunPlan( } } - const secrets = request.secrets ?? {}; - const legacyHarnessApiKeyVar = + const materializedModel = materializeModelEnvironment(request); + if (!materializedModel.ok) return materializedModel; + // Daytona opaque-credential delivery is FLAG-GATED (AGENTA_DAYTONA_OPAQUE_SECRETS= + // process_local). Flag OFF: no secret plan is built at all, so behavior is identical to the + // pre-feature runner — the full materialized environment reaches sandbox create as plaintext + // env, no provider wrapper is applied, and the plan's strict endpoint/binding validation + // cannot introduce a new failure mode. Flag ON: the plan splits every opaque_http value out + // of the plaintext env and is ALWAYS kept, even with zero candidates, so the provider wrapper + // (and its create fingerprint) governs every flag-on Daytona reconnect. A zero-candidate plan + // allocates no Secrets, but a parked sandbox created with plaintext local_use credentials must + // be rebuilt — never reconnected — after those credentials rotate, and only the wrapper's + // fingerprint check enforces that (the plain reconnect path converges network policy only). + let daytonaSecretPlan: DaytonaSecretPlan | undefined; + if (isDaytona && daytonaOpaqueSecretsEnabled()) { + try { + daytonaSecretPlan = buildDaytonaSecretPlan({ + modelConnection: request.modelConnection, + mcpServers: request.mcpServers, + }); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + // Local keeps its existing direct environment. A flag-on Daytona run with opaque credentials + // removes every opaque_http value and passes only non-secret config plus explicitly local_use + // credentials to sandbox create. With zero candidates the plan's environment equals the + // materialized one, so keeping the empty plan changes nothing here. + const modelEnvironment = + daytonaSecretPlan?.environment ?? materializedModel.environment; + const harnessApiKeyVar = acpAgent === "claude" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"; const toolSpecs = (request.customTools as ResolvedToolSpec[]) ?? []; const executableToolSpecsForRun = executableToolSpecs(toolSpecs); @@ -483,10 +655,13 @@ export function buildRunPlan( prompt, turnText: buildTurnText(request, log), agentsMd: request.agentsMd?.trim() || undefined, - secrets, - legacyHarnessApiKeyVar, - hasApiKey: !!secrets[legacyHarnessApiKeyVar], - credentialMode: request.credentialMode, + modelEnvironment, + daytonaSecretPlan, + harnessApiKeyVar, + // Consult the FULL materialized environment: on a Daytona Secrets run the opaque key is + // delivered as a Secret attachment rather than plaintext env, but the harness still has it. + hasApiKey: !!materializedModel.environment[harnessApiKeyVar], + credentialMode: materializedModel.credentialMode, cwd, relayDir, toolMcpDir, diff --git a/services/runner/src/engines/sandbox_agent/run-turn.ts b/services/runner/src/engines/sandbox_agent/run-turn.ts index 111904b2d4..4fb4a6d6c9 100644 --- a/services/runner/src/engines/sandbox_agent/run-turn.ts +++ b/services/runner/src/engines/sandbox_agent/run-turn.ts @@ -257,16 +257,15 @@ export async function runTurn( endpoint: request.telemetry?.exporters?.otlp?.endpoint, authorization: request.telemetry?.exporters?.otlp?.headers?.authorization, captureContent: request.telemetry?.capture?.content?.enabled, - // Seed from the keys actually APPLIED to this run (`plan.secrets`) plus the mount's STS - // pair — neither lives in the sidecar's process env. - redactor: seedForRun( - { secrets: plan.secrets, telemetry: request.telemetry }, - [ - env.mountCreds?.accessKey, - env.mountCreds?.secretKey, - env.mountCreds?.sessionToken, - ], - ), + // Seed from the request's typed model/MCP credential material (`requestSecretValues` — + // on a Daytona Secrets run the opaque values left the plaintext env for the secret plan + // but still transit runner memory) plus the mount's STS pair — none of which lives in the + // sidecar's process env. + redactor: seedForRun(request, [ + env.mountCreds?.accessKey, + env.mountCreds?.secretKey, + env.mountCreds?.sessionToken, + ]), emitSpans: !plan.isPi || plan.isDaytona, // Every emitted event is a progress signal for the idle/TTFB deadlines (message/thought // deltas, tool calls and results, usage, ...) — the one seam every harness's output flows @@ -298,7 +297,7 @@ export async function runTurn( plan, capabilities: env.capabilities, modelCapabilities: request.modelCapabilities, - provider: request.provider, + provider: request.modelConnection?.provider, emit: (event) => run.emitEvent(event), }) : []; @@ -307,7 +306,7 @@ export async function runTurn( for (const image of legacyImages) { const gate = attachmentCapabilityGate({ acpAgent: plan.acpAgent, - provider: request.provider, + provider: request.modelConnection?.provider, capabilities: env.capabilities, // Legacy inline images predate the catalog, so a caller that declares nothing keeps // the historical image-capable assumption; a caller that declares modalities is @@ -1040,9 +1039,9 @@ export async function runTurn( swallowedError = conciseError( new Error(swallowedPiError), plan.harness, - request.provider, + request.modelConnection?.provider, ); - run.recordError(swallowedError, request.provider); + run.recordError(swallowedError, request.modelConnection?.provider); run.emitEvent({ type: "error", message: swallowedError }); } @@ -1108,8 +1107,8 @@ export async function runTurn( traceId: run.traceId(), } as AgentRunResult; } catch (err) { - const error = conciseError(err, plan.harness, request.provider); - otel?.recordError(error, request.provider); + const error = conciseError(err, plan.harness, request.modelConnection?.provider); + otel?.recordError(error, request.modelConnection?.provider); otel?.emitEvent({ type: "error", message: error }); // An aborted turn may have left a partial turn in the native transcript. invalidateContinuity(sessionId, plan.harness, deps); diff --git a/services/runner/src/engines/sandbox_agent/runtime-policy.ts b/services/runner/src/engines/sandbox_agent/runtime-policy.ts index 7d883bc2c4..34a509af8a 100644 --- a/services/runner/src/engines/sandbox_agent/runtime-policy.ts +++ b/services/runner/src/engines/sandbox_agent/runtime-policy.ts @@ -80,9 +80,9 @@ export function applyClaudeConnectionEnv( // so it is never stripped, and it reaches the Daytona sandbox like `ANTHROPIC_BASE_URL`. env.ENABLE_TOOL_SEARCH = "false"; - const deployment = request.deployment; + const deployment = request.modelConnection?.deployment; const selectedModel = request.model; - const baseUrl = request.endpoint?.baseUrl; + const baseUrl = request.modelConnection?.endpoint?.baseUrl; if (baseUrl) { env.ANTHROPIC_BASE_URL = baseUrl; logger(`claude base_url: ${baseUrl}`); @@ -90,7 +90,7 @@ export function applyClaudeConnectionEnv( if (deployment === "bedrock") { env.CLAUDE_CODE_USE_BEDROCK = "1"; - const region = request.endpoint?.region; + const region = request.modelConnection?.endpoint?.region; if (region) { env.AWS_REGION = region; env.AWS_DEFAULT_REGION ??= region; diff --git a/services/runner/src/engines/sandbox_agent/session-identity.ts b/services/runner/src/engines/sandbox_agent/session-identity.ts index 088af6b32e..57bbbd9903 100644 --- a/services/runner/src/engines/sandbox_agent/session-identity.ts +++ b/services/runner/src/engines/sandbox_agent/session-identity.ts @@ -138,10 +138,13 @@ function canonicalJson(value: unknown): string { /** * A canonical hash over the config-bearing request fields (the continuation-versus-cold * decision). Per-turn volatiles are excluded: `messages`, `turnId`, trace propagation - * (`context`), the rotating telemetry headers, and secret VALUES (`secrets` — the credential - * epoch covers rotation, and values must never enter any hash used for logging). The - * tool-callback ENDPOINT is included (routing config); its authorization is a credential and - * lives in the credential epoch instead. + * (`context`), the rotating telemetry headers, and credential VALUES + * (`modelConnection.credentials` / MCP `connection.credentials` — the credential epoch covers + * rotation, and values must never enter any hash used for logging). The tool-callback ENDPOINT + * is included (routing config); its authorization is per-turn credential material excluded from + * every hash, the credential epoch included — each turn's relay uses the INCOMING request's + * `toolCallback` (see `CredentialEpoch` and `run-turn.ts`), so the parked copy never executes + * anything. */ export function configFingerprint(request: AgentRunRequest): string { const workflow = request.runContext?.workflow; @@ -149,18 +152,41 @@ export function configFingerprint(request: AgentRunRequest): string { harness: request.harness ?? null, sandbox: request.sandbox ?? null, model: request.model ?? null, - provider: request.provider ?? null, connection: request.connection ?? null, - deployment: request.deployment ?? null, - endpoint: request.endpoint ?? null, + modelConnection: request.modelConnection + ? { + provider: request.modelConnection.provider, + deployment: request.modelConnection.deployment, + endpoint: request.modelConnection.endpoint ?? null, + credentialMode: request.modelConnection.credentialMode, + environment: request.modelConnection.environment ?? null, + credentials: (request.modelConnection.credentials ?? []).map( + (credential) => ({ + binding: credential.binding, + usage: credential.usage, + }), + ), + } + : null, modelCapabilities: request.modelCapabilities ?? null, - credentialMode: request.credentialMode ?? null, agentsMd: request.agentsMd ?? null, systemPrompt: request.systemPrompt ?? null, appendSystemPrompt: request.appendSystemPrompt ?? null, skills: request.skills ?? null, customTools: request.customTools ?? null, - mcpServers: request.mcpServers ?? null, + // Credential VALUES are stripped (binding + usage identify the shape); public headers are + // config and stay in. + mcpServers: + request.mcpServers?.map((server) => ({ + ...server, + connection: { + ...server.connection, + credentials: server.connection?.credentials?.map((credential) => ({ + binding: credential.binding, + usage: credential.usage, + })), + }, + })) ?? null, toolCallbackEndpoint: request.toolCallback?.endpoint ?? null, permissions: request.permissions ?? null, sandboxPermission: request.sandboxPermission ?? null, @@ -465,7 +491,9 @@ export function mountExpiryMs( } /** - * The epoch an INCOMING request carries: just the secret material. An incoming request has no + * The epoch an INCOMING request carries: just the secret material — the typed model credentials + * and the typed MCP header credentials, i.e. exactly what gets BAKED into the sandbox/session + * environment (plaintext locally; Daytona Secret records remotely). An incoming request has no * mount lease of its own to contribute; a parked epoch's `mountExpiresAtMs` is stamped from the * environment's installed mounts at park time (see `installedMountLease`). */ @@ -473,11 +501,35 @@ export function computeCredentialEpoch( request: AgentRunRequest, ): CredentialEpoch { const material = canonicalJson({ - secrets: request.secrets ?? {}, + modelEnvironment: request.modelConnection?.environment ?? {}, + modelCredentials: (request.modelConnection?.credentials ?? []).map( + (credential) => ({ + binding: credential.binding, + value: credential.value, + usage: credential.usage, + }), + ), + mcpCredentials: (request.mcpServers ?? []).flatMap((server) => + (server.connection?.credentials ?? []).map((credential) => ({ + server: server.name, + url: server.connection?.url ?? null, + binding: credential.binding, + value: credential.value, + usage: credential.usage, + })), + ), }); return { secretsHash: sha256(material) }; } +/** True when credentials baked into a parked sandbox/session changed (rotation ⇒ evict). */ +export function sandboxCredentialsRotated( + parked: CredentialEpoch, + incoming: CredentialEpoch, +): boolean { + return parked.secretsHash !== incoming.secretsHash; +} + /** * Expiry (epoch millis) of the credentials actually installed in each of an environment's running * geesefs daemons. Per mount kind, because a remount replaces one mount's credentials and must not diff --git a/services/runner/src/extensions/agenta.ts b/services/runner/src/extensions/agenta.ts index 419f48abb4..7f93f63510 100644 --- a/services/runner/src/extensions/agenta.ts +++ b/services/runner/src/extensions/agenta.ts @@ -48,6 +48,10 @@ import { PI_GATE_DIALOG_TITLE, type PiGateKind, } from "../engines/sandbox_agent/pi-gate-envelope.ts"; +import { + decodePiModelProviderOverride, + PI_MODEL_PROVIDER_OVERRIDE_ENV, +} from "./model-provider-override.ts"; /** Read the OTLP bearer from its runner-written file once, then best-effort delete it. */ export function readOtlpAuthFile(path?: string): string | undefined { @@ -328,6 +332,12 @@ function registerTools(pi: ExtensionAPI): void { /** The Pi ExtensionFactory: tools + (env-driven) tracing + usage writeback. */ const factory = (pi: ExtensionAPI): void => { + const modelProviderOverrideRaw = + process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV]; + const modelProviderOverride = + modelProviderOverrideRaw === undefined + ? undefined + : decodePiModelProviderOverride(modelProviderOverrideRaw); // Fully inert unless Agenta wired this run (so it is safe to install globally in a // shared Pi agent dir — a normal `pi` session with no Agenta env does nothing). const hasTracing = !!( @@ -343,6 +353,7 @@ const factory = (pi: ExtensionAPI): void => { ); const usageOut = process.env.AGENTA_AGENT_USAGE_CAPTURE_PATH; if ( + !modelProviderOverride && !hasTracing && !hasTools && !hasBuiltinActivation && @@ -351,6 +362,14 @@ const factory = (pi: ExtensionAPI): void => { ) return; + // Extension factories complete before Pi selects the configured model. Registering only a + // baseUrl here overrides the built-in provider without replacing its model catalog or auth. + if (modelProviderOverride) { + pi.registerProvider(modelProviderOverride.provider, { + baseUrl: modelProviderOverride.baseUrl, + }); + } + if (hasTools) registerTools(pi); if (hasBuiltinActivation) registerBuiltinActivation(pi); if (hasBuiltinGating) registerBuiltinGating(pi); diff --git a/services/runner/src/extensions/model-provider-override.ts b/services/runner/src/extensions/model-provider-override.ts new file mode 100644 index 0000000000..35096e18e6 --- /dev/null +++ b/services/runner/src/extensions/model-provider-override.ts @@ -0,0 +1,65 @@ +export const PI_MODEL_PROVIDER_OVERRIDE_ENV = + "AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE"; + +export interface PiModelProviderOverride { + provider: string; + baseUrl: string; +} + +const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +/** Validate the public routing config shared by the runner and the in-Pi extension. */ +export function validatePiModelProviderOverride( + value: unknown, +): PiModelProviderOverride { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("model provider override must be an object"); + } + + const provider = (value as { provider?: unknown }).provider; + if (typeof provider !== "string" || !PROVIDER_ID.test(provider)) { + throw new Error("model provider override has an invalid provider"); + } + + const baseUrl = (value as { baseUrl?: unknown }).baseUrl; + if (typeof baseUrl !== "string" || baseUrl.trim() !== baseUrl) { + throw new Error("model provider override has an invalid baseUrl"); + } + + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw new Error("model provider override baseUrl must be a valid URL"); + } + if ( + url.protocol !== "https:" || + !url.hostname || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error( + "model provider override baseUrl must be an HTTPS URL without credentials, query, or fragment", + ); + } + + return { provider, baseUrl }; +} + +export function encodePiModelProviderOverride(value: unknown): string { + return JSON.stringify(validatePiModelProviderOverride(value)); +} + +export function decodePiModelProviderOverride( + raw: string, +): PiModelProviderOverride { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("model provider override must be valid JSON"); + } + return validatePiModelProviderOverride(parsed); +} diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index e958ed3101..ac70b3520f 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -260,13 +260,28 @@ export interface McpToolPolicy { names?: string[]; } +/** One secret HTTP header binding owned by an HTTP MCP consumer. */ +export interface McpCredential { + binding: { kind: "header"; name: string }; + value: string; + usage: "opaque_http"; +} + +/** + * A user-declared HTTP MCP server attached to the run. Public HTTP headers and secret HTTP + * header credentials remain separate by protocol role: `headers` carries non-secret values + * only, and every secret header rides a typed `McpCredential` so the runner can materialize + * it (plaintext locally; a Daytona Secret placeholder on a remote sandbox). + */ export interface McpServerConfig { name: string; connection: { type: "http"; url: string; - /** Resolved per-run headers. Values may be secret and must never be logged. */ + /** Resolved per-run PUBLIC headers. Secret headers ride `credentials`. */ headers?: Record; + /** Typed secret header bindings. Values must never be logged. */ + credentials?: McpCredential[]; }; policy: { tools: McpToolPolicy; @@ -430,6 +445,32 @@ export interface AgentUsage { cost: number; } +export interface ModelCredentialBinding { + kind: "environment"; + name: string; +} + +export interface ModelCredential { + binding: ModelCredentialBinding; + value: string; + usage: "opaque_http" | "local_use"; +} + +/** Resolved route and credentials owned by the model consumer. */ +export interface ModelConnection { + provider: string; + deployment: string; + endpoint?: { + baseUrl?: string; + apiVersion?: string; + region?: string; + headers?: Record; + }; + credentialMode: "env" | "runtime_provided" | "none"; + environment?: Record; + credentials: ModelCredential[]; +} + export interface AgentRunRequest { /** * Harness id: "pi_core" | "pi_agenta" | "claude". `pi_core` and `pi_agenta` both drive the @@ -441,8 +482,6 @@ export interface AgentRunRequest { sandbox?: string; /** External conversation id. The cold runtime still receives history in `messages`. */ sessionId?: string; - /** Provider API keys as env vars ({OPENAI_API_KEY,...}), resolved from the vault. */ - secrets?: Record; /** AGENTS.md text injected as the agent's instructions. */ agentsMd?: string; /** @@ -461,39 +500,17 @@ export interface AgentRunRequest { model?: string; /** Resolved model input modalities. Omitted when the resolver cannot determine them. */ modelCapabilities?: { inputModalities?: string[] }; - /** - * Provider family for the run, e.g. "openai" | "anthropic" | . Non-secret. - * Present only when the config carries a structured model ref. See the provider-model-auth - * design (Concern 1). - */ - provider?: string; /** * Where the credential comes from, named portably (a slug, never a db id). Non-secret. - * Present only when the config carries a structured model ref. See the provider-model-auth - * design (Concern 1). + * The RESOLVED routing and credential values live in `modelConnection`; this field carries + * only the author's intent (`mode`) and the connection identity (`slug`, which names the Pi + * custom provider in `pi-model-config.ts`). The current SDK resolver does NOT send it — + * custom-endpoint Pi routing rides the extension provider override instead — so the + * models.json path only activates for a direct caller that still supplies it. */ connection?: { mode: string; slug?: string }; - /** - * Deployment surface for the provider: "direct" | "azure" | "bedrock" | "vertex" | - * "custom". From a resolved connection; see the provider-model-auth design (Concern 3). - */ - deployment?: string; - /** - * Non-secret connection config (custom base URL, api version, region, public headers). - * Secret values never live here; they ride `secrets`. See the provider-model-auth design - * (Concern 3). - */ - endpoint?: { - baseUrl?: string; - apiVersion?: string; - region?: string; - headers?: Record; - }; - /** - * How the credential is delivered: "env" | "runtime_provided" | "none". From a resolved - * connection; see the provider-model-auth design (Concern 3). - */ - credentialMode?: string; + /** Resolved model routing and credential bindings, grouped under their consumer. */ + modelConnection?: ModelConnection; /** The conversation so far; the runner picks the latest turn and replays the rest. */ messages?: ChatMessage[]; /** Deprecated: accepted and ignored. Pi activates every built-in tool on every run. */ diff --git a/services/runner/src/redaction.ts b/services/runner/src/redaction.ts index 96a2ada6ad..6c1b66cef8 100644 --- a/services/runner/src/redaction.ts +++ b/services/runner/src/redaction.ts @@ -389,17 +389,49 @@ export function seedFromEnv(options?: { /** The shape `seedForRun` reads off an `AgentRunRequest` (structural, to avoid importing the * wire types into the redaction primitive). */ export interface RunSeedSource { - /** Provider API keys resolved per run and applied into the run env (`daemon.ts`). */ - secrets?: Record; + /** Resolved model routing: typed credential values plus the materialized environment values. */ + modelConnection?: { + environment?: Record; + credentials?: Array<{ value?: string }>; + }; + /** Resolved MCP servers: each connection's typed secret header credential values. */ + mcpServers?: Array<{ + connection?: { credentials?: Array<{ value?: string }> }; + }>; telemetry?: { exporters?: { otlp?: { headers?: Record } }; }; } /** - * The runner's per-run deny-set (WP1.1). The run's resolved provider keys ride `secrets` on the - * wire and never appear in the sidecar's own process env, so a process-env-only seed would miss - * exactly the highest-value secrets — they must be seeded from the REQUEST. + * Every credential-bearing value the request's TYPED shapes carry: the model connection's + * credential values and materialized environment values, plus each MCP server connection's + * credential values. This is a superset of whatever subset actually lands in the sandbox env + * (on a Daytona Secrets run the opaque values leave the plaintext env for the secret plan, but + * they still transit runner memory and can be echoed by the model), so the deny-set seeds from + * the request, not from the delivered environment. + */ +export function requestSecretValues( + request: RunSeedSource, +): Array { + return [ + ...Object.values(request.modelConnection?.environment ?? {}), + ...(request.modelConnection?.credentials ?? []).map( + (credential) => credential.value, + ), + ...(request.mcpServers ?? []).flatMap((server) => + (server.connection?.credentials ?? []).map( + (credential) => credential.value, + ), + ), + ]; +} + +/** + * The runner's per-run deny-set (WP1.1). The run's resolved credential values ride the typed + * `modelConnection` / `mcpServers` wire shapes and never appear in the sidecar's own process + * env, so a process-env-only seed would miss exactly the highest-value secrets — they must be + * seeded from the REQUEST (`requestSecretValues`). */ export function seedForRun( request: RunSeedSource, @@ -412,7 +444,7 @@ export function seedForRun( "" ).trim(); return seedFromEnv({ - resolvedSecrets: Object.values(request.secrets ?? {}), + resolvedSecrets: requestSecretValues(request), runCredential: runCredential || null, extraValues, }); diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 357f375b2c..033d8b5210 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -61,6 +61,7 @@ import { mountCredentialsExpired, mountCredentialsExpireBy, MOUNT_LEASE_SKEW_MS, + sandboxCredentialsRotated, type CredentialEpoch, expectedNextHistoryFingerprint, historyFingerprint, @@ -765,15 +766,9 @@ export async function runWithKeepalive( // An approval-parked session. A validated approval decision that matches the parked // Claude ACP gate resumes it live; anything else evicts and degrades to cold. // - // Unlike the idle-continuation branch above, this branch does NOT require the resume request's - // configFingerprint or credential epoch to EQUAL the parked session's. Every approval reply is - // a fresh /run the backend mints carrying freshly minted short-lived material (gateway/Composio - // secret VALUES, a per-turn tool-callback bearer), so the incoming credential epoch — and often - // the config fingerprint, which can embed those per-turn tokens — practically never match the - // parked ones. But the parked live process already holds its OWN resolved credentials baked at - // acquire time; the resume request only delivers the human's yes/no. Re-minted per-turn material - // on the resume says nothing about the parked environment's validity, so matching it against the - // park would evict a perfectly good live session on every approval (the "approve twice" bug). + // Approval replies may carry re-minted per-turn callback authorization, which does not invalidate + // the parked process. Credentials baked into the model/MCP environment are different: rotation + // must evict and cold-start so the resumed process never keeps stale credential material. // // We keep the checks that DO bound the parked environment: the approval-decision match, the // history fingerprint (an edited transcript must not continue wrongly, but only for a client @@ -839,9 +834,19 @@ export async function runWithKeepalive( } else if (mountCredentialsExpired(existing.credentialEpoch)) { mismatch = "credentials-expired"; } else if ( - mountCredentialsExpireBy(existing.credentialEpoch, requiredValidThroughMs) + mountCredentialsExpireBy( + existing.credentialEpoch, + requiredValidThroughMs, + ) ) { mismatch = "credentials-expiring"; + } else if ( + sandboxCredentialsRotated(existing.credentialEpoch, incomingEpoch) + ) { + // Re-minted per-turn callback auth never invalidates the parked process, but credentials + // BAKED into the model/MCP environment are different: rotation must evict and cold-start + // so the resumed process never keeps stale credential material. + mismatch = "credentials-rotated"; } } @@ -949,9 +954,15 @@ const keepalivePools: Record< const runAgent: RunAgent = (request, emit, signal, options) => { const provider = resolveKeepaliveDispatch(request, keepaliveConfigs); if (!provider) { - return runSandboxAgent(request, emit, signal, {}, { - ...(options?.credential ? { credential: options.credential } : {}), - }); + return runSandboxAgent( + request, + emit, + signal, + {}, + { + ...(options?.credential ? { credential: options.credential } : {}), + }, + ); } const config = keepaliveConfigs[provider]; return runWithKeepalive(request, emit, signal, { @@ -1131,8 +1142,10 @@ async function runAndStreamWithApiBaseResolved( answeredTokens, watchdog.credential, ); - // Deny-set from THIS run's resolved provider keys + run credential (not process env, - // which never holds them). + // Deny-set from THIS run's typed credential material (model connection credentials + + // materialized environment values + MCP connection credentials) and the run credential — + // not process env, which never holds them. A credential value a model echoes back must + // never reach the durable session records unredacted. const { emit: persistingEmit, persist, @@ -1182,7 +1195,13 @@ async function runAndStreamWithApiBaseResolved( } catch (err) { const message = err instanceof Error ? err.message : String(err); // Stack stays server-side; the message alone goes on the wire and into the transcript. - if (err instanceof Error && err.stack) console.error(err.stack); + // Server-side is still a sink: an escaping error can capture this run's live credentials + // in its message/stack (an auth failure echoing the key, a dumped env), so the stack runs + // through the run's own deny-set before it reaches stderr — same seed the persisting + // emitter uses (`seedForRun(request)`), keeping the log shape intact with values scrubbed. + if (err instanceof Error && err.stack) { + console.error(seedForRun(request).redactString(err.stack, "stderr")); + } // A throw escaping run() itself (outside the engine's own try/catch) emitted no error // event — persist it here as the backstop. if (persistError) persistError(message); @@ -1389,7 +1408,7 @@ export function createRequestListener( // Only .message goes on the wire: the raw thrown value (even via String()) is // stack-trace-tainted to CodeQL, and the stack itself stays server-side. const message = err instanceof Error ? err.message : "Internal error"; - console.error(err instanceof Error ? err.stack ?? err.message : err); + console.error(err instanceof Error ? (err.stack ?? err.message) : err); return send(res, 500, { ok: false, error: message }); } }; @@ -1447,7 +1466,7 @@ if (isEntrypoint(import.meta.url)) { // run still returns its own error to its caller. process.on("unhandledRejection", (reason) => { process.stderr.write( - `[sandbox-agent] unhandledRejection: ${reason instanceof Error ? reason.stack ?? reason.message : String(reason)}\n`, + `[sandbox-agent] unhandledRejection: ${reason instanceof Error ? (reason.stack ?? reason.message) : String(reason)}\n`, ); }); process.on("uncaughtException", (err) => { diff --git a/services/runner/tests/setup/hermetic-env.ts b/services/runner/tests/setup/hermetic-env.ts index b3a34a624b..a521e9f2a5 100644 --- a/services/runner/tests/setup/hermetic-env.ts +++ b/services/runner/tests/setup/hermetic-env.ts @@ -44,6 +44,7 @@ const SCRUBBED = [ "AGENTA_RUNNER_DAYTONA_IMAGE", "AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES", "AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES", + "AGENTA_DAYTONA_OPAQUE_SECRETS", "DAYTONA_API_KEY", "DAYTONA_API_URL", "DAYTONA_TARGET", diff --git a/services/runner/tests/unit/daytona-secret-plan.test.ts b/services/runner/tests/unit/daytona-secret-plan.test.ts new file mode 100644 index 0000000000..aceca379c8 --- /dev/null +++ b/services/runner/tests/unit/daytona-secret-plan.test.ts @@ -0,0 +1,275 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "vitest"; + +import type { AgentRunRequest, McpServerConfig } from "../../src/protocol.ts"; +import { + assertDaytonaOpaqueSecretsEnabled, + buildDaytonaSecretPlan, + exactHttpsHost, +} from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; +import { buildRunPlan } from "../../src/engines/sandbox_agent/run-plan.ts"; +import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; + +// The buildRunPlan case below exercises a Daytona run, so enable the provider (with a +// provisioning credential) on top of the hermetic scrub and drop the memoized config. +beforeEach(() => { + process.env.AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS = "local,daytona"; + process.env.AGENTA_RUNNER_DAYTONA_API_KEY = "test-key"; + resetRunnerConfigCache(); +}); + +afterEach(() => { + delete process.env.AGENTA_DAYTONA_OPAQUE_SECRETS; +}); + +const modelConnection = { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com/v1/messages" }, + credentialMode: "env" as const, + environment: { AWS_REGION: "us-east-1" }, + credentials: [ + { + binding: { kind: "environment" as const, name: "ANTHROPIC_API_KEY" }, + value: "opaque-model-value", + usage: "opaque_http" as const, + }, + ], +}; + +describe("Daytona Secret planning", () => { + it("plans exact hosts and keeps opaque values out of the direct environment", () => { + const plan = buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + credentials: [ + ...modelConnection.credentials, + { + binding: { + kind: "environment" as const, + name: "AWS_PROFILE", + }, + value: "local-only", + usage: "local_use" as const, + }, + ], + }, + mcpServers: [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/rpc", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value: "opaque-mcp-value", + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, + ], + }); + + assert.deepEqual( + plan.candidates.map((candidate) => ({ + consumer: candidate.consumer, + binding: candidate.binding.name, + host: candidate.allowedHost, + })), + [ + { + consumer: { kind: "model" }, + binding: "ANTHROPIC_API_KEY", + host: "api.anthropic.com", + }, + { + consumer: { kind: "http_mcp", server: "linear" }, + binding: "Authorization", + host: "mcp.linear.app", + }, + ], + ); + assert.deepEqual(plan.environment, { + AWS_REGION: "us-east-1", + AWS_PROFILE: "local-only", + }); + assert.equal(JSON.stringify(plan.environment).includes("opaque-"), false); + }); + + it("rejects IP literals, internal names, wildcards, credentials, and non-default ports", () => { + for (const url of [ + "https://8.8.8.8/v1", + "https://[2001:4860:4860::8888]/v1", + "https://metadata.google.internal/v1", + "https://metadata/v1", + "https://localhost/v1", + "https://*.example.com/v1", + "https://user:pass@example.com/v1", + "https://example.com:8443/v1", + ]) { + assert.throws(() => exactHttpsHost(url), /Invalid Daytona secret plan/); + } + assert.equal( + exactHttpsHost("https://API.EXAMPLE.COM./v1"), + "api.example.com", + ); + }); + + it("rejects reserved credential bindings", () => { + assert.throws( + () => + buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + credentials: [ + { + ...modelConnection.credentials[0], + binding: { kind: "environment", name: "DAYTONA_API_KEY" }, + }, + ], + }, + }), + /credential binding 'DAYTONA_API_KEY' is reserved/, + ); + }); + + it("fails closed on plaintext credential bypasses in model environment and local_use", () => { + assert.throws( + () => + buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + credentialMode: "none", + environment: { ANTHROPIC_API_KEY: "plaintext-bypass" }, + credentials: [], + }, + }), + /not approved public config/, + ); + assert.throws( + () => + buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + credentials: [ + { + binding: { + kind: "environment", + name: "ANTHROPIC_API_KEY", + }, + value: "plaintext-bypass", + usage: "local_use", + }, + ], + }, + }), + /not approved for local provider-SDK use/, + ); + }); + + it("secretizes every MCP header regardless of whether its name looks credential-like", () => { + const plaintext = ["Bearer plaintext-bypass", "arbitrary-secret"]; + const plan = buildDaytonaSecretPlan({ + mcpServers: [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/rpc", + headers: { + Authorization: plaintext[0], + "X-Foo": plaintext[1], + }, + credentials: [ + { + binding: { kind: "header", name: "X-Typed-Key" }, + value: "typed-secret", + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, + ], + }); + assert.deepEqual( + plan.candidates.map((candidate) => candidate.binding.name), + ["Authorization", "X-Foo", "X-Typed-Key"], + ); + assert.equal( + plaintext.some((value) => + JSON.stringify(plan.environment).includes(value), + ), + false, + ); + assert.throws( + () => + buildDaytonaSecretPlan({ + mcpServers: [ + { + name: "bad", + connection: { + type: "http", + headers: { Accept: "application/json" }, + }, + policy: { tools: { mode: "all" } }, + } as unknown as McpServerConfig, + ], + }), + /require a URL/, + ); + }); + + it("keeps the feature default-off and accepts only the explicit process_local mode", () => { + const plan = buildDaytonaSecretPlan({ modelConnection }); + assert.throws( + () => assertDaytonaOpaqueSecretsEnabled(plan), + /AGENTA_DAYTONA_OPAQUE_SECRETS=process_local/, + ); + assert.doesNotThrow(() => + assertDaytonaOpaqueSecretsEnabled(plan, "process_local"), + ); + assert.throws(() => assertDaytonaOpaqueSecretsEnabled(plan, "true")); + }); + + it("keeps flag-off Daytona runs on today's plaintext delivery and secretizes only when on", () => { + const request = { + harness: "claude", + sandbox: "daytona", + messages: [{ role: "user", content: "hello" }], + modelConnection, + } satisfies AgentRunRequest; + + // Flag OFF (the hermetic default): behavior-identical to main — the run proceeds and the + // opaque key rides the plaintext model environment; no secret plan, no fail-closed. + const disabled = buildRunPlan(request, { + createDaytonaCwd: () => "/sandbox/cwd", + }); + assert.equal(disabled.ok, true); + if (!disabled.ok) return; + assert.equal( + disabled.plan.modelEnvironment.ANTHROPIC_API_KEY, + "opaque-model-value", + ); + assert.equal(disabled.plan.daytonaSecretPlan, undefined); + assert.equal(disabled.plan.hasApiKey, true); + + // Flag ON: the opaque value leaves the plaintext environment for the secret plan. + process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = "process_local"; + const enabled = buildRunPlan(request, { + createDaytonaCwd: () => "/sandbox/cwd", + }); + assert.equal(enabled.ok, true); + if (!enabled.ok) return; + assert.deepEqual(enabled.plan.modelEnvironment, { + AWS_REGION: "us-east-1", + }); + // hasApiKey consults the FULL materialized environment: the opaque key left the plaintext + // env for the secret plan, but the harness still receives it as a Secret attachment. + assert.equal(enabled.plan.hasApiKey, true); + assert.equal(enabled.plan.daytonaSecretPlan?.candidates.length, 1); + }); +}); diff --git a/services/runner/tests/unit/daytona-secret-provider.test.ts b/services/runner/tests/unit/daytona-secret-provider.test.ts new file mode 100644 index 0000000000..a7eae750e4 --- /dev/null +++ b/services/runner/tests/unit/daytona-secret-provider.test.ts @@ -0,0 +1,579 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it, vi } from "vitest"; + +import type { McpServerConfig } from "../../src/protocol.ts"; +import { + daytonaWithProcessLocalSecrets, + type DaytonaProviderLike, +} from "../../src/engines/sandbox_agent/daytona-secret-provider.ts"; +import { DaytonaReconnectTerminalError } from "../../src/engines/sandbox_agent/daytona-provider.ts"; +import type { DaytonaSecretPlan } from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; +import type { DaytonaSecretApi } from "../../src/engines/sandbox_agent/daytona-secrets.ts"; +import { + buildDaytonaCreate, + daytonaCreateFingerprint, +} from "../../src/engines/sandbox_agent/provider.ts"; +import { parseRunnerConfig } from "../../src/config/runner-config.ts"; + +const plan: DaytonaSecretPlan = { + environment: {}, + candidates: [ + { + ordinal: 0, + consumer: { kind: "model" }, + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + allowedHost: "api.anthropic.com", + value: "model-plaintext", + }, + { + ordinal: 1, + consumer: { kind: "http_mcp", server: "linear" }, + binding: { kind: "header", name: "Authorization" }, + allowedHost: "mcp.linear.app", + value: "mcp-plaintext", + }, + ], +}; + +const mcpServers: McpServerConfig[] = [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/rpc", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value: "mcp-plaintext", + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, +]; + +function secretApi(events: string[]): DaytonaSecretApi { + let count = 0; + return { + async create(input) { + count += 1; + events.push(`secret:create:${input.value}`); + return { + id: `secret-${count}`, + name: input.name, + placeholder: `dtn_secret_${count}`, + hosts: input.hosts, + }; + }, + async delete(id) { + events.push(`secret:delete:${id}`); + }, + }; +} + +function providerFactory(events: string[], attachmentLog: any[]) { + return (attachments: Record): DaytonaProviderLike => { + attachmentLog.push(attachments); + return { + name: "daytona", + async create() { + events.push("sandbox:create"); + return "sandbox-1"; + }, + async destroy(id) { + events.push(`sandbox:destroy:${id}`); + }, + async pause(id) { + events.push(`sandbox:pause:${id}`); + }, + async reconnect(id) { + events.push(`sandbox:reconnect:${id}`); + }, + }; + }; +} + +afterEach(() => vi.useRealTimers()); + +describe("process-local Daytona Secret provider", () => { + it("attaches Secret names at create and substitutes MCP plaintext with placeholders", async () => { + const events: string[] = []; + const attachments: any[] = []; + const headerPlan: DaytonaSecretPlan = { + ...plan, + candidates: [ + ...plan.candidates, + { + ordinal: 2, + consumer: { kind: "http_mcp", server: "linear" }, + binding: { kind: "header", name: "X-Foo" }, + allowedHost: "mcp.linear.app", + value: "mcp-public-plaintext", + }, + ], + }; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events, attachments), + headerPlan, + secretApi(events), + { registry: new Map(), cleanupDelayMilliseconds: 1_000 }, + ); + + await provider.create(); + assert.deepEqual(attachments[0], { + ANTHROPIC_API_KEY: attachments[0].ANTHROPIC_API_KEY, + AGENTA_MCP_SECRET_1: attachments[0].AGENTA_MCP_SECRET_1, + AGENTA_MCP_SECRET_2: attachments[0].AGENTA_MCP_SECRET_2, + }); + assert.notEqual(attachments[0].ANTHROPIC_API_KEY, "model-plaintext"); + const materialized = provider.materializeMcpServers([ + { + ...mcpServers[0], + connection: { + ...mcpServers[0].connection, + headers: { "X-Foo": "mcp-public-plaintext" }, + }, + }, + ])!; + assert.equal( + materialized[0].connection.credentials?.[0].value, + "dtn_secret_2", + ); + assert.equal(materialized[0].connection.headers?.["X-Foo"], "dtn_secret_3"); + assert.equal(JSON.stringify(materialized).includes("mcp-plaintext"), false); + assert.equal( + JSON.stringify(materialized).includes("mcp-public-plaintext"), + false, + ); + }); + + it("deletes the sandbox before Secrets on destructive teardown", async () => { + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + secretApi(events), + { registry: new Map(), cleanupDelayMilliseconds: 1_000 }, + ); + const id = await provider.create(); + await provider.destroy(id); + + const destroyIndex = events.indexOf("sandbox:destroy:sandbox-1"); + const firstSecretDelete = events.findIndex((event) => + event.startsWith("secret:delete"), + ); + assert.ok(destroyIndex >= 0 && destroyIndex < firstSecretDelete); + assert.deepEqual(events.slice(firstSecretDelete), [ + "secret:delete:secret-2", + "secret:delete:secret-1", + ]); + }); + + it("retains Secrets when create rejects and remote sandbox absence is unknown", async () => { + const events: string[] = []; + const logs: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + (): DaytonaProviderLike => ({ + name: "daytona", + async create() { + events.push("sandbox:create:remote-created"); + throw new Error("daemon start failed"); + }, + async destroy() { + events.push("sandbox:destroy"); + }, + }), + plan, + secretApi(events), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + log: (message) => logs.push(message), + }, + ); + + await assert.rejects(() => provider.create(), /daemon start failed/); + assert.equal( + events.some((event) => event.startsWith("secret:delete")), + false, + ); + assert.equal(events.includes("sandbox:destroy"), false); + assert.match(logs[0], /retaining 2 Secret allocation/); + }); + + it("deletes Secrets when provider construction proves no remote create started", async () => { + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + (): DaytonaProviderLike => { + throw new Error("provider construction failed"); + }, + plan, + secretApi(events), + { registry: new Map(), cleanupDelayMilliseconds: 1_000 }, + ); + + await assert.rejects( + () => provider.create(), + /provider construction failed/, + ); + assert.deepEqual(events.slice(-2), [ + "secret:delete:secret-2", + "secret:delete:secret-1", + ]); + }); + + it("retains allocation across park/reconnect and cancels timed cleanup", async () => { + vi.useFakeTimers(); + const events: string[] = []; + const registry = new Map(); + const api = secretApi(events); + const first = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + const id = await first.create(); + await first.pause!(id); + + const second = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + await vi.advanceTimersByTimeAsync(500); + await second.reconnect!(id); + await vi.advanceTimersByTimeAsync(1_000); + + assert.equal( + events.some((event) => event.startsWith("secret:delete")), + false, + ); + assert.equal( + second.materializeMcpServers(mcpServers)?.[0].connection.credentials?.[0] + .value, + "dtn_secret_2", + ); + }); + + it("never reconnects concurrently with an already-started timer cleanup", async () => { + vi.useFakeTimers(); + const events: string[] = []; + const registry = new Map(); + const api = secretApi(events); + let cleanupStarted!: () => void; + const cleanupEntered = new Promise((resolve) => { + cleanupStarted = resolve; + }); + let releaseCleanup!: () => void; + const cleanupBlocked = new Promise((resolve) => { + releaseCleanup = resolve; + }); + const first = daytonaWithProcessLocalSecrets( + (attachments): DaytonaProviderLike => ({ + name: "daytona", + async create() { + assert.ok(Object.keys(attachments).length > 0); + return "sandbox-1"; + }, + async pause() { + events.push("sandbox:pause"); + }, + async destroy() { + events.push("sandbox:destroy:start"); + cleanupStarted(); + await cleanupBlocked; + events.push("sandbox:destroy:end"); + }, + }), + plan, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + const id = await first.create(); + await first.pause!(id); + await vi.advanceTimersByTimeAsync(1_000); + await cleanupEntered; + + const second = daytonaWithProcessLocalSecrets( + (): DaytonaProviderLike => ({ + name: "daytona", + async create() { + throw new Error("unused"); + }, + async reconnect() { + events.push("sandbox:reconnect"); + }, + async destroy() { + events.push("sandbox:destroy:second"); + }, + }), + plan, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + const reconnect = second.reconnect!(id); + await Promise.resolve(); + assert.equal( + events.includes("sandbox:reconnect"), + false, + "reconnect waits while timer cleanup owns the lifecycle operation", + ); + + releaseCleanup(); + await assert.rejects( + reconnect, + (error: unknown) => + error instanceof DaytonaReconnectTerminalError && + error.state === "missing-process-local-secret-allocation", + ); + assert.equal(events.includes("sandbox:reconnect"), false); + assert.deepEqual(events.slice(-3), [ + "sandbox:destroy:end", + "secret:delete:secret-2", + "secret:delete:secret-1", + ]); + }); + + it("cleans a parked sandbox and its Secrets slightly after the auto-delete window", async () => { + vi.useFakeTimers(); + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + secretApi(events), + { registry: new Map(), cleanupDelayMilliseconds: 1_000 }, + ); + const id = await provider.create(); + await provider.pause!(id); + await vi.advanceTimersByTimeAsync(999); + assert.equal(events.includes("sandbox:destroy:sandbox-1"), false); + await vi.advanceTimersByTimeAsync(1); + assert.deepEqual(events.slice(-3), [ + "sandbox:destroy:sandbox-1", + "secret:delete:secret-2", + "secret:delete:secret-1", + ]); + }); + + it("deletes and rejects reconnect when the process-local allocation is missing", async () => { + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + secretApi(events), + { registry: new Map(), cleanupDelayMilliseconds: 1_000 }, + ); + await assert.rejects( + () => provider.reconnect!("old-sandbox"), + (error: unknown) => + error instanceof DaytonaReconnectTerminalError && + error.state === "missing-process-local-secret-allocation", + ); + assert.deepEqual(events, ["sandbox:destroy:old-sandbox"]); + }); + + it("deletes the old sandbox and Secrets instead of reconnecting rotated credentials", async () => { + const events: string[] = []; + const registry = new Map(); + const api = secretApi(events); + const first = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + const id = await first.create(); + const rotated: DaytonaSecretPlan = { + ...plan, + candidates: plan.candidates.map((candidate, index) => + index === 0 + ? { ...candidate, value: "rotated-model-plaintext" } + : candidate, + ), + }; + const second = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + rotated, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + + await assert.rejects( + () => second.reconnect!(id), + (error: unknown) => + error instanceof DaytonaReconnectTerminalError && + error.state === "process-local-secret-allocation-mismatch", + ); + assert.equal(events.includes("sandbox:reconnect:sandbox-1"), false); + assert.deepEqual(events.slice(-3), [ + "sandbox:destroy:sandbox-1", + "secret:delete:secret-2", + "secret:delete:secret-1", + ]); + }); + + // Zero-candidate plans ARE a production state: buildRunPlan keeps the (empty) plan on every + // flag-on Daytona run and provider.ts wraps unconditionally on plan presence, so a run whose + // credentials are all local_use/direct still flows through this wrapper — creating no Secrets + // but subjecting every reconnect to the create-fingerprint check. These tests pin exactly the + // fingerprints production computes (`daytonaCreateFingerprint` over `buildDaytonaCreate`). + describe("zero-candidate plan (flag-on run with no opaque credentials)", () => { + const emptyPlan: DaytonaSecretPlan = { environment: {}, candidates: [] }; + const daytonaConfig = parseRunnerConfig({ + AGENTA_RUNNER_DAYTONA_API_KEY: "test-key", + }).daytona; + const fingerprintFor = ( + piExtEnv: Record, + environment: Record, + ) => + daytonaCreateFingerprint({ + create: buildDaytonaCreate( + daytonaConfig, + piExtEnv, + environment, + undefined, + ), + secretPlan: emptyPlan, + }); + + it("creates no Secrets and attaches nothing, end to end", async () => { + const events: string[] = []; + const attachments: any[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events, attachments), + emptyPlan, + secretApi(events), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + createFingerprint: fingerprintFor({}, { AWS_PROFILE: "profile-a" }), + }, + ); + + const id = await provider.create(); + await provider.destroy(id); + + assert.equal( + events.some((event) => event.startsWith("secret:")), + false, + "no Secret is ever created or deleted for a zero-candidate plan", + ); + assert.deepEqual( + attachments[0], + {}, + "sandbox create gets no attachments", + ); + // No http_mcp candidates: MCP servers pass through untouched. + assert.deepEqual(provider.materializeMcpServers(undefined), undefined); + }); + + it("reconnects a parked sandbox when the create fingerprint is unchanged", async () => { + const events: string[] = []; + const registry = new Map(); + const fingerprint = fingerprintFor({}, { AWS_PROFILE: "profile-a" }); + const first = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + emptyPlan, + secretApi(events), + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: fingerprint, + }, + ); + const id = await first.create(); + const second = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + emptyPlan, + secretApi(events), + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: fingerprint, + }, + ); + + await second.reconnect!(id); + + assert.equal(events.includes("sandbox:reconnect:sandbox-1"), true); + assert.equal(events.includes("sandbox:destroy:sandbox-1"), false); + assert.equal( + events.some((event) => event.startsWith("secret:")), + false, + ); + }); + + type FingerprintInputs = [Record, Record]; + const rotations: Array<{ + name: string; + before: FingerprintInputs; + after: FingerprintInputs; + }> = [ + { + name: "local_use credential rotation", + before: [{}, { AWS_REGION: "us-east-1", AWS_PROFILE: "profile-a" }], + after: [{}, { AWS_REGION: "us-east-1", AWS_PROFILE: "profile-b" }], + }, + { + name: "custom endpoint override rotation", + before: [ + { + AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE: + '{"baseUrl":"https://a.test"}', + }, + { AWS_PROFILE: "profile-a" }, + ], + after: [ + { + AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE: + '{"baseUrl":"https://b.test"}', + }, + { AWS_PROFILE: "profile-a" }, + ], + }, + ]; + for (const { name, before, after } of rotations) { + it(`deletes instead of reconnecting after ${name}`, async () => { + const events: string[] = []; + const registry = new Map(); + const first = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + emptyPlan, + secretApi(events), + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: fingerprintFor(...before), + }, + ); + const id = await first.create(); + const second = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + emptyPlan, + secretApi(events), + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: fingerprintFor(...after), + }, + ); + + await assert.rejects( + () => second.reconnect!(id), + (error: unknown) => + error instanceof DaytonaReconnectTerminalError && + error.state === "process-local-secret-allocation-mismatch", + ); + assert.equal(events.includes("sandbox:reconnect:sandbox-1"), false); + assert.equal(events.at(-1), "sandbox:destroy:sandbox-1"); + assert.equal( + events.some((event) => event.startsWith("secret:")), + false, + ); + }); + } + }); +}); diff --git a/services/runner/tests/unit/daytona-secrets.test.ts b/services/runner/tests/unit/daytona-secrets.test.ts new file mode 100644 index 0000000000..e84cb637f8 --- /dev/null +++ b/services/runner/tests/unit/daytona-secrets.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import type { DaytonaSecretPlan } from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; +import { + allocateDaytonaSecrets, + deleteDaytonaSecrets, + type DaytonaSecretApi, +} from "../../src/engines/sandbox_agent/daytona-secrets.ts"; + +const plan: DaytonaSecretPlan = { + environment: {}, + candidates: [ + { + ordinal: 0, + consumer: { kind: "model" }, + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + allowedHost: "api.anthropic.com", + value: "model-plain", + }, + { + ordinal: 1, + consumer: { kind: "http_mcp", server: "linear" }, + binding: { kind: "header", name: "Authorization" }, + allowedHost: "mcp.linear.app", + value: "mcp-plain", + }, + ], +}; + +describe("Daytona Secret allocation", () => { + it("creates exact-host Secrets and returns names plus MCP placeholders", async () => { + const creates: any[] = []; + const api: DaytonaSecretApi = { + async create(input) { + creates.push(input); + return { + id: `id-${creates.length}`, + name: input.name, + placeholder: `dtn_secret_${creates.length}`, + hosts: input.hosts, + }; + }, + async delete() {}, + }; + const allocation = await allocateDaytonaSecrets( + plan, + api, + (candidate) => `agenta_test_${candidate.ordinal}`, + ); + + assert.deepEqual( + creates.map(({ name, value, hosts }) => ({ name, value, hosts })), + [ + { + name: "agenta_test_0", + value: "model-plain", + hosts: ["api.anthropic.com"], + }, + { + name: "agenta_test_1", + value: "mcp-plain", + hosts: ["mcp.linear.app"], + }, + ], + ); + assert.deepEqual(allocation.attachments, { + ANTHROPIC_API_KEY: "agenta_test_0", + AGENTA_MCP_SECRET_1: "agenta_test_1", + }); + assert.deepEqual(allocation.mcpHeaderPlaceholders, { + linear: { Authorization: "dtn_secret_2" }, + }); + }); + + it("compensates created records in reverse order when metadata validation fails", async () => { + const deletes: string[] = []; + let count = 0; + const api: DaytonaSecretApi = { + async create(input) { + count += 1; + return { + id: `id-${count}`, + name: input.name, + placeholder: + count === 2 ? "plaintext-not-placeholder" : `dtn_secret_${count}`, + hosts: input.hosts, + }; + }, + async delete(id) { + deletes.push(id); + }, + }; + + await assert.rejects( + () => + allocateDaytonaSecrets( + plan, + api, + (candidate) => `agenta_test_${candidate.ordinal}`, + ), + /valid opaque Secret placeholder/, + ); + assert.deepEqual(deletes, ["id-2", "id-1"]); + }); + + it("deletes in reverse order and treats 404 as idempotent success", async () => { + const deletes: string[] = []; + const api: DaytonaSecretApi = { + async create() { + throw new Error("unused"); + }, + async delete(id) { + deletes.push(id); + if (id === "id-2") throw { statusCode: 404 }; + }, + }; + await deleteDaytonaSecrets( + { + attachments: {}, + mcpHeaderPlaceholders: {}, + created: [ + { id: "id-1", name: "one", placeholder: "dtn_secret_1" }, + { id: "id-2", name: "two", placeholder: "dtn_secret_2" }, + ], + }, + api, + ); + assert.deepEqual(deletes, ["id-2", "id-1"]); + }); +}); diff --git a/services/runner/tests/unit/extension-tools.test.ts b/services/runner/tests/unit/extension-tools.test.ts index 339ad5a6de..cca77c62f0 100644 --- a/services/runner/tests/unit/extension-tools.test.ts +++ b/services/runner/tests/unit/extension-tools.test.ts @@ -26,6 +26,7 @@ import factory, { readOtlpAuthFile, replaceActiveBuiltinTools, } from "../../src/extensions/agenta.ts"; +import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../src/extensions/model-provider-override.ts"; const TOOL_ENV = [ "AGENTA_AGENT_TOOLS_PUBLIC_SPECS", @@ -37,6 +38,7 @@ const TOOL_ENV = [ "AGENTA_AGENT_CONTENT_CAPTURE_ENABLED", "AGENTA_AGENT_BUILTIN_ACTIVATION", "AGENTA_AGENT_BUILTIN_GATING", + PI_MODEL_PROVIDER_OVERRIDE_ENV, ]; /** A fake extension UI context whose `confirm` records its calls and returns a scripted answer. */ @@ -59,14 +61,19 @@ function fakeDialogCtx(answer: boolean | (() => Promise)) { function fakePi(opts: { activeTools?: string[]; allTools?: string[] } = {}) { const registered: any[] = []; + const registeredProviders: Array<{ name: string; config: unknown }> = []; const handlers: Record = {}; let activeTools = opts.activeTools ?? []; return { registered, + registeredProviders, handlers, registerTool(spec: any) { registered.push(spec); }, + registerProvider(name: string, config: unknown) { + registeredProviders.push({ name, config }); + }, on(event: string, handler: any) { (handlers[event] ??= []).push(handler); }, @@ -88,6 +95,44 @@ function clearEnv() { afterEach(clearEnv); +describe("agenta extension model provider override", () => { + it("overrides the built-in provider during extension initialization", () => { + clearEnv(); + process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = JSON.stringify({ + provider: "anthropic", + baseUrl: "https://proxy.example.test/anthropic", + }); + const pi = fakePi(); + + factory(pi as any); + + assert.deepEqual(pi.registeredProviders, [ + { + name: "anthropic", + config: { baseUrl: "https://proxy.example.test/anthropic" }, + }, + ]); + assert.equal(pi.registered.length, 0); + assert.deepEqual(pi.handlers, {}); + }); + + it("rejects malformed public override config before registration", () => { + clearEnv(); + process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = JSON.stringify({ + provider: "anthropic", + baseUrl: "http://proxy.example.test", + }); + const pi = fakePi(); + + assert.throws(() => factory(pi as any), /must be an HTTPS URL/); + assert.deepEqual(pi.registeredProviders, []); + + process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = ""; + assert.throws(() => factory(pi as any), /must be valid JSON/); + assert.deepEqual(pi.registeredProviders, []); + }); +}); + describe("agenta extension tool registration", () => { it("registers one tool per public spec, schema passed through", () => { clearEnv(); diff --git a/services/runner/tests/unit/mcp-servers.test.ts b/services/runner/tests/unit/mcp-servers.test.ts index 5b6345e49b..28bb4dc51d 100644 --- a/services/runner/tests/unit/mcp-servers.test.ts +++ b/services/runner/tests/unit/mcp-servers.test.ts @@ -1,11 +1,20 @@ -/** External HTTP MCP conversion and SSRF policy. */ +/** External HTTP MCP conversion, typed credential materialization, and SSRF policy. */ import { afterEach, beforeEach, describe, it } from "vitest"; import assert from "node:assert/strict"; import { toAcpMcpServers } from "../../src/engines/sandbox_agent.ts"; -import type { McpServerHttp } from "../../src/engines/sandbox_agent/mcp.ts"; +import { + validateUserMcpServers, + type McpServerHttp, +} from "../../src/engines/sandbox_agent/mcp.ts"; import type { McpServerConfig } from "../../src/protocol.ts"; +const credential = (name = "Authorization", value = "Bearer secret") => ({ + binding: { kind: "header" as const, name }, + value, + usage: "opaque_http" as const, +}); + const http = ( url: string, headers: Record = { Authorization: "Bearer secret" }, @@ -42,12 +51,141 @@ describe("toAcpMcpServers", () => { ); }); + it("combines public headers and secret credential bindings only at the ACP boundary", async () => { + const out = await toAcpMcpServers([ + { + name: "linear", + connection: { + type: "http", + url: "https://93.184.216.34:8443/sse", + headers: { "X-Client": "agenta" }, + credentials: [ + credential("Authorization", "Bearer secret-token-value"), + ], + }, + policy: { tools: { mode: "all" } }, + }, + ]); + const server = out[0] as McpServerHttp; + assert.equal( + server.url, + "https://93.184.216.34:8443/sse", + "exact URL and port preserved", + ); + assert.deepEqual(server.headers, [ + { name: "X-Client", value: "agenta" }, + { name: "Authorization", value: "Bearer secret-token-value" }, + ]); + }); + it("delivers an http server with no secrets as an empty header list", async () => { const out = await toAcpMcpServers(http("https://93.184.216.34/sse", {})); const server = out[0] as McpServerHttp; assert.equal(server.type, "http"); assert.deepEqual(server.headers, [], "no headers stays empty"); }); + + it("skips a url-less server and still delivers the valid one", async () => { + const out = await toAcpMcpServers([ + { + name: "remote", + connection: { + type: "http", + url: "https://93.184.216.34/mcp", + credentials: [credential("X-Api-Key", "k-secret")], + }, + policy: { tools: { mode: "all" } }, + }, + // A url-less server was never deliverable and is skipped, not fatal. + { + name: "no-url", + connection: { type: "http" }, + policy: { tools: { mode: "all" } }, + } as unknown as McpServerConfig, + ]); + assert.deepEqual( + out.map((server) => server.name), + ["remote"], + ); + }); +}); + +describe("validateUserMcpServers structural validation", () => { + const server = ( + connection: Partial, + ): McpServerConfig[] => [ + { + name: "s", + connection: { + type: "http", + url: "https://93.184.216.34", + ...connection, + }, + policy: { tools: { mode: "all" } }, + }, + ]; + + it("requires a url", async () => { + await assert.rejects( + () => + validateUserMcpServers([ + { + name: "no-url", + connection: { type: "http" }, + policy: { tools: { mode: "all" } }, + } as unknown as McpServerConfig, + ]), + /requires url/, + ); + }); + + it("rejects empty or malformed credentials and headers", async () => { + const cases: McpServerConfig[][] = [ + server({ headers: { "": "x" } }), + server({ headers: { X: "" } }), + server({ credentials: [credential("", "secret")] }), + server({ credentials: [credential("X", "")] }), + server({ + credentials: [ + { ...credential("X", "secret"), usage: "environment" as never }, + ], + }), + server({ + credentials: [ + { + ...credential("X", "secret"), + binding: { kind: "environment" as never, name: "X" }, + }, + ], + }), + ]; + for (const servers of cases) + await assert.rejects(() => validateUserMcpServers(servers)); + }); + + it("rejects duplicate public and secret bindings case-insensitively", async () => { + await assert.rejects( + () => + validateUserMcpServers( + server({ + headers: { Authorization: "public" }, + credentials: [credential("authorization", "secret")], + }), + ), + /duplicate HTTP MCP header binding/, + ); + }); + + it("accepts a well-formed credentialed server", async () => { + await assert.doesNotReject(() => + validateUserMcpServers( + server({ + headers: { "X-Client": "agenta" }, + credentials: [credential()], + }), + ), + ); + }); }); describe("toAcpMcpServers SSRF guard (http url scheme/host)", () => { @@ -122,6 +260,24 @@ describe("toAcpMcpServers SSRF guard (http url scheme/host)", () => { assert.equal((out[0] as McpServerHttp).url, "https://93.184.216.34/sse"); }); + it("guards a credentialed server the same way (typed credentials ride headers)", async () => { + await assert.rejects( + () => + toAcpMcpServers([ + { + name: "s", + connection: { + type: "http", + url: "https://169.254.169.254/mcp", + credentials: [credential()], + }, + policy: { tools: { mode: "all" } }, + }, + ]), + /internal\/metadata host/, + ); + }); + it("allowlist opts a host out of the https + internal-host checks", async () => { process.env.AGENTA_AGENT_MCPS_HOST_ALLOWLIST = "localhost,10.0.0.5"; // http://localhost is normally rejected twice over (non-https + internal); allowlisted -> ok. diff --git a/services/runner/tests/unit/redaction-sinks.test.ts b/services/runner/tests/unit/redaction-sinks.test.ts index 191d11002a..5d1f0ce991 100644 --- a/services/runner/tests/unit/redaction-sinks.test.ts +++ b/services/runner/tests/unit/redaction-sinks.test.ts @@ -3,10 +3,11 @@ * durable/exported sinks — the persisted transcript (WP1.4) and the exported OTel spans (WP1.5), * seeded from the per-run deny-set (WP1.1). * - * The load-bearing case is the seed SOURCE. A run's provider keys ride `secrets` on the wire and - * are applied per run (`buildDaemonEnv`); they are never in the sidecar's own process env. A - * redactor seeded only from process env would therefore look wired and catch nothing that matters, - * so these tests plant a key that exists ONLY in the request and assert it is scrubbed. + * The load-bearing case is the seed SOURCE. A run's provider keys ride the typed + * `modelConnection.credentials` (and user MCP `connection.credentials`) on the wire and are + * applied per run (`buildDaemonEnv`); they are never in the sidecar's own process env. A + * redactor seeded only from process env would therefore look wired and catch nothing that + * matters, so these tests plant a key that exists ONLY in the request and assert it is scrubbed. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; @@ -17,14 +18,45 @@ import { seedForRun } from "../../src/redaction.ts"; import { buildPersistingEmitter } from "../../src/sessions/persist.ts"; import { createSandboxAgentOtel } from "../../src/tracing/otel.ts"; -/** A per-run provider key: present in the request's `secrets`, never in `process.env`. */ +/** A per-run provider key: a typed `modelConnection` credential, never in `process.env`. */ const PER_RUN_KEY = "sk-per-run-fake-key-DO-NOT-USE-a1b2c3d4e5f6"; +/** A typed user HTTP-MCP header credential, also request-only material. */ +const MCP_HEADER_KEY = "Bearer mcp-per-run-fake-DO-NOT-USE-f6e5d4"; /** The invoke caller's credential, which rides the OTLP auth header. */ const RUN_CREDENTIAL = "ApiKey ag-run-cred-9f8e7d6c5b4a"; -/** A request carrying this run's resolved provider key + run credential. */ +/** A request carrying this run's resolved typed credentials + run credential. */ const runRequest = { - secrets: { OPENAI_API_KEY: PER_RUN_KEY }, + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: PER_RUN_KEY, + usage: "opaque_http", + }, + ], + }, + mcpServers: [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/sse", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value: MCP_HEADER_KEY, + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, + ], telemetry: { exporters: { otlp: { headers: { authorization: RUN_CREDENTIAL } } }, }, @@ -101,6 +133,13 @@ describe("seedForRun (WP1.1 — the deny-set source)", () => { redactor.redactString(`called back with ${RUN_CREDENTIAL}`, "test"), ).not.toContain("ag-run-cred-9f8e7d6c5b4a"); }); + + it("seeds typed user HTTP-MCP header credentials", () => { + const redactor = seedForRun(runRequest); + expect( + redactor.redactString(`header was ${MCP_HEADER_KEY}`, "test"), + ).not.toContain(MCP_HEADER_KEY); + }); }); describe("persisted transcript sink (WP1.4)", () => { @@ -306,7 +345,9 @@ describe("exported span sink (WP1.5)", () => { emitSpans: false, // span-less: only recordError's standalone-span path emits here endpoint: "http://127.0.0.1:1/v1/traces", traceparent: sharedTraceparent, - redactor: seedForRun({ secrets: { A_KEY: SECRET_A } }), + redactor: seedForRun({ + modelConnection: { credentials: [{ value: SECRET_A }] }, + }), }); const runB = createSandboxAgentOtel({ harness: "claude", @@ -314,7 +355,9 @@ describe("exported span sink (WP1.5)", () => { emitSpans: false, endpoint: "http://127.0.0.1:1/v1/traces", traceparent: sharedTraceparent, - redactor: seedForRun({ secrets: { B_KEY: SECRET_B } }), + redactor: seedForRun({ + modelConnection: { credentials: [{ value: SECRET_B }] }, + }), }); runA.start({ prompt: "a" }); diff --git a/services/runner/tests/unit/sandbox-agent-daemon.test.ts b/services/runner/tests/unit/sandbox-agent-daemon.test.ts index be05a9c9c5..c834b8d006 100644 --- a/services/runner/tests/unit/sandbox-agent-daemon.test.ts +++ b/services/runner/tests/unit/sandbox-agent-daemon.test.ts @@ -104,7 +104,7 @@ describe("buildDaemonEnv", () => { process.env.COMPOSIO_API_KEY = "composio"; process.env.DAYTONA_API_KEY = "daytona"; - // Default (clearProviderEnv: false) = a runtime_provided / un-migrated run: keep the + // Default (clearProviderEnv: false) = a runtime_provided / no-modelConnection run: keep the // sidecar's own provider/auth keys so the harness login still works. const env = buildDaemonEnv("claude"); diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index c1cffcc30a..ec0c81d9e9 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -101,6 +101,7 @@ function fakeHarness(options: FakeOptions = {}) { runFinished: 0, runFlushed: 0, recordedErrors: [] as Array<{ message: string; provider?: string }>, + handledUpdates: [] as unknown[], }; const events: AgentEvent[] = []; const logs: string[] = []; @@ -193,7 +194,9 @@ function fakeHarness(options: FakeOptions = {}) { start(input: any) { calls.runStart = input; }, - handleUpdate(_update: any) {}, + handleUpdate(update: any) { + calls.handledUpdates.push(update); + }, emitEvent(event: AgentEvent) { events.push(event); }, @@ -344,6 +347,11 @@ describe("PendingApprovalPauseController", () => { }); describe("runSandboxAgent orchestration", () => { + // NOTE: in-band redaction of the LIVE event stream / result / trace-start input was a + // daytona-secret-materialization concept that was not adopted. Redaction happens at the + // durable/exported sinks (persisted transcript + exported spans; see redaction-sinks.test.ts), + // seeded per run from the typed model and MCP credentials. + it("returns a successful one-shot result and cleans up acquired resources", async () => { const { calls, deps } = fakeHarness(); @@ -735,6 +743,8 @@ describe("runSandboxAgent orchestration", () => { // endpoint. The runner must pass the exact `/` so it always wins. const { calls, deps } = fakeHarness(); deps.prepareDaytonaPiAssets = (async () => true) as any; + // The opaque vault key on a Daytona run requires the process-local Secret gate. + process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = "process_local"; const result = await runSandboxAgent( { @@ -742,12 +752,20 @@ describe("runSandboxAgent orchestration", () => { sandbox: "daytona", messages: [{ role: "user", content: "hello" }], model: "gpt-4o", - provider: "openai", - deployment: "custom", connection: { mode: "agenta", slug: "my-conn" }, - endpoint: { baseUrl: "https://proxy.test/v1" }, - credentialMode: "env", - secrets: { OPENAI_API_KEY: "sk-vault-xyz" }, + modelConnection: { + provider: "openai", + deployment: "custom", + endpoint: { baseUrl: "https://proxy.test/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "sk-vault-xyz", + usage: "opaque_http", + }, + ], + }, }, undefined, undefined, @@ -1728,9 +1746,19 @@ describe("runSandboxAgent orchestration", () => { { harness: "claude", messages: [{ role: "user", content: "hello" }], - credentialMode: "env", - secrets: { ANTHROPIC_API_KEY: "resolved" }, - endpoint: { baseUrl: "https://claude-gw.example/v1" }, + modelConnection: { + provider: "anthropic", + deployment: "custom", + endpoint: { baseUrl: "https://claude-gw.example/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "resolved", + usage: "opaque_http", + }, + ], + }, } as AgentRunRequest, undefined, undefined, @@ -1807,10 +1835,22 @@ describe("runSandboxAgent orchestration", () => { harness: "claude", messages: [{ role: "user", content: "hello" }], model: "anthropic.claude-x", - deployment: "bedrock", - credentialMode: "env", - secrets: { AWS_ACCESS_KEY_ID: "AKIA" }, - endpoint: { region: "us-east-1" }, + modelConnection: { + provider: "anthropic", + deployment: "bedrock", + endpoint: { + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + }, + credentialMode: "env", + environment: { AWS_REGION: "us-east-1" }, + credentials: [ + { + binding: { kind: "environment", name: "AWS_ACCESS_KEY_ID" }, + value: "AKIA", + usage: "local_use", + }, + ], + }, } as AgentRunRequest, undefined, undefined, @@ -1838,11 +1878,28 @@ describe("runSandboxAgent orchestration", () => { harness: "claude", messages: [{ role: "user", content: "hello" }], model: "claude-sonnet-4", - deployment: "vertex_ai", - credentialMode: "env", - secrets: { - GOOGLE_CLOUD_PROJECT: "proj", - GOOGLE_CLOUD_LOCATION: "us-central1", + modelConnection: { + provider: "anthropic", + deployment: "vertex_ai", + endpoint: { + baseUrl: "https://us-central1-aiplatform.googleapis.com", + region: "us-central1", + }, + credentialMode: "env", + environment: { + GOOGLE_CLOUD_PROJECT: "proj", + GOOGLE_CLOUD_LOCATION: "us-central1", + }, + credentials: [ + { + binding: { + kind: "environment", + name: "GOOGLE_APPLICATION_CREDENTIALS", + }, + value: "/tmp/adc.json", + usage: "local_use", + }, + ], }, } as AgentRunRequest, undefined, @@ -1870,7 +1927,12 @@ describe("runSandboxAgent orchestration", () => { { harness: "claude", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "anthropic", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, } as AgentRunRequest, undefined, undefined, @@ -1902,9 +1964,12 @@ describe("runSandboxAgent orchestration", () => { { harness: "claude", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", - provider: "anthropic", - deployment: "bedrock", + modelConnection: { + provider: "anthropic", + deployment: "bedrock", + credentialMode: "runtime_provided", + credentials: [], + }, } as AgentRunRequest, undefined, undefined, @@ -1920,6 +1985,29 @@ describe("runSandboxAgent orchestration", () => { assert.equal(calls.daemonOptions?.provider, "anthropic"); assert.equal(calls.daemonOptions?.deployment, "bedrock"); }); + + it("clears inherited provider env when the resolved credential mode is none", async () => { + const { calls, deps } = fakeHarness(); + const result = await runSandboxAgent( + { + harness: "claude", + messages: [{ role: "user", content: "hello" }], + modelConnection: { + provider: "anthropic", + deployment: "direct", + credentialMode: "none", + credentials: [], + }, + }, + undefined, + undefined, + deps, + ); + assert.equal(result.ok, true); + // "none" means the run declared it uses NO ambient credential: the daemon must not inherit + // the sidecar's own provider keys either (only runtime_provided keeps them). + assert.equal(calls.daemonOptions?.clearProviderEnv, true); + }); }); // These exercise the engine's default ApprovalResponder by dropping the `responderFactory` @@ -2186,7 +2274,10 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { sessionUpdate: "tool_call", toolCallId: "tool-late", title: "request_connection", - rawInput: { integration: "slack" }, + rawInput: { + integration: "slack", + token: "marker-late-secret-9a21", + }, }, }, }, @@ -2201,6 +2292,19 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { harness: "claude", permissions: { default: "ask" }, messages: [{ role: "user", content: "commit and connect" }], + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "marker-late-secret-9a21", + usage: "opaque_http", + }, + ], + }, } as AgentRunRequest, undefined, undefined, @@ -2241,21 +2345,23 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { }, }, }, + ], + emitPermission: true, + permissionToolCallId: "tool-a", + permissionToolName: "commit_revision", + permissionRawInput: { revision: "r1" }, + postPermissionEvents: [ { payload: { update: { sessionUpdate: "tool_call", toolCallId: "tool-b", title: "create_subscription", - rawInput: { plan: "pro" }, + rawInput: { plan: "pro", token: "marker-live-secret-42bd" }, }, }, }, ], - emitPermission: true, - permissionToolCallId: "tool-a", - permissionToolName: "commit_revision", - permissionRawInput: { revision: "r1" }, hangPrompt: true, }); delete deps.responderFactory; @@ -2266,6 +2372,19 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { harness: "claude", permissions: { default: "ask" }, messages: [{ role: "user", content: "commit and subscribe" }], + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "marker-live-secret-42bd", + usage: "opaque_http", + }, + ], + }, } as AgentRunRequest, (event) => emitted.push(event), undefined, @@ -2277,22 +2396,49 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { const interactionIndex = emitted.findIndex( (event) => event.type === "interaction_request", ); + const siblingCallIndex = emitted.findIndex( + (event) => event.type === "tool_call" && (event as any).id === "tool-b", + ); const siblingResultIndex = emitted.findIndex( (event) => event.type === "tool_result" && (event as any).id === "tool-b", ); const doneIndex = emitted.findIndex((event) => event.type === "done"); assert.notEqual(interactionIndex, -1, "approval request is emitted"); + assert.notEqual(siblingCallIndex, -1, "late sibling call is emitted"); assert.notEqual(siblingResultIndex, -1, "sibling result is emitted"); assert.notEqual(doneIndex, -1, "done is emitted"); assert.ok( - interactionIndex < siblingResultIndex, - "approval request is emitted before the teardown sweep runs", + interactionIndex < siblingCallIndex && + siblingCallIndex < siblingResultIndex, + "the queued late call is emitted and settled after the approval request", ); assert.ok( siblingResultIndex < doneIndex, "sibling result reaches the live sink before turn finish", ); + assert.equal( + emitted.filter( + (event) => + event.type === "tool_result" && (event as any).id === "tool-b", + ).length, + 1, + "the late sibling is settled exactly once", + ); + assert.equal( + emitted.some( + (event) => + event.type === "tool_result" && (event as any).id === "tool-a", + ), + false, + "the gated call remains open for human approval", + ); + await flushPromises(); + assert.equal( + emitted.at(-1)?.type, + "done", + "done remains terminal after another event-loop turn", + ); }); it("drops teardown tool updates after a Pi approval pause while keeping other ids", async () => { diff --git a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts index 24106b9a10..690723e53e 100644 --- a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts +++ b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts @@ -19,6 +19,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentRunRequest } from "../../src/protocol.ts"; +import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../src/extensions/model-provider-override.ts"; import { buildPiExtensionEnv, configurePiSkillSnapshot, @@ -91,6 +92,76 @@ afterEach(() => { }); describe("buildPiExtensionEnv", () => { + it("carries only public provider endpoint config for Pi", () => { + const request = { + modelConnection: { + provider: "anthropic", + deployment: "claude-sonnet-4-5", + endpoint: { + baseUrl: "https://proxy.example.test/anthropic", + headers: { Authorization: "Bearer do-not-expose" }, + }, + credentialMode: "env", + environment: { PUBLIC_HINT: "not-needed-by-extension" }, + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "secret-model-key", + usage: "local_use", + }, + ], + }, + } as AgentRunRequest; + + const env = buildPiExtensionEnv(request, false); + + assert.deepEqual(JSON.parse(env[PI_MODEL_PROVIDER_OVERRIDE_ENV]), { + provider: "anthropic", + baseUrl: "https://proxy.example.test/anthropic", + }); + assert.equal(JSON.stringify(env).includes("do-not-expose"), false); + assert.equal(JSON.stringify(env).includes("secret-model-key"), false); + assert.equal(JSON.stringify(env).includes("PUBLIC_HINT"), false); + }); + + it("rejects malformed provider endpoint overrides", () => { + const request = (provider: string, baseUrl: string) => + ({ + modelConnection: { + provider, + deployment: "model", + endpoint: { baseUrl }, + credentialMode: "none", + credentials: [], + }, + }) as AgentRunRequest; + + assert.throws( + () => + buildPiExtensionEnv( + request("bad/provider", "https://proxy.example.test"), + false, + ), + /invalid provider/, + ); + assert.throws( + () => + buildPiExtensionEnv( + request("anthropic", "http://proxy.example.test"), + false, + ), + /must be an HTTPS URL/, + ); + assert.throws( + () => + buildPiExtensionEnv( + request("anthropic", "https://user:pass@proxy.example.test"), + false, + ), + /without credentials/, + ); + }); + it("exposes tracing, usage, and public tool metadata only", () => { const request = { context: { @@ -198,6 +269,7 @@ describe("buildPiExtensionEnv", () => { assert.equal(env.TRACEPARENT, undefined); assert.equal(env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS, undefined); assert.equal(env.AGENTA_AGENT_TOOLS_RELAY_DIR, undefined); + assert.equal(env[PI_MODEL_PROVIDER_OVERRIDE_ENV], undefined); }); it("sets builtin gating env WITHOUT a relay dir (the gate rides the ACP dialog plane)", () => { @@ -745,6 +817,7 @@ describe("prepareLocalPiAssets (runtime_provided runs out of the mount, read-wri assert.equal(env.PI_CODING_AGENT_DIR, runDir); dirs.push(runDir as string); }); + }); describe("sandbox uploads", () => { diff --git a/services/runner/tests/unit/sandbox-agent-pi-model-config.test.ts b/services/runner/tests/unit/sandbox-agent-pi-model-config.test.ts index dbbb61ff4e..68e16a95b4 100644 --- a/services/runner/tests/unit/sandbox-agent-pi-model-config.test.ts +++ b/services/runner/tests/unit/sandbox-agent-pi-model-config.test.ts @@ -18,17 +18,31 @@ import { const RAW_KEY = "sk-super-secret-value-do-not-leak"; +/** Flattened overrides: the resolved routing fields live on `modelConnection` on the wire. */ +interface RequestOverrides { + harness?: string; + provider?: string; + deployment?: string; + endpoint?: { baseUrl?: string }; + credentialMode?: "env" | "runtime_provided" | "none"; + connection?: AgentRunRequest["connection"]; + model?: string; +} + /** A complete, applicable managed OpenAI-compatible custom Pi request. */ -function completeRequest(over: Partial = {}): AgentRunRequest { +function completeRequest(over: RequestOverrides = {}): AgentRunRequest { return { - harness: "pi_core", - provider: "openai", - deployment: "custom", - connection: { mode: "agenta", slug: "my-ollama" }, - endpoint: { baseUrl: "https://example.test/v1" }, - credentialMode: "env", - model: "qwen2.5-coder:7b", - ...over, + harness: "harness" in over ? over.harness : "pi_core", + connection: + "connection" in over ? over.connection : { mode: "agenta", slug: "my-ollama" }, + model: over.model ?? "qwen2.5-coder:7b", + modelConnection: { + provider: over.provider ?? "openai", + deployment: over.deployment ?? "custom", + endpoint: over.endpoint ?? { baseUrl: "https://example.test/v1" }, + credentialMode: over.credentialMode ?? "env", + credentials: [], + }, }; } @@ -137,7 +151,7 @@ describe("buildPiModelConfigPlan (non-applicable -> no plan, current behavior)", describe("buildPiModelConfigPlan (applicable but incomplete -> typed error)", () => { const cases: Array<{ name: string; - over: Partial; + over: RequestOverrides; secrets?: Record; hint: RegExp; }> = [ diff --git a/services/runner/tests/unit/sandbox-agent-provider.test.ts b/services/runner/tests/unit/sandbox-agent-provider.test.ts index 625cddbd2e..fe6aef56e9 100644 --- a/services/runner/tests/unit/sandbox-agent-provider.test.ts +++ b/services/runner/tests/unit/sandbox-agent-provider.test.ts @@ -14,8 +14,14 @@ import assert from "node:assert/strict"; import { buildDaytonaCreate, buildSandboxProvider, + daytonaCreateFingerprint, daytonaNetworkFields, } from "../../src/engines/sandbox_agent/provider.ts"; +import { buildDaytonaSecretPlan } from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; +import { + DAYTONA_PI_COMMAND, + DAYTONA_PI_DIR, +} from "../../src/engines/sandbox_agent/daytona.ts"; import { DEFAULT_DAYTONA_AUTOSTOP_MINUTES, DEFAULT_DAYTONA_AUTODELETE_MINUTES, @@ -85,7 +91,95 @@ describe("daytonaNetworkFields", () => { }); }); +describe("daytonaCreateFingerprint", () => { + const secretPlan = { + environment: {}, + candidates: [], + }; + + it("changes for local_use values and Pi custom endpoint routing", () => { + const fingerprint = ( + piExtEnv: Record, + environment: Record, + ) => + daytonaCreateFingerprint({ + image: "runner-image", + create: buildDaytonaCreate( + daytonaConfig(), + piExtEnv, + environment, + undefined, + ), + secretPlan, + }); + + const base = fingerprint( + { AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE: '{"baseUrl":"https://a.test"}' }, + { AWS_PROFILE: "profile-a" }, + ); + assert.notEqual( + base, + fingerprint( + { + AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE: '{"baseUrl":"https://a.test"}', + }, + { AWS_PROFILE: "profile-b" }, + ), + ); + assert.notEqual( + base, + fingerprint( + { + AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE: '{"baseUrl":"https://b.test"}', + }, + { AWS_PROFILE: "profile-a" }, + ), + ); + }); +}); + describe("buildDaytonaCreate (lifecycle + artifact on the create object)", () => { + it("carries Secret names separately and never puts opaque plaintext in env/config", () => { + const opaque = "marker-opaque-plaintext"; + const plan = buildDaytonaSecretPlan({ + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: opaque, + usage: "opaque_http", + }, + ], + }, + }); + const create = buildDaytonaCreate( + daytonaConfig(), + { PUBLIC_EXTENSION_CONFIG: "enabled" }, + { ...plan.environment, AWS_REGION: "us-east-1" }, + undefined, + { ANTHROPIC_API_KEY: "agenta_random_secret_name" }, + ); + assert.deepEqual(create.secrets, { + ANTHROPIC_API_KEY: "agenta_random_secret_name", + }); + assert.deepEqual(create.envVars, { + PI_CODING_AGENT_DIR: DAYTONA_PI_DIR, + PUBLIC_EXTENSION_CONFIG: "enabled", + AWS_REGION: "us-east-1", + PI_ACP_PI_COMMAND: DAYTONA_PI_COMMAND, + }); + assert.equal(JSON.stringify(create).includes(opaque), false); + }); + + it("omits the secrets field when no Secret attachments exist", () => { + const create = buildDaytonaCreate(daytonaConfig(), {}, {}, undefined, {}); + assert.equal("secrets" in create, false); + }); + it("carries stop and delete intervals without auto-archive by default", () => { const create = buildDaytonaCreate(daytonaConfig(), {}, {}, undefined); // ephemeral:false so a stop PARKS (warm) instead of deleting; the intervals are the reapers. @@ -147,6 +241,7 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () {}, {}, undefined, + undefined, localOnly, ), /Unknown sandbox id 'typo-sandbox'/, @@ -155,7 +250,16 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () it("resolves 'local' without refusing", () => { assert.doesNotThrow(() => - buildSandboxProvider("local", {}, undefined, {}, {}, undefined, localOnly), + buildSandboxProvider( + "local", + {}, + undefined, + {}, + {}, + undefined, + undefined, + localOnly, + ), ); }); @@ -169,6 +273,7 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () {}, {}, undefined, + undefined, localOnly, ), /not enabled on this deployment/, @@ -184,8 +289,72 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () {}, {}, undefined, + undefined, runnerConfig("local,daytona"), ), ); }); + + it("wraps Daytona with process-local Secrets for EVERY plan-bearing run, zero candidates included", () => { + const plan = buildDaytonaSecretPlan({ + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "opaque", + usage: "opaque_http", + }, + ], + }, + }); + const build = (secretPlan?: typeof plan) => + buildSandboxProvider( + "daytona", + {}, + undefined, + {}, + {}, + undefined, + secretPlan, + runnerConfig("local,daytona"), + ) as { materializeMcpServers?: unknown }; + + // Flag OFF (hermetic default): a flag-off run never carries a plan (buildRunPlan builds one + // only when the flag is on), and the plain provider is unchanged from main. + assert.equal( + typeof build(undefined).materializeMcpServers, + "undefined", + "flag off (no plan) stays on the plain provider", + ); + // Defense-in-depth: a direct caller handing a candidate-bearing plan while the flag is off + // is refused — that plan's environment already dropped the opaque values, so proceeding + // unwrapped would silently run without credentials. + assert.throws( + () => build(plan), + /AGENTA_DAYTONA_OPAQUE_SECRETS=process_local/, + ); + + try { + process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = "process_local"; + assert.equal( + typeof build(plan).materializeMcpServers, + "function", + "flag on + candidates attaches the Secret wrapper", + ); + // Zero candidates must ALSO wrap: buildRunPlan keeps the empty plan on every flag-on + // Daytona run precisely so the wrapper's create-fingerprint check governs reconnects + // (a rotated local_use credential forces a rebuild, never a stale plaintext reconnect). + assert.equal( + typeof build(buildDaytonaSecretPlan({})).materializeMcpServers, + "function", + "flag on + zero candidates still attaches the Secret wrapper", + ); + } finally { + delete process.env.AGENTA_DAYTONA_OPAQUE_SECRETS; + } + }); }); 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 5ebc7b19c2..f2d55664da 100644 --- a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts +++ b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts @@ -17,6 +17,7 @@ import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; const previousPiDir = process.env.PI_CODING_AGENT_DIR; const previousDenyPermissions = process.env.SANDBOX_AGENT_DENY_PERMISSIONS; +const previousOpaqueSecrets = process.env.AGENTA_DAYTONA_OPAQUE_SECRETS; // 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. @@ -32,6 +33,9 @@ afterEach(() => { if (previousDenyPermissions === undefined) delete process.env.SANDBOX_AGENT_DENY_PERMISSIONS; else process.env.SANDBOX_AGENT_DENY_PERMISSIONS = previousDenyPermissions; + if (previousOpaqueSecrets === undefined) + delete process.env.AGENTA_DAYTONA_OPAQUE_SECRETS; + else process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = previousOpaqueSecrets; }); describe("buildRunPlan", () => { @@ -319,7 +323,19 @@ describe("buildRunPlan", () => { skills: [ { name: "alpha", description: "Alpha skill.", body: "Do alpha." }, ], - secrets: { OPENAI_API_KEY: "key" }, + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "key", + usage: "opaque_http", + }, + ], + }, } as AgentRunRequest, { createLocalCwd: () => "/tmp/local-cwd", @@ -354,6 +370,7 @@ describe("buildRunPlan", () => { assert.equal(result.plan.appendSystemPrompt, "append"); assert.equal(result.plan.hasSystemPrompt, true); assert.equal(result.plan.hasApiKey, true); + assert.deepEqual(result.plan.modelEnvironment, { OPENAI_API_KEY: "key" }); assert.equal(result.plan.sourcePiAgentDir, "/tmp/pi-agent"); assert.deepEqual( result.plan.executableToolSpecs.map((tool) => tool.name), @@ -877,10 +894,7 @@ describe("buildRunPlan", () => { result.plan.executableToolSpecs.map((tool) => tool.name), ["server_tool"], ); - assert.equal( - result.plan.clientToolPauseDisposition, - "cold-acknowledge", - ); + assert.equal(result.plan.clientToolPauseDisposition, "cold-acknowledge"); }); it("allows claude x daytona x client-ONLY tools (the shim advertises them and the relay parks)", () => { @@ -1131,13 +1145,25 @@ describe("buildRunPlan", () => { }); it("normalizes a Daytona Claude run without Pi-only state", () => { + process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = "process_local"; const result = buildRunPlan( { harness: "claude", sandbox: "daytona", messages: [{ role: "user", content: "hello" }], - secrets: { ANTHROPIC_API_KEY: "anthropic" }, - credentialMode: "env", + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "anthropic", + usage: "opaque_http", + }, + ], + }, systemPrompt: "ignored for non-pi", }, { @@ -1152,14 +1178,75 @@ describe("buildRunPlan", () => { assert.equal(result.plan.isDaytona, true); assert.equal(result.plan.cwd, "/home/sandbox/agenta-fixed"); assert.equal(result.plan.usageOutPath, undefined); - assert.equal(result.plan.legacyHarnessApiKeyVar, "ANTHROPIC_API_KEY"); + assert.equal(result.plan.harnessApiKeyVar, "ANTHROPIC_API_KEY"); + // The FULL materialized environment sets hasApiKey: on a Daytona Secrets run the opaque key + // leaves the plaintext env for the secret plan, but the harness still receives its binding. assert.equal(result.plan.hasApiKey, true); + assert.equal(result.plan.modelEnvironment.ANTHROPIC_API_KEY, undefined); + assert.equal(result.plan.daytonaSecretPlan?.candidates.length, 1); // The resolved credentialMode is carried onto the plan (drives clear-then-apply). assert.equal(result.plan.credentialMode, "env"); assert.equal(result.plan.systemPrompt, undefined); assert.equal(result.plan.hasSystemPrompt, false); assert.deepEqual(result.plan.skillDirs, []); }); + + it("keeps a zero-candidate secret plan when the flag is on, and none when it is off", () => { + // A run with only local_use credentials produces ZERO opaque candidates. The plan must + // still ride the run plan when the flag is on: provider.ts applies the Secret wrapper off + // plan PRESENCE, and only the wrapper's create-fingerprint check forces a rebuild (instead + // of a stale plaintext reconnect) after the local_use credentials rotate. + const localUseRequest = { + harness: "claude", + sandbox: "daytona", + messages: [{ role: "user", content: "hello" }], + modelConnection: { + provider: "bedrock", + deployment: "bedrock", + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "AWS_ACCESS_KEY_ID" }, + value: "AKIA-local-use", + usage: "local_use", + }, + { + binding: { kind: "environment", name: "AWS_SECRET_ACCESS_KEY" }, + value: "aws-secret-local-use", + usage: "local_use", + }, + ], + }, + } as AgentRunRequest; + const deps = { createDaytonaCwd: () => "/home/sandbox/agenta-fixed" }; + + process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = "process_local"; + const flagOn = buildRunPlan(localUseRequest, deps); + assert.equal(flagOn.ok, true); + if (!flagOn.ok) return; + assert.ok(flagOn.plan.daytonaSecretPlan, "flag on keeps the empty plan"); + assert.equal(flagOn.plan.daytonaSecretPlan.candidates.length, 0); + // local_use values still reach sandbox create as plaintext env (by design). + assert.equal( + flagOn.plan.modelEnvironment.AWS_ACCESS_KEY_ID, + "AKIA-local-use", + ); + assert.equal( + flagOn.plan.modelEnvironment.AWS_SECRET_ACCESS_KEY, + "aws-secret-local-use", + ); + + // Flag OFF stays exactly the pre-feature behavior: no plan, so no wrapper is applied. + delete process.env.AGENTA_DAYTONA_OPAQUE_SECRETS; + const flagOff = buildRunPlan(localUseRequest, deps); + assert.equal(flagOff.ok, true); + if (!flagOff.ok) return; + assert.equal(flagOff.plan.daytonaSecretPlan, undefined); + assert.equal( + flagOff.plan.modelEnvironment.AWS_ACCESS_KEY_ID, + "AKIA-local-use", + ); + }); }); describe("buildRunPlan durableCwd (prefix-derived cwd)", () => { @@ -1300,7 +1387,12 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "pi_core", sandbox: "daytona", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "openai", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, }, { createDaytonaCwd: () => { @@ -1321,7 +1413,12 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "pi_core", sandbox: "local", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "openai", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, }); assert.equal(result.ok, false); if (result.ok) return; @@ -1335,7 +1432,12 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "claude", sandbox: "local", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "anthropic", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, }); assert.equal(result.ok, false); if (result.ok) return; @@ -1349,7 +1451,12 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "pi_core", sandbox: "local", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "openai", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, }); assert.equal(result.ok, true); if (!result.ok) return; @@ -1364,10 +1471,210 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "pi_core", sandbox: "local", messages: [{ role: "user", content: "hello" }], - secrets: { OPENAI_API_KEY: "sk-test" }, - credentialMode: "env", + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "sk-test", + usage: "opaque_http", + }, + ], + }, }); assert.equal(result.ok, true); }); }); }); + +describe("modelConnection validation", () => { + const base = { + harness: "pi_core", + messages: [{ role: "user", content: "hi" }], + } satisfies AgentRunRequest; + + const connection = (overrides: Record = {}) => ({ + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "key", + usage: "opaque_http", + }, + ], + ...overrides, + }); + + for (const [name, modelConnection, error] of [ + [ + "rejects an empty binding name", + connection({ + credentials: [ + { + binding: { kind: "environment", name: "" }, + value: "key", + usage: "opaque_http", + }, + ], + }), + "modelConnection credential binding and value must be non-empty", + ], + [ + "rejects an empty credential value", + connection({ + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "", + usage: "opaque_http", + }, + ], + }), + "modelConnection credential binding and value must be non-empty", + ], + [ + "rejects opaque HTTP credentials without an endpoint", + connection({ endpoint: undefined }), + "opaque_http model credentials require an effective HTTPS endpoint", + ], + [ + "rejects non-HTTPS opaque HTTP routes", + connection({ endpoint: { baseUrl: "http://api.openai.com/v1" } }), + "opaque_http model credentials require an effective HTTPS endpoint", + ], + [ + "rejects credentials under runtime-provided mode", + connection({ credentialMode: "runtime_provided" }), + "modelConnection credentials require credentialMode env", + ], + [ + "rejects env mode without credentials", + connection({ credentials: [] }), + "modelConnection credentialMode env requires credentials", + ], + ] as const) { + it(name, () => { + // The local runtime_provided mount gate runs before credential validation; satisfy it so + // the runtime_provided case reaches the validation under test (afterEach restores this). + process.env.PI_CODING_AGENT_DIR = "/agenta/harness/pi"; + let created = false; + const result = buildRunPlan( + { ...base, modelConnection } as AgentRunRequest, + { + createLocalCwd: () => { + created = true; + return "/tmp/unused"; + }, + }, + ); + assert.deepEqual(result, { ok: false, error }); + assert.equal(created, false); + }); + } + + it("materializes local_use credentials and non-secret config only after validation", () => { + const result = buildRunPlan({ + ...base, + modelConnection: connection({ + provider: "anthropic", + deployment: "bedrock", + endpoint: { + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + region: "us-east-1", + }, + environment: { AWS_REGION: "us-east-1" }, + credentials: [ + { + binding: { kind: "environment", name: "AWS_ACCESS_KEY_ID" }, + value: "AKIA", + usage: "local_use", + }, + ], + }), + } as AgentRunRequest); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.deepEqual(result.plan.modelEnvironment, { + AWS_REGION: "us-east-1", + AWS_ACCESS_KEY_ID: "AKIA", + }); + }); + + it("does not require an HTTP endpoint for local_use credentials", () => { + const result = buildRunPlan({ + ...base, + modelConnection: connection({ + provider: "anthropic", + deployment: "vertex_ai", + endpoint: undefined, + credentials: [ + { + binding: { + kind: "environment", + name: "GOOGLE_APPLICATION_CREDENTIALS", + }, + value: "/tmp/adc.json", + usage: "local_use", + }, + ], + }), + } as AgentRunRequest); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal( + result.plan.modelEnvironment.GOOGLE_APPLICATION_CREDENTIALS, + "/tmp/adc.json", + ); + }); + + it("fails legacy top-level credential fields instead of treating the run as unmanaged", () => { + // `connection` ({mode, slug}) is NOT legacy: it stays tolerated on the wire for the + // models.json Pi custom-provider path. + for (const field of [ + "secrets", + "provider", + "deployment", + "credentialMode", + "endpoint", + ]) { + let created = false; + const result = buildRunPlan( + { + ...base, + [field]: + field === "secrets" ? { OPENAI_API_KEY: "legacy" } : "legacy", + } as AgentRunRequest, + { + createLocalCwd: () => { + created = true; + return "/unused"; + }, + }, + ); + assert.equal(result.ok, false, field); + if (!result.ok) assert.match(result.error, /modelConnection object/); + assert.equal(created, false, field); + } + }); + + it("rejects a legacy field even when modelConnection is also present", () => { + // A caller mixing the retired flat fields with the resolved object is ambiguous about which + // credential set governs the run; fail loudly instead of silently preferring one. + const result = buildRunPlan( + { + ...base, + modelConnection: connection(), + secrets: { OPENAI_API_KEY: "legacy" }, + } as AgentRunRequest, + { createLocalCwd: () => "/tmp/unused" }, + ); + assert.equal(result.ok, false); + if (!result.ok) assert.match(result.error, /modelConnection object/); + }); +}); diff --git a/services/runner/tests/unit/server.test.ts b/services/runner/tests/unit/server.test.ts index c62230891b..defcc971b1 100644 --- a/services/runner/tests/unit/server.test.ts +++ b/services/runner/tests/unit/server.test.ts @@ -433,6 +433,60 @@ describe("createAgentServer", () => { } }); + it("redacts this run's credentials from the stderr stack log when a run throws", async () => { + // A per-run provider key rides ONLY the typed request (never process env). When the run + // throws with that key captured in the error message/stack (an auth failure echoing it, + // a dumped env), the stack must pass through the run's deny-set before reaching the + // stderr sink — persistence is already redacted; stderr must be too. + const PER_RUN_KEY = "sk-escaping-stack-fake-key-DO-NOT-USE-9a8b7c"; + const throwingRun: RunAgent = async () => { + throw new Error(`provider auth failed for key ${PER_RUN_KEY}`); + }; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const s = await listen(throwingRun); + try { + const res = await fetch(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify({ + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: PER_RUN_KEY, + usage: "opaque_http", + }, + ], + }, + }), + }); + assert.equal(res.status, 200); + const records = (await res.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + // The escaping error still terminates the stream with a failed result. + assert.equal(records.at(-1)!.kind, "result"); + assert.equal(records.at(-1)!.result.ok, false); + + const logged = errorSpy.mock.calls + .map((args) => args.map(String).join(" ")) + .join("\n"); + // The log keeps its shape (an Error stack was written)... + assert.match(logged, /Error: provider auth failed/); + assert.match(logged, /\n\s+at /); + // ...but the live credential value never reaches the stderr sink. + assert.equal(logged.includes(PER_RUN_KEY), false); + assert.match(logged, /\[ag:redacted/); + } finally { + await s.close(); + } + }); + it("rejects an over-cap session turn before persistence or attachment claiming", async () => { // Override the cap rather than generating a default-sized batch, so the case stays small. process.env.AGENTA_ATTACHMENTS_MAX_PER_TURN = "2"; @@ -551,9 +605,7 @@ describe("createAgentServer", () => { messages: [ { role: "user", - content: [ - { type: "image", uri: "data:image/png;base64,AQID" }, - ], + content: [{ type: "image", uri: "data:image/png;base64,AQID" }], }, ], }), diff --git a/services/runner/tests/unit/session-keepalive-approval.test.ts b/services/runner/tests/unit/session-keepalive-approval.test.ts index 8fedc40d9a..a66acaad4c 100644 --- a/services/runner/tests/unit/session-keepalive-approval.test.ts +++ b/services/runner/tests/unit/session-keepalive-approval.test.ts @@ -1073,13 +1073,27 @@ describe("runWithKeepalive: an approval park from a last-message-only turn", () }); }); -describe("runWithKeepalive: approval resume ignores re-minted credentials/config", () => { - // The "approve twice" bug: every approval reply is a fresh /run carrying freshly minted - // short-lived material (gateway/Composio secret VALUES, a per-turn tool-callback bearer), so its - // credential epoch — and often its config fingerprint (per-turn tokens embed in it) — never match - // the parked session's. The parked live process already holds its own baked credentials; the - // resume only delivers the human's yes/no, so a mismatch there must NOT evict the live session. - async function parkThenResume(resume: AgentRunRequest) { +describe("runWithKeepalive: approval credential lifecycle", () => { + const modelConnection = ( + value: string, + ): NonNullable => ({ + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value, + usage: "opaque_http", + }, + ], + }); + + async function parkThenResume( + resume: AgentRunRequest, + initial: Partial = {}, + ) { const { engine, calls } = makeApprovalEngine([ { approvalPause: { @@ -1092,48 +1106,73 @@ describe("runWithKeepalive: approval resume ignores re-minted credentials/config { result: { ok: true, output: "resumed", stopReason: "complete" } }, ]); const ctx = makeCtx(engine); - await runWithKeepalive(pauseTurn(), undefined, undefined, ctx); + await runWithKeepalive( + { ...pauseTurn(), ...initial }, + undefined, + undefined, + ctx, + ); const env1 = calls.acquiredEnvs[0]; const r2 = await runWithKeepalive(resume, undefined, undefined, ctx); return { calls, env1, ctx, r2 }; } - it("resumes LIVE when the resume carries a DIFFERENT credential epoch AND config fingerprint but a matching decision + history", async () => { - // The resume request re-mints a fresh tool-callback bearer (changes both the config fingerprint - // via toolCallback.endpoint and the credential epoch via secrets + toolCallback.authorization). + it("resumes live when only per-turn callback authorization rotates", async () => { const { calls, env1, r2 } = await parkThenResume( approveResume(true, { toolCallback: { endpoint: "https://gateway/tools/call", authorization: "fresh-per-turn-bearer", }, - secrets: { OPENAI_API_KEY: "sk-freshly-minted" }, }), + { + toolCallback: { + endpoint: "https://gateway/tools/call", + authorization: "original-per-turn-bearer", + }, + }, ); assert.equal(r2.ok, true); assert.equal( calls.acquire, 1, - "no cold re-acquire; the live parked session was reused", + "transient auth does not recreate the sandbox", ); - assert.equal(env1.destroyed, 0, "the parked session was NOT evicted"); + assert.equal(env1.destroyed, 0); + assert.equal(calls.resumes.length, 1, "the parked gate is answered live"); + }); + + it("evicts and cold-acquires when a model credential baked into the sandbox rotates", async () => { + const { calls, env1, r2 } = await parkThenResume( + approveResume(true, { modelConnection: modelConnection("sk-model-b") }), + { modelConnection: modelConnection("sk-model-a") }, + ); + assert.equal(r2.ok, true); assert.equal( - calls.resumes.length, + env1.destroyed, 1, - "the gate was answered live exactly once (respondPermission)", + "the stale credential environment is evicted", + ); + assert.equal( + calls.acquire, + 2, + "the approval request cold-acquires with the new key", + ); + assert.equal( + calls.resumes.length, + 0, + "the stale live gate is never answered", ); - assert.equal(calls.resumes[0].reply, "once"); - assert.equal(calls.resumes[0].permissionId, "perm-1"); }); - it("a changed model on the resume still resumes live (config fingerprint no longer gates the approval branch)", async () => { + it("a changed model without credential rotation still resumes live", async () => { const { calls, env1, r2 } = await parkThenResume( approveResume(true, { model: "m2" }), ); assert.equal(r2.ok, true); - assert.equal(calls.acquire, 1, "no cold re-acquire"); - assert.equal(env1.destroyed, 0, "the parked session was reused"); - assert.equal(calls.resumes.length, 1, "answered live exactly once"); + assert.equal(calls.acquire, 1); + assert.equal(env1.destroyed, 0); + assert.equal(calls.resumes.length, 1); }); it("the repark after a resume keeps the parked secrets hash AND the installed mount lease", async () => { @@ -1158,13 +1197,23 @@ describe("runWithKeepalive: approval resume ignores re-minted credentials/config const ctx = makeCtx(engine); const paused: AgentRunRequest = { ...pauseTurn(), - secrets: { ANTHROPIC_API_KEY: "baked-at-acquire" }, + modelConnection: modelConnection("baked-at-acquire"), + toolCallback: { + endpoint: "https://gateway/tools/call", + authorization: "original-per-turn-bearer", + }, }; await runWithKeepalive(paused, undefined, undefined, ctx); assert.equal(ctx.pool.get(POOL_KEY)!.state, "awaiting_approval"); + // Same baked model credential (a rotated one would rightly evict), but re-minted per-turn + // callback authorization — transient material the epoch hash deliberately excludes. const resume = approveResume(true, { - secrets: { ANTHROPIC_API_KEY: "re-minted-on-the-resume" }, + modelConnection: modelConnection("baked-at-acquire"), + toolCallback: { + endpoint: "https://gateway/tools/call", + authorization: "re-minted-on-the-resume", + }, }); const r2 = await runWithKeepalive(resume, undefined, undefined, ctx); assert.equal(r2.ok, true); @@ -1176,10 +1225,10 @@ describe("runWithKeepalive: approval resume ignores re-minted credentials/config computeCredentialEpoch(paused).secretsHash, "the repark keeps the hash of the secrets the environment actually baked", ); - assert.notEqual( - parked.credentialEpoch.secretsHash, + assert.equal( computeCredentialEpoch(resume).secretsHash, - "the resume's re-minted secrets never entered this environment", + parked.credentialEpoch.secretsHash, + "the re-minted per-turn bearer never enters the baked-credential hash", ); assert.equal( parked.credentialEpoch.mountExpiresAtMs, diff --git a/services/runner/tests/unit/session-keepalive-dispatch.test.ts b/services/runner/tests/unit/session-keepalive-dispatch.test.ts index c3a332381e..8724816b9c 100644 --- a/services/runner/tests/unit/session-keepalive-dispatch.test.ts +++ b/services/runner/tests/unit/session-keepalive-dispatch.test.ts @@ -523,7 +523,19 @@ describe("runWithKeepalive: validation mismatches degrade to cold", () => { it("a changed secret value evicts to cold (same config, same history)", async () => { const withSecret = turn2("s1", { - secrets: { ANTHROPIC_API_KEY: "rotated" }, + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "rotated", + usage: "opaque_http", + }, + ], + }, }); const { engine, calls } = makeEngine(); const ctx = makeCtx(engine); diff --git a/services/runner/tests/unit/session-mcp-layering.test.ts b/services/runner/tests/unit/session-mcp-layering.test.ts index ab7180d730..ecd212fed4 100644 --- a/services/runner/tests/unit/session-mcp-layering.test.ts +++ b/services/runner/tests/unit/session-mcp-layering.test.ts @@ -30,6 +30,12 @@ import type { ResolvedToolSpec, } from "../../src/protocol.ts"; +const credential = (name: string, value: string) => ({ + binding: { kind: "header" as const, name }, + value, + usage: "opaque_http" as const, +}); + const relayDir = "/tmp/agenta-tools-layering"; const mcpCapable: HarnessCapabilities = { mcpTools: true, toolCalls: true }; @@ -88,7 +94,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = connection: { type: "http", url: "https://mcp.linear.app/sse", - headers: { Authorization: "Bearer x" }, + credentials: [credential("Authorization", "Bearer x")], }, policy: { tools: { mode: "all" } }, }, @@ -420,7 +426,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = connection: { type: "http", url: "https://mcp.linear.app/sse", - headers: { Authorization: "Bearer x" }, + credentials: [credential("Authorization", "Bearer x")], }, policy: { tools: { mode: "all" } }, }, diff --git a/services/runner/tests/unit/session-pool.test.ts b/services/runner/tests/unit/session-pool.test.ts index 2bbd34d5a7..e092ecf07a 100644 --- a/services/runner/tests/unit/session-pool.test.ts +++ b/services/runner/tests/unit/session-pool.test.ts @@ -14,6 +14,7 @@ import { credentialEpochMismatch, credentialEpochValid, mountCredentialsExpired, + sandboxCredentialsRotated, expectedNextHistoryFingerprint, historyFingerprint, poolKeyFor, @@ -216,13 +217,40 @@ describe("configFingerprint", () => { messages: [{ role: "user", content: "hi" }], }; - it("ignores per-turn volatiles (messages, turnId, telemetry, secrets)", () => { - const a = configFingerprint(base); + it("ignores per-turn volatiles and credential values", () => { + const a = configFingerprint({ + ...base, + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "original", + usage: "opaque_http", + }, + ], + }, + }); const b = configFingerprint({ ...base, messages: [{ role: "user", content: "totally different" }], turnId: "t-2", - secrets: { ANTHROPIC_API_KEY: "sekret" }, + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "sekret", + usage: "opaque_http", + }, + ], + }, telemetry: { exporters: { otlp: { headers: { authorization: "Bearer x" } } }, }, @@ -235,6 +263,33 @@ describe("configFingerprint", () => { ); }); + it("ignores MCP credential values while retaining their binding contract", () => { + const withMcp = (value: string): AgentRunRequest => ({ + ...base, + mcpServers: [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/sse", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value, + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, + ], + }); + assert.equal( + configFingerprint(withMcp("secret-a")), + configFingerprint(withMcp("secret-b")), + ); + }); + it("changes when a config-bearing field changes (model)", () => { assert.notEqual( configFingerprint(base), @@ -264,12 +319,16 @@ describe("configFingerprint", () => { // model, or endpoint must cold-start rather than reuse a mismatched live session. No new // fingerprint field is needed — these already ride configFingerprint. it("changes when the connection changes (custom provider identity)", () => { - const withConn = { + const withConn: AgentRunRequest = { ...base, - provider: "openai", - deployment: "custom", connection: { mode: "agenta", slug: "ollama-a" }, - endpoint: { baseUrl: "https://a.test/v1" }, + modelConnection: { + provider: "openai", + deployment: "custom", + endpoint: { baseUrl: "https://a.test/v1" }, + credentialMode: "none", + credentials: [], + }, }; assert.notEqual( configFingerprint(withConn), @@ -281,17 +340,25 @@ describe("configFingerprint", () => { }); it("changes when the endpoint base URL changes", () => { - const withEndpoint = { + const withEndpoint: AgentRunRequest = { ...base, - deployment: "custom", connection: { mode: "agenta", slug: "ollama-a" }, - endpoint: { baseUrl: "https://a.test/v1" }, + modelConnection: { + provider: "openai", + deployment: "custom", + endpoint: { baseUrl: "https://a.test/v1" }, + credentialMode: "none", + credentials: [], + }, }; assert.notEqual( configFingerprint(withEndpoint), configFingerprint({ ...withEndpoint, - endpoint: { baseUrl: "https://b.test/v1" }, + modelConnection: { + ...withEndpoint.modelConnection!, + endpoint: { baseUrl: "https://b.test/v1" }, + }, }), ); }); @@ -518,17 +585,35 @@ describe("tailIsFreshUserMessage", () => { }); describe("credential epoch", () => { + // A typed model connection whose one credential carries `value` under env var `name`. + const modelConnection = ( + value: string, + name = "A", + ): AgentRunRequest["modelConnection"] => ({ + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name }, + value, + usage: "opaque_http", + }, + ], + }); + it("same secrets hash equal; a changed secret value differs", () => { const a = computeCredentialEpoch({ - secrets: { A: "1" }, + modelConnection: modelConnection("1"), toolCallback: { endpoint: "e", authorization: "z" }, }); const b = computeCredentialEpoch({ - secrets: { A: "1" }, + modelConnection: modelConnection("1"), toolCallback: { endpoint: "e", authorization: "z" }, }); const c = computeCredentialEpoch({ - secrets: { A: "2" }, + modelConnection: modelConnection("2"), toolCallback: { endpoint: "e", authorization: "z" }, }); assert.equal(a.secretsHash, b.secretsHash); @@ -544,12 +629,13 @@ describe("credential epoch", () => { // with the fresh key rather than reuse a warm session baked with the old one (design // Decision 7 — the credential epoch already covers this; no new key is needed). const parked = computeCredentialEpoch({ - secrets: { OPENAI_API_KEY: "sk-old" }, + modelConnection: modelConnection("sk-old", "OPENAI_API_KEY"), }); const rotated = computeCredentialEpoch({ - secrets: { OPENAI_API_KEY: "sk-new" }, + modelConnection: modelConnection("sk-new", "OPENAI_API_KEY"), }); assert.notEqual(parked.secretsHash, rotated.secretsHash); + assert.equal(sandboxCredentialsRotated(parked, rotated), true); assert.equal(credentialEpochValid(parked, rotated, Date.now()), false); }); @@ -557,23 +643,52 @@ describe("credential epoch", () => { // The backend re-mints the callback bearer on its auth-cache cadence (~60s); the turn's // relay always uses the incoming bearer, so a warm continue must not evict over it. const parked = computeCredentialEpoch({ - secrets: { A: "1" }, + modelConnection: modelConnection("1"), toolCallback: { endpoint: "e", authorization: "bearer-old" }, }); const incoming = computeCredentialEpoch({ - secrets: { A: "1" }, + modelConnection: modelConnection("1"), toolCallback: { endpoint: "e", authorization: "bearer-new" }, }); assert.equal(parked.secretsHash, incoming.secretsHash); + assert.equal(sandboxCredentialsRotated(parked, incoming), false); assert.equal(credentialEpochMismatch(parked, incoming), undefined); }); + it("rotates the epoch when an MCP header credential changes", () => { + const withMcp = (value: string): AgentRunRequest => ({ + mcpServers: [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/sse", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value, + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, + ], + }); + assert.notEqual( + computeCredentialEpoch(withMcp("secret-a")).secretsHash, + computeCredentialEpoch(withMcp("secret-b")).secretsHash, + ); + }); + it("valid until the mount expiry elapses; invalid once expired", () => { const parked = { - ...computeCredentialEpoch({ secrets: { A: "1" } }), + ...computeCredentialEpoch({ modelConnection: modelConnection("1") }), mountExpiresAtMs: Date.parse("2026-01-01T00:00:10.000Z"), }; - const incoming = computeCredentialEpoch({ secrets: { A: "1" } }); + const incoming = computeCredentialEpoch({ + modelConnection: modelConnection("1"), + }); const before = Date.parse("2026-01-01T00:00:05.000Z"); const after = Date.parse("2026-01-01T00:00:15.000Z"); assert.equal(credentialEpochValid(parked, incoming, before), true); @@ -585,18 +700,26 @@ describe("credential epoch", () => { }); it("invalid when the secret material changed even if not expired", () => { - const parked = computeCredentialEpoch({ secrets: { A: "1" } }); - const incoming = computeCredentialEpoch({ secrets: { A: "2" } }); + const parked = computeCredentialEpoch({ + modelConnection: modelConnection("1"), + }); + const incoming = computeCredentialEpoch({ + modelConnection: modelConnection("2"), + }); assert.equal(credentialEpochValid(parked, incoming, Date.now()), false); }); it("credentialEpochMismatch splits the reason: expired vs rotated vs none", () => { const parked = { - ...computeCredentialEpoch({ secrets: { A: "1" } }), + ...computeCredentialEpoch({ modelConnection: modelConnection("1") }), mountExpiresAtMs: Date.parse("2026-01-01T00:00:10.000Z"), }; - const same = computeCredentialEpoch({ secrets: { A: "1" } }); - const rotated = computeCredentialEpoch({ secrets: { A: "2" } }); + const same = computeCredentialEpoch({ + modelConnection: modelConnection("1"), + }); + const rotated = computeCredentialEpoch({ + modelConnection: modelConnection("2"), + }); const before = Date.parse("2026-01-01T00:00:05.000Z"); const after = Date.parse("2026-01-01T00:00:15.000Z"); assert.equal(credentialEpochMismatch(parked, same, before), undefined); @@ -617,7 +740,7 @@ describe("credential epoch", () => { it("mountCredentialsExpired checks only the mount lifetime, ignoring the secret hash", () => { const parked = { - ...computeCredentialEpoch({ secrets: { A: "1" } }), + ...computeCredentialEpoch({ modelConnection: modelConnection("1") }), mountExpiresAtMs: Date.parse("2026-01-01T00:00:10.000Z"), }; const before = Date.parse("2026-01-01T00:00:05.000Z"); @@ -625,7 +748,9 @@ describe("credential epoch", () => { assert.equal(mountCredentialsExpired(parked, before), false); assert.equal(mountCredentialsExpired(parked, after), true); // No expiry recorded => never expired, regardless of the secret material. - const noExpiry = computeCredentialEpoch({ secrets: { A: "1" } }); + const noExpiry = computeCredentialEpoch({ + modelConnection: modelConnection("1"), + }); assert.equal(mountCredentialsExpired(noExpiry, after), false); }); }); diff --git a/services/runner/tests/unit/wire-contract.test.ts b/services/runner/tests/unit/wire-contract.test.ts index dab383d5ad..938b5a6cd8 100644 --- a/services/runner/tests/unit/wire-contract.test.ts +++ b/services/runner/tests/unit/wire-contract.test.ts @@ -38,13 +38,8 @@ const KNOWN_REQUEST_KEYS = [ "agentsMd", "model", "modelCapabilities", - "provider", - "connection", - "deployment", - "endpoint", - "credentialMode", + "modelConnection", "messages", - "secrets", "context", "telemetry", "runContext", diff --git a/services/runner/tests/utils/qa-transcripts.ts b/services/runner/tests/utils/qa-transcripts.ts index 6eaba61eb1..515e6d260c 100644 --- a/services/runner/tests/utils/qa-transcripts.ts +++ b/services/runner/tests/utils/qa-transcripts.ts @@ -70,13 +70,19 @@ export function agentRunRequestFromTranscript( transcript: QaTranscript, ): AgentRunRequest { const agent = transcript.request.data.parameters.agent as { - harness?: string; + harness?: string | { kind?: string }; + sandbox?: string | { kind?: string }; model?: string; + llm?: { model?: string }; agents_md?: string; + instructions?: { agents_md?: string }; tools?: Array<{ type: string; name: string }>; harness_options?: Record; }; - const capturedHarness = agent.harness ?? "pi"; + const capturedHarness = + typeof agent.harness === "string" + ? agent.harness + : (agent.harness?.kind ?? "pi"); const harness = HARNESS_RENAME[capturedHarness] ?? capturedHarness; const appendSystemPrompt = agent.harness_options?.[capturedHarness]?.append_system; @@ -87,8 +93,8 @@ export function agentRunRequestFromTranscript( return { harness, sandbox: "local", - agentsMd: agent.agents_md, - model: agent.model, + agentsMd: agent.agents_md ?? agent.instructions?.agents_md, + model: agent.model ?? agent.llm?.model, appendSystemPrompt, tools: builtinTools, messages: transcript.request.data.inputs.messages.map((m) => ({ From dd8a9f6cdf047c012f47e5409c2df0b7a82b6c0b Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 3 Aug 2026 09:17:09 +0200 Subject: [PATCH 02/11] fix: address review findings on the Daytona Secrets port - SDK: build_resolved_connection wraps classify_environment failures in InvalidConnectionConfigurationError (422), matching the effective_endpoint handling; malformed bindings no longer surface as 500s. Test added. - runner: buildDaytonaSecretPlan rejects an opaque model credential whose binding name (case-insensitive) collides with a direct environment binding, so one name can never ride both envVars and secrets in a create request. local_use bindings now settle before opaque ones so wire order cannot evade the check. Tests added. - runner: extracted one shared isDaytonaNotFound helper (daytona-secrets.ts) recognizing both the typed DaytonaNotFoundError and 404-shaped errors; used by Secret cleanup and the process-local sandbox lifecycle wrapper. Test added. - hermetic-env AGENTA_DAYTONA_OPAQUE_SECRETS scrub: already present at HEAD (SCRUBBED list + per-test re-scrub); no change needed. - daytona-secret-provider proxy staleness claim: rebutted, intentionally unchanged (all lifecycle methods are declared on the facade; attachments only affect create, which always rebuilds the delegate). - configFingerprint environment-values claim: rebutted, intentionally unchanged (modelConnection.environment carries non-secret config only by contract; secret material rides typed credentials whose values are already stripped from the fingerprint). --- .../sdk/agents/connections/endpoints.py | 7 ++- .../unit/agents/connections/test_models.py | 19 ++++++++ .../sandbox_agent/daytona-secret-plan.ts | 37 ++++++++++----- .../sandbox_agent/daytona-secret-provider.ts | 15 +------ .../engines/sandbox_agent/daytona-secrets.ts | 20 ++++++--- .../tests/unit/daytona-secret-plan.test.ts | 45 +++++++++++++++++++ .../runner/tests/unit/daytona-secrets.test.ts | 14 +++++- 7 files changed, 125 insertions(+), 32 deletions(-) diff --git a/sdks/python/agenta/sdk/agents/connections/endpoints.py b/sdks/python/agenta/sdk/agents/connections/endpoints.py index 62cfa2d8b0..aa3cfd7c76 100644 --- a/sdks/python/agenta/sdk/agents/connections/endpoints.py +++ b/sdks/python/agenta/sdk/agents/connections/endpoints.py @@ -114,7 +114,12 @@ def build_resolved_connection( raise InvalidConnectionConfigurationError( "Vertex API-key authentication is not supported by the agent connection contract" ) - credentials, environment = classify_environment(values.items()) + try: + credentials, environment = classify_environment(values.items()) + except ValueError as exc: + # Same contract as the endpoint-resolution failure below: a malformed binding is a + # caller configuration problem (422), never an unhandled 500. + raise InvalidConnectionConfigurationError(str(exc)) from exc if credential_mode == "env" and not credentials: raise InvalidConnectionConfigurationError( "credential_mode 'env' requires at least one usable credential" diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py index b61442d817..2a389ac8c6 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py @@ -18,6 +18,7 @@ ResolvedConnection, ) from agenta.sdk.agents.connections.endpoints import build_resolved_connection +from agenta.sdk.agents.connections.errors import InvalidConnectionConfigurationError # ----------------------------------------------------------------- ModelRef.coerce @@ -203,6 +204,24 @@ def test_local_use_credentials_do_not_require_an_http_endpoint(): assert [credential.usage for credential in resolved.credentials] == ["local_use"] +@pytest.mark.parametrize("values", [{"": "value"}, {"OPENAI_API_KEY": ""}]) +def test_build_resolved_connection_maps_malformed_bindings_to_configuration_error( + values, +): + # A malformed binding is a caller configuration problem: it must surface as the typed + # 422 error (like the endpoint-resolution failures), never as a bare ValueError (500). + with pytest.raises( + InvalidConnectionConfigurationError, + match="non-empty names and values", + ): + build_resolved_connection( + provider="openai", + model="gpt-5.5", + credential_mode="env", + values=values, + ) + + def test_resolved_connection_credential_is_hidden_from_repr(): resolved = ResolvedConnection( provider="openai", diff --git a/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts b/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts index edcd0d2df2..c36704f04d 100644 --- a/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts +++ b/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts @@ -157,6 +157,18 @@ export function buildDaytonaSecretPlan(input: { const add = (candidate: Omit): void => { assertBinding(candidate.binding.name); + // A model credential and a direct environment binding land in ONE create request as + // `secrets` and `envVars` respectively; a shared name would leave precedence to Daytona. + // (MCP candidates attach under generated AGENTA_MCP_SECRET_ names, so only the model + // consumer can collide.) + if ( + candidate.consumer.kind === "model" && + directBindings.has(candidate.binding.name.toLowerCase()) + ) { + fail( + `credential binding '${candidate.binding.name}' collides with a direct environment binding`, + ); + } const consumerKey = candidate.consumer.kind === "model" ? "model" : candidate.consumer.server; const key = `${candidate.consumer.kind}:${consumerKey}:${candidate.binding.kind}:${candidate.binding.name.toLowerCase()}`; @@ -176,19 +188,22 @@ export function buildDaytonaSecretPlan(input: { opaqueCredentials.length > 0 && connection.endpoint?.baseUrl ? exactHttpsHost(connection.endpoint.baseUrl) : undefined; + // Two passes: settle every direct (local_use) binding first, so the collision check in + // `add` sees the complete direct set regardless of credential order on the wire. for (const credential of connection.credentials ?? []) { - if (credential.usage === "local_use") { - assertLocalUseBinding(credential.binding.name); - const normalized = credential.binding.name.toLowerCase(); - if (directBindings.has(normalized)) { - fail( - `duplicate direct environment binding '${credential.binding.name}'`, - ); - } - directBindings.add(normalized); - environment[credential.binding.name] = credential.value; - continue; + if (credential.usage !== "local_use") continue; + assertLocalUseBinding(credential.binding.name); + const normalized = credential.binding.name.toLowerCase(); + if (directBindings.has(normalized)) { + fail( + `duplicate direct environment binding '${credential.binding.name}'`, + ); } + directBindings.add(normalized); + environment[credential.binding.name] = credential.value; + } + for (const credential of connection.credentials ?? []) { + if (credential.usage === "local_use") continue; if (!host) { fail( "opaque model credentials require endpoint.baseUrl for exact-host restriction", diff --git a/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts b/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts index a05ae5e087..08e64d2a5d 100644 --- a/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts +++ b/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts @@ -1,11 +1,10 @@ -import { DaytonaNotFoundError } from "@daytonaio/sdk"; - import type { McpServerConfig } from "../../protocol.ts"; import { DaytonaReconnectTerminalError } from "./daytona-provider.ts"; import type { DaytonaSecretPlan } from "./daytona-secret-plan.ts"; import { allocateDaytonaSecrets, deleteDaytonaSecrets, + isDaytonaNotFound, type DaytonaSecretAllocation, type DaytonaSecretApi, } from "./daytona-secrets.ts"; @@ -49,16 +48,6 @@ function plansMatch(entry: RegistryEntry, createFingerprint: string): boolean { return entry.createFingerprint === createFingerprint; } -function isNotFound(error: unknown): boolean { - return ( - error instanceof DaytonaNotFoundError || - (typeof error === "object" && - error !== null && - "statusCode" in error && - error.statusCode === 404) - ); -} - async function destroySandboxIdempotently( provider: DaytonaProviderLike, sandboxId: string, @@ -66,7 +55,7 @@ async function destroySandboxIdempotently( try { await provider.destroy(sandboxId); } catch (error) { - if (!isNotFound(error)) throw error; + if (!isDaytonaNotFound(error)) throw error; } } diff --git a/services/runner/src/engines/sandbox_agent/daytona-secrets.ts b/services/runner/src/engines/sandbox_agent/daytona-secrets.ts index 4780b6176b..191c0a63af 100644 --- a/services/runner/src/engines/sandbox_agent/daytona-secrets.ts +++ b/services/runner/src/engines/sandbox_agent/daytona-secrets.ts @@ -1,5 +1,7 @@ import { randomBytes } from "node:crypto"; +import { DaytonaNotFoundError } from "@daytonaio/sdk"; + import type { DaytonaSecretCandidate, DaytonaSecretPlan, @@ -28,12 +30,18 @@ export interface DaytonaSecretAllocation { created: DaytonaSecretRecord[]; } -function isNotFound(error: unknown): boolean { +/** + * True when a Daytona failure means "the resource is already gone": the SDK's typed + * not-found error, or any 404-shaped error object. The one absence predicate shared by + * Secret cleanup here and the sandbox lifecycle wrapper (`daytona-secret-provider.ts`). + */ +export function isDaytonaNotFound(error: unknown): boolean { return ( - typeof error === "object" && - error !== null && - "statusCode" in error && - error.statusCode === 404 + error instanceof DaytonaNotFoundError || + (typeof error === "object" && + error !== null && + "statusCode" in error && + error.statusCode === 404) ); } @@ -44,7 +52,7 @@ async function deleteIdempotently( try { await api.delete(id); } catch (error) { - if (!isNotFound(error)) throw error; + if (!isDaytonaNotFound(error)) throw error; } } diff --git a/services/runner/tests/unit/daytona-secret-plan.test.ts b/services/runner/tests/unit/daytona-secret-plan.test.ts index aceca379c8..ba05218231 100644 --- a/services/runner/tests/unit/daytona-secret-plan.test.ts +++ b/services/runner/tests/unit/daytona-secret-plan.test.ts @@ -136,6 +136,51 @@ describe("Daytona Secret planning", () => { ); }); + it("rejects an opaque model credential that collides with a direct environment binding", () => { + // AWS_REGION rides `envVars` directly; a same-named (case-insensitive) Secret attachment + // would put the binding in BOTH `envVars` and `secrets` with undefined precedence. + assert.throws( + () => + buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + credentials: [ + { + binding: { kind: "environment", name: "aws_region" }, + value: "opaque-collides", + usage: "opaque_http", + }, + ], + }, + }), + /collides with a direct environment binding/, + ); + // Wire order must not matter: an opaque credential listed BEFORE the same-named + // local_use credential still collides (direct bindings settle first). + assert.throws( + () => + buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + environment: {}, + credentials: [ + { + binding: { kind: "environment", name: "aws_profile" }, + value: "opaque-collides", + usage: "opaque_http", + }, + { + binding: { kind: "environment", name: "AWS_PROFILE" }, + value: "local-only", + usage: "local_use", + }, + ], + }, + }), + /collides with a direct environment binding/, + ); + }); + it("fails closed on plaintext credential bypasses in model environment and local_use", () => { assert.throws( () => diff --git a/services/runner/tests/unit/daytona-secrets.test.ts b/services/runner/tests/unit/daytona-secrets.test.ts index e84cb637f8..8588e91310 100644 --- a/services/runner/tests/unit/daytona-secrets.test.ts +++ b/services/runner/tests/unit/daytona-secrets.test.ts @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; +import { DaytonaNotFoundError } from "@daytonaio/sdk"; import { describe, it } from "vitest"; import type { DaytonaSecretPlan } from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; import { allocateDaytonaSecrets, deleteDaytonaSecrets, + isDaytonaNotFound, type DaytonaSecretApi, } from "../../src/engines/sandbox_agent/daytona-secrets.ts"; @@ -104,7 +106,7 @@ describe("Daytona Secret allocation", () => { assert.deepEqual(deletes, ["id-2", "id-1"]); }); - it("deletes in reverse order and treats 404 as idempotent success", async () => { + it("deletes in reverse order and treats not-found (typed or 404-shaped) as idempotent success", async () => { const deletes: string[] = []; const api: DaytonaSecretApi = { async create() { @@ -112,7 +114,10 @@ describe("Daytona Secret allocation", () => { }, async delete(id) { deletes.push(id); + // Both absence shapes the shared predicate recognizes: the SDK's typed error + // (no statusCode needed) and a bare 404-shaped object. if (id === "id-2") throw { statusCode: 404 }; + if (id === "id-1") throw new DaytonaNotFoundError("secret not found"); }, }; await deleteDaytonaSecrets( @@ -128,4 +133,11 @@ describe("Daytona Secret allocation", () => { ); assert.deepEqual(deletes, ["id-2", "id-1"]); }); + + it("shares one not-found predicate across Secret and sandbox cleanup", () => { + assert.equal(isDaytonaNotFound(new DaytonaNotFoundError("gone")), true); + assert.equal(isDaytonaNotFound({ statusCode: 404 }), true); + assert.equal(isDaytonaNotFound({ statusCode: 500 }), false); + assert.equal(isDaytonaNotFound(new Error("gone")), false); + }); }); From 963f60708754897e4e8e3049bbb7ee7767fc22db Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 3 Aug 2026 12:47:28 +0200 Subject: [PATCH 03/11] fix: repair the CI failures the port introduced Four problems, all caught by suites the port never ran locally. 1. The author's connection choice stopped reaching the runner. The port dropped `wire_model_ref()` because it also carried the retired flat `provider` field, but that method was the only emitter of the top-level `connection` reference. The runner still gates Pi's OpenAI-compatible models.json path on `request.connection.mode === "agenta"` and names the provider from its slug (`pi-model-config.ts`), so a named custom connection silently fell back to the generic provider-override env. Restored as `wire_connection_ref()`, which emits only `{mode, slug}` and stays empty for the project default so a plain run's payload is unchanged. 2. `_fake_runner_backend.py` still passed `secrets=` to `request_to_wire`, which the typed wire no longer accepts. It now mirrors the production sandbox-agent backend: the parameter is accepted for interface parity and ignored, because credentials ride inside `config` as `modelConnection`. 3. The services handler tests still built `ResolvedConnection(env=...)` and expected a resolution failure to degrade into an empty `runtime_provided` plan. Ported to typed credentials, and the degradation test became a fail-closed test. The shared no-credential stub now reads its provider from the harness capability table, because a fail-closed resolve must return a connection the post-resolve gate accepts for whichever harness is running. 4. The custom-connection replay test asserted the retired flat wire. Also: the credential epoch now uses an HMAC keyed with a per-process random key instead of a bare sha256 of the secret values. The epoch is only ever compared within one process, so the key costs nothing, and the digest stops being brute-forceable against candidate API keys. This is what CodeQL flagged. Verified: runner tsc clean, 99 files / 1534 tests; SDK 2067 passed (unit + integration) with the 10 pre-existing litellm xfails; services 100 passed. --- sdks/python/agenta/sdk/agents/dtos.py | 24 ++++ sdks/python/agenta/sdk/agents/utils/wire.py | 9 +- .../agents/_fake_runner_backend.py | 7 +- .../agents/test_custom_connection_replay.py | 41 ++++--- .../agents/connections/test_dtos_model_ref.py | 49 ++++++++- .../pytest/unit/agent/test_invoke_handler.py | 104 ++++++++++++------ .../engines/sandbox_agent/session-identity.ts | 41 +++++-- 7 files changed, 207 insertions(+), 68 deletions(-) diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index 480e7516a5..c1b57b7841 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -814,6 +814,30 @@ def wire_harness_mode(self) -> Dict[str, Any]: """The harness-specific ACP session mode override for the ``/run`` payload.""" return {} + def wire_connection_ref(self) -> Dict[str, Any]: + """The author's connection CHOICE for the ``/run`` payload, not its resolved contents. + + This is the reference the author picked in the config (``self_managed``, or an Agenta + connection named by slug). It stays separate from ``modelConnection`` because the two + answer different questions: this one is "which connection did the author select", which + the runner reads to route a named OpenAI-compatible Pi run through its models.json path + (``pi-model-config.ts``); ``modelConnection`` is "what did that connection resolve to", + which is credential material. + + Empty when ``model_ref`` is unset, and empty for the project default (``agenta`` with no + slug) because that carries no information beyond the model itself, so a default run's + payload stays byte-identical. + """ + if self.model_ref is None: + return {} + connection = self.model_ref.connection + if connection.mode == "agenta" and connection.slug is None: + return {} + wire: Dict[str, Any] = {"mode": connection.mode} + if connection.slug is not None: + wire["slug"] = connection.slug + return {"connection": wire} + def wire_model_connection(self) -> Dict[str, Any]: """The resolved model route and credentials, grouped under their consumer. diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index 64600d3bd8..3ece69488b 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -103,9 +103,11 @@ def request_to_wire( packages, likewise omitted when there are none (skills ride their own seam, not the tool wire). ``config.wire_sandbox_permission()`` adds the declared sandbox security boundary, omitted when unset (plumbing only; the runner does not enforce it yet). - ``config.wire_model_connection()`` adds the resolved model route and typed credentials as one - consumer-owned object. It is omitted when no connection was resolved and overrides the base - model id with the exact resolved model. + ``config.wire_connection_ref()`` adds the author's connection CHOICE (``self_managed``, or an + Agenta connection named by slug), omitted for the project default because it carries nothing + beyond the model. ``config.wire_model_connection()`` adds what that choice RESOLVED to: the + model route and typed credentials as one consumer-owned object. It is omitted when no + connection was resolved and overrides the base model id with the exact resolved model. ``config.wire_harness_files()`` adds the generic ``harnessFiles`` array: files the active harness's config rendered from its own ``permissions`` / ``extras`` slice, to materialize in the session cwd before the session starts (``path`` relative to cwd, ``content`` the file text). Omitted @@ -137,6 +139,7 @@ def request_to_wire( **config.wire_mcp(), **config.wire_skills(), **config.wire_sandbox_permission(), + **config.wire_connection_ref(), **config.wire_model_connection(), **config.wire_harness_mode(), **config.wire_harness_files(), diff --git a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py index 33876544d0..d5974be435 100644 --- a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py +++ b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py @@ -54,7 +54,6 @@ def __init__( config: HarnessAgentTemplate, *, harness: HarnessKind, - secrets: Optional[Mapping[str, str]], trace: Optional[TraceContext], run_context: Optional[RunContext], session_id: Optional[str], @@ -62,7 +61,6 @@ def __init__( self._backend = backend self._config = config self._harness = harness - self._secrets = dict(secrets or {}) self._trace = trace self._run_context = run_context self._session_id = session_id @@ -78,7 +76,6 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: sandbox="local", config=self._config, messages=messages, - secrets=self._secrets, trace=self._trace, run_context=self._run_context, session_id=self._session_id, @@ -150,6 +147,9 @@ async def create_session( config: HarnessAgentTemplate, *, harness: HarnessKind, + # Accepted for interface parity and ignored, exactly like the production sandbox-agent + # backend: resolved credentials reach the runner inside `config` as the typed + # `modelConnection`, never as a separate plaintext map on the wire. secrets: Optional[Mapping[str, str]] = None, trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, @@ -159,7 +159,6 @@ async def create_session( self, config, harness=harness, - secrets=secrets, trace=trace, run_context=run_context, session_id=session_id, diff --git a/sdks/python/oss/tests/pytest/integration/agents/test_custom_connection_replay.py b/sdks/python/oss/tests/pytest/integration/agents/test_custom_connection_replay.py index a7e659fb61..4e0f521702 100644 --- a/sdks/python/oss/tests/pytest/integration/agents/test_custom_connection_replay.py +++ b/sdks/python/oss/tests/pytest/integration/agents/test_custom_connection_replay.py @@ -14,9 +14,9 @@ offline stand-in for the live ``GET /secrets/`` fetch), threads the resulting ``ResolvedConnection`` onto the ``SessionConfig``, and ``request_to_wire`` spreads it onto the ``/run`` payload. The test drives that through the real subprocess transport and asserts the - wire carries ``deployment=custom``, ``provider=openai``, the author's ``connection``, the - endpoint ``baseUrl``, the exact resolved ``model``, and the provider key present in - ``secrets`` by NAME (``OPENAI_API_KEY``), value redacted. + wire carries ``modelConnection`` with ``deployment=custom``, ``provider=openai``, the endpoint + ``baseUrl``, and the provider key as a typed credential bound to ``OPENAI_API_KEY`` (value + redacted), plus the author's ``connection`` and the exact resolved ``model`` at the top level. 2. The RESULT-parsing half. The recorded runner response replays back through ``result_from_wire`` / the transport, proving the SDK folds a real recorded custom-connection @@ -125,13 +125,12 @@ async def test_custom_openai_compatible_connection_replays(tmp_path): assert resolved.endpoint is not None assert resolved.endpoint.base_url == "https://openrouter.ai/api/v1" assert resolved.credential_mode == "env" - assert set(resolved.env) == {"OPENAI_API_KEY"} + assert [credential.binding.name for credential in resolved.credentials] == [ + "OPENAI_API_KEY" + ] - session_config = SessionConfig( - agent=template, - secrets=resolved.env, # Slice 1 ships the credential through `secrets` on the wire - resolved_connection=resolved, - ) + # The resolved connection is the only credential channel; nothing rides beside it. + session_config = SessionConfig(agent=template, resolved_connection=resolved) messages = [ Message(role=m["role"], content=m["content"]) for m in rec["request"]["messages"] @@ -145,16 +144,24 @@ async def test_custom_openai_compatible_connection_replays(tmp_path): # 1) REQUEST-shaping half: the /run wire carries the resolved custom-connection descriptor. sent = json.loads(capture_path.read_text(encoding="utf-8")) - assert sent["deployment"] == "custom" - assert sent["provider"] == "openai" assert sent["connection"] == {"mode": "agenta", "slug": "replay-compat"} - assert sent["endpoint"] == {"baseUrl": "https://openrouter.ai/api/v1"} assert sent["model"] == "openai/gpt-oss-20b:free" - assert sent["credentialMode"] == "env" - # The provider key rides `secrets` by NAME; the value is the redacted placeholder, and no - # real key ever reaches the wire (the fixture carries only `sk-test`). - assert "OPENAI_API_KEY" in sent["secrets"] - assert sent["secrets"]["OPENAI_API_KEY"] == "sk-test" + connection = sent["modelConnection"] + assert connection["deployment"] == "custom" + assert connection["provider"] == "openai" + assert connection["endpoint"] == {"baseUrl": "https://openrouter.ai/api/v1"} + assert connection["credentialMode"] == "env" + # The provider key rides one typed credential naming its own binding; the value is the + # redacted placeholder, and no real key ever reaches the wire (the fixture carries only + # `sk-test`). `usage: opaque_http` marks it as a key the remote provider reads over HTTPS, + # which is what makes it substitutable by a Daytona Secret on a remote sandbox. + assert connection["credentials"] == [ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-test", + "usage": "opaque_http", + } + ] # 2) RESULT-parsing half: the recorded runner response folds back cleanly, no live LLM. assert result.output == rec["result"]["output"] == "REPLAY-COMPAT-OK" diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py index 7da22b9328..cce1413d9c 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py @@ -1,7 +1,9 @@ """``ModelRef`` wiring into the config DTOs (no behavior change for string-only configs). -A structured ``model`` populates resolver intent and projects to a plain model string. Author -connection intent never crosses the runner boundary; only a resolved ``modelConnection`` does. +A structured ``model`` populates resolver intent and projects to a plain model string. The +author's connection CHOICE crosses the runner boundary as a bare ``{mode, slug}`` reference, +because the runner routes on it; what that choice RESOLVED to (route plus credentials) crosses +only as ``modelConnection``. No credential and no flat provider field ride the wire. """ from __future__ import annotations @@ -89,7 +91,14 @@ def test_string_only_config_wire_has_no_model_connection(): assert payload["model"] == "openai-codex/gpt-5.5" -def test_structured_author_intent_does_not_cross_runner_boundary(): +def test_named_connection_choice_crosses_the_boundary_without_credentials(): + """The named connection rides the wire; nothing resolved or secret does. + + The runner needs the author's choice to route a named OpenAI-compatible Pi run through its + models.json path, so ``connection`` is part of the contract. The flat ``provider`` and + ``secrets`` fields are retired: a provider is only meaningful once resolved, and credentials + only ever travel inside ``modelConnection``. + """ payload = request_to_wire( harness=HarnessKind.PI, sandbox="local", @@ -103,11 +112,43 @@ def test_structured_author_intent_does_not_cross_runner_boundary(): messages=[Message(role="user", content="hi")], ) assert payload["model"] == "openai/gpt-5.5" + assert payload["connection"] == {"mode": "agenta", "slug": "openai-prod"} assert "modelConnection" not in payload - for removed in ("provider", "connection", "secrets"): + for removed in ("provider", "secrets"): assert removed not in payload +def test_project_default_connection_is_omitted_from_the_wire(): + """The project default (``agenta``, no slug) says nothing beyond the model, so it is omitted. + + This keeps a plain run's payload byte-identical to a config that never named a connection. + """ + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(model={"provider": "openai", "model": "gpt-5.5"}), + messages=[Message(role="user", content="hi")], + ) + assert "connection" not in payload + + +def test_self_managed_connection_choice_crosses_the_boundary(): + """``self_managed`` is a real choice (the harness owns its own login), so it rides the wire.""" + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate( + model={ + "provider": "openai-codex", + "model": "gpt-5.5", + "connection": {"mode": "self_managed"}, + } + ), + messages=[Message(role="user", content="hi")], + ) + assert payload["connection"] == {"mode": "self_managed"} + + def test_default_connection_equality(): # The default connection is `agenta` with no slug. assert Connection() == Connection(mode="agenta", slug=None) diff --git a/services/oss/tests/pytest/unit/agent/test_invoke_handler.py b/services/oss/tests/pytest/unit/agent/test_invoke_handler.py index d141914687..0ffae99ada 100644 --- a/services/oss/tests/pytest/unit/agent/test_invoke_handler.py +++ b/services/oss/tests/pytest/unit/agent/test_invoke_handler.py @@ -33,6 +33,17 @@ from oss.src.agent import app +def _first_allowed_provider(harness): + """The first provider ``harness`` can consume, per the SDK's own capability table. + + Used by the no-credential stub so one stub satisfies the post-resolve capability gate for + every harness this file runs, without the test restating the capability matrix. + """ + from agenta.sdk.agents.capabilities import HARNESS_CONNECTION_CAPABILITIES + + return HARNESS_CONNECTION_CAPABILITIES[harness].providers[0] + + def _request(*, stream=None, session_id=None): """Build the request `_agent` reads stream/session_id off of. @@ -63,15 +74,18 @@ async def _no_mcp(mcp_servers, **_kw): return [] async def _no_connection(*, model, context): - # No connection is configured for the default model, so the resolve fails and the handler - # degrades to a no-credential ``runtime_provided`` plan (harness login / self-managed). - # This is the realistic "no connection" simulation: it exercises the degraded path that - # every harness tolerates, so the response-body / lifecycle / cross-harness tests run - # clean regardless of harness. A stubbed *successful* resolve would instead pin a single - # provider and be rejected by the post-resolve capability gate on a mismatched harness - # (e.g. ``openai`` on ``claude``), which is not what these tests are exercising. - raise ConnectionResolutionError( - "no connection configured for the default model" + # The run has no key of its own: the harness authenticates with its own login, so the + # resolve succeeds with a no-credential ``runtime_provided`` plan. Resolution FAILURES + # are fail-closed now, so a raising stub would abort every test in this file instead of + # exercising the response-body / lifecycle / cross-harness paths they are about. + # + # The provider is read from the harness's own capability table rather than hardcoded, + # because the post-resolve gate rejects a provider the harness cannot consume (e.g. + # ``openai`` on ``claude``) and this stub serves every harness the file exercises. + return ResolvedConnection( + provider=_first_allowed_provider(context.harness), + model=model.model, + credential_mode="runtime_provided", ) monkeypatch.setattr(app, "resolve_tools", _tools) @@ -420,8 +434,9 @@ async def _no_mcp(mcp_servers, **_kw): async def test_named_connection_env_reaches_session(monkeypatch, fake_backend): """A structured ModelRef with a named connection resolves one key onto the session. - The resolved ``env`` reaches ``SessionConfig.secrets`` (the wire's credential channel) and - the ``ResolvedConnection`` is set on the session. The resolver is called with a ``ModelRef`` + The resolved credential reaches the session on ``SessionConfig.resolved_connection`` (the + single credential channel) and is materialized as plaintext only at the local backend + boundary. The resolver is called with a ``ModelRef`` carrying the config's connection and a ``RuntimeAuthContext`` for the run. """ backend = fake_backend(result=AgentResult(output="echo", usage={"total": 1})) @@ -434,7 +449,14 @@ async def _resolve(*, model, context): provider="openai", model="gpt-5.5", credential_mode="env", - env={"OPENAI_API_KEY": "sk-x"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-x", + "usage": "opaque_http", + } + ], + endpoint={"base_url": "https://api.openai.com/v1"}, ) built = _patch_resolution(monkeypatch, backend, resolve=_resolve) @@ -451,11 +473,13 @@ async def _resolve(*, model, context): assert captured["context"].harness == "pi_core" assert captured["context"].project_id is None - # the resolved key reached the backend boundary as the session's secrets + # the resolved key reached the local backend boundary as plaintext environment assert backend.created_secrets == [{"OPENAI_API_KEY": "sk-x"}] session_cfg = built[0] - assert session_cfg.secrets == {"OPENAI_API_KEY": "sk-x"} assert session_cfg.resolved_connection is not None + assert session_cfg.resolved_connection.plaintext_environment() == { + "OPENAI_API_KEY": "sk-x" + } assert session_cfg.resolved_connection.provider == "openai" @@ -471,8 +495,7 @@ async def _resolve(*, model, context): return ResolvedConnection( provider="anthropic", model="claude-x", - credential_mode="env", - env={}, + credential_mode="runtime_provided", ) _patch_resolution(monkeypatch, backend, resolve=_resolve) @@ -498,28 +521,26 @@ async def _resolve(*, model, context): await _invoke("pi_core", model=_STRUCTURED_MODEL) -async def test_default_connection_resolution_failure_degrades( +async def test_default_connection_resolution_failure_fails_closed( monkeypatch, fake_backend ): - """An unconfigured default-mode run degrades gracefully: no raise, empty secrets. + """A default-mode resolution failure propagates instead of degrading. - This is the common playground case (a default model on every run, no configured - connection). A resolution failure must NOT crash the run; the harness uses its own login, - exactly as the old whole-vault dump returned ``{}`` and the run proceeded. + A vault outage or a missing key used to degrade into an empty ``runtime_provided`` plan, so + the run continued with no credential and failed later as a confusing provider auth error. It + now fails closed: a caller that genuinely wants harness-owned authentication must say so with + a ``self_managed`` connection (see ``test_self_managed_connection_reaches_session``), which + stays an explicit choice rather than an implicit fallback. """ backend = fake_backend(result=AgentResult(output="echo", usage={"total": 1})) async def _resolve(*, model, context): raise ConnectionResolutionError("connection resolution request failed") - built = _patch_resolution(monkeypatch, backend, resolve=_resolve) + _patch_resolution(monkeypatch, backend, resolve=_resolve) - body = await _invoke("pi_core", model={"provider": "openai", "model": "gpt-5.5"}) - - assert body == {"messages": [{"role": "assistant", "content": "echo"}]} - assert backend.created_secrets == [{}] - assert built[0].secrets == {} - assert built[0].resolved_connection.credential_mode == "runtime_provided" + with pytest.raises(ConnectionResolutionError): + await _invoke("pi_core", model={"provider": "openai", "model": "gpt-5.5"}) async def test_default_connection_missing_provider_fails_loud( @@ -573,7 +594,14 @@ async def _resolve(*, model, context): model="anthropic.claude-x", deployment="bedrock", credential_mode="env", - env={"AWS_ACCESS_KEY_ID": "AKIA", "AWS_REGION": "us-east-1"}, + environment={"AWS_REGION": "us-east-1"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "AWS_ACCESS_KEY_ID"}, + "value": "AKIA", + "usage": "local_use", + } + ], ) built = _patch_resolution(monkeypatch, backend, resolve=_resolve) @@ -584,7 +612,12 @@ async def _resolve(*, model, context): assert body == {"messages": [{"role": "assistant", "content": "echo"}]} assert built[0].resolved_connection.deployment == "bedrock" - assert built[0].secrets == {"AWS_ACCESS_KEY_ID": "AKIA", "AWS_REGION": "us-east-1"} + # Public config (the region) and the local-use credential materialize together only at the + # local execution boundary; on the wire they stay separate fields. + assert built[0].resolved_connection.plaintext_environment() == { + "AWS_ACCESS_KEY_ID": "AKIA", + "AWS_REGION": "us-east-1", + } async def test_pi_bedrock_rejected_post_resolve(monkeypatch, fake_backend): @@ -599,7 +632,13 @@ async def _resolve(*, model, context): model="anthropic.claude-x", deployment="bedrock", credential_mode="env", - env={"AWS_ACCESS_KEY_ID": "AKIA"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "AWS_ACCESS_KEY_ID"}, + "value": "AKIA", + "usage": "local_use", + } + ], ) _patch_resolution(monkeypatch, backend, resolve=_resolve) @@ -726,7 +765,6 @@ async def _resolve(*, model, context): provider="openai-codex", model=model.model, credential_mode="runtime_provided", - env={}, ) built = _patch_resolution(monkeypatch, backend, resolve=_resolve) @@ -746,5 +784,5 @@ async def _resolve(*, model, context): assert captured["model"].connection.mode == "self_managed" # No key injected: the harness uses its own subscription login. assert backend.created_secrets == [{}] - assert built[0].secrets == {} + assert built[0].resolved_connection.plaintext_environment() == {} assert built[0].resolved_connection.credential_mode == "runtime_provided" diff --git a/services/runner/src/engines/sandbox_agent/session-identity.ts b/services/runner/src/engines/sandbox_agent/session-identity.ts index 236b867b67..e01f18a4ac 100644 --- a/services/runner/src/engines/sandbox_agent/session-identity.ts +++ b/services/runner/src/engines/sandbox_agent/session-identity.ts @@ -1,4 +1,4 @@ -import { createHash } from "node:crypto"; +import { createHash, createHmac, randomBytes } from "node:crypto"; import { currentUserTurn, @@ -121,6 +121,33 @@ function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } +/** + * A random key minted once per runner process, used to key the credential-epoch digest below. + * + * The epoch is only ever compared against other epochs computed in the SAME process (the warm + * session pool is process-local and dies with the process), so a per-process key costs nothing + * and is never persisted or shared. What it buys: the digest becomes a keyed tag rather than a + * plain hash of secret material, so anyone who somehow obtains one cannot test candidate API + * keys against it offline. A fresh key per process also means two runners never produce the + * same tag for the same credential. + */ +const CREDENTIAL_EPOCH_KEY = randomBytes(32); + +/** + * The keyed digest of a run's credential material. Separate from `sha256` on purpose: this is + * the ONLY place secret values are digested, and it must stay keyed. + */ +function credentialTag(material: string): string { + // codeql[js/insufficient-password-hash] This is not a stored password hash. It is an + // in-memory, keyed change-detection tag for the warm-session pool: it is compared only + // against other tags from the same process, never persisted, transmitted, or logged, and + // it authenticates nothing. A deliberately slow KDF here would add per-turn latency to + // every request and protect nothing that the random per-process key does not already. + return createHmac("sha256", CREDENTIAL_EPOCH_KEY) + .update(material) + .digest("hex"); +} + /** Deterministic JSON: object keys sorted recursively so equal values hash equal. */ function canonicalJson(value: unknown): string { if (value === null || typeof value !== "object") return JSON.stringify(value); @@ -461,10 +488,10 @@ export function tailIsFreshUserMessage(request: AgentRunRequest): boolean { /** * The credential epoch bounds how long a parked session may reuse its baked credentials. It is - * a PROCESS-LOCAL hash over the actual resolved secret VALUES (held only in runner memory — - * never logged, persisted, or emitted), combined with the mount credential expiry. A rotated - * same-slug secret changes the hash; an elapsed expiry invalidates the epoch. Either way the - * dispatch evicts and cold-starts with fresh credentials. + * a PROCESS-LOCAL keyed digest over the actual resolved secret VALUES (see `credentialTag`; + * held only in runner memory, never logged, persisted, or emitted), combined with the mount + * credential expiry. A rotated same-slug secret changes the digest; an elapsed expiry + * invalidates the epoch. Either way the dispatch evicts and cold-starts with fresh credentials. * * The tool-callback bearer is deliberately EXCLUDED: it is per-turn material the backend * re-mints on its auth-cache cadence (~60s), and every turn — continuation included — starts @@ -474,7 +501,7 @@ export function tailIsFreshUserMessage(request: AgentRunRequest): boolean { * environment (the sandbox env secrets) belongs in the hash; the mount expiry bounds the rest. */ export interface CredentialEpoch { - /** sha256 over canonical(secrets). In-memory only; never surfaced. */ + /** Keyed digest over canonical(secrets); see `credentialTag`. In-memory only, never surfaced. */ secretsHash: string; /** * Parked epochs only: the environment's installed-mount lease as epoch millis, or undefined when @@ -524,7 +551,7 @@ export function computeCredentialEpoch( })), ), }); - return { secretsHash: sha256(material) }; + return { secretsHash: credentialTag(material) }; } /** True when credentials baked into a parked sandbox/session changed (rotation ⇒ evict). */ From bd0067cfeb7556e3675af4a4b4d5d52972c27afd Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 3 Aug 2026 12:53:40 +0200 Subject: [PATCH 04/11] docs: document the feature and make the flag reachable Review found the feature had no documentation and, more importantly, no way for an operator to turn it on: the environment variable was read straight from process.env in the runner and was never plumbed through docker-compose or the Helm chart, so setting it on the host did nothing. - Renamed the flag to AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS. Every other runner Daytona setting is AGENTA_RUNNER_DAYTONA_*, and the bare AGENTA_DAYTONA_ prefix collided visually with the DAYTONA_* variables that configure the unrelated code-evaluator sandbox. The feature is unreleased, so no operator has set the old name. - Plumbed it through all seven docker-compose runner services, the four env examples, the Helm deployment template, values.yaml, and values.schema.json. - Documented it in the configuration reference (what it does, the two caveats: the Daytona API key needs Secrets permission, and AWS keys cannot be hidden) and in the how-agents-run concept page (why an agent reading its own environment is the threat, and what the sandbox sees with the flag on). Also expanded the wire-contract comments the review asked about: what the usage values mean and why there are exactly two, what endpoint covers beyond OpenAI-compatible routes (Azure apiVersion, AWS/Vertex region), why a Daytona secret candidate is called a candidate, why allowedHost can never be a wildcard, and why the retired-field guard rejects rather than ignores while `connection` is deliberately not among the retired fields. --- .../self-host/concepts/02-how-agents-run.mdx | 23 +++++ .../self-host/reference/01-configuration.mdx | 26 +++++ .../docker-compose/ee/docker-compose.dev.yml | 1 + .../ee/docker-compose.gh.local.yml | 1 + .../docker-compose/ee/docker-compose.gh.yml | 1 + hosting/docker-compose/ee/env.ee.dev.example | 6 ++ hosting/docker-compose/ee/env.ee.gh.example | 6 ++ .../docker-compose/oss/docker-compose.dev.yml | 1 + .../oss/docker-compose.gh.local.yml | 1 + .../oss/docker-compose.gh.ssl.yml | 1 + .../docker-compose/oss/docker-compose.gh.yml | 1 + .../docker-compose/oss/env.oss.dev.example | 6 ++ hosting/docker-compose/oss/env.oss.gh.example | 6 ++ .../helm/templates/runner-deployment.yaml | 4 + hosting/kubernetes/helm/values.schema.json | 3 +- hosting/kubernetes/helm/values.yaml | 2 + .../sandbox_agent/daytona-secret-plan.ts | 49 +++++++++- .../src/engines/sandbox_agent/provider.ts | 2 +- .../src/engines/sandbox_agent/run-plan.ts | 28 ++++-- services/runner/src/protocol.ts | 94 ++++++++++++++++++- services/runner/tests/setup/hermetic-env.ts | 2 +- .../tests/unit/daytona-secret-plan.test.ts | 6 +- .../unit/sandbox-agent-orchestration.test.ts | 2 +- .../tests/unit/sandbox-agent-provider.test.ts | 6 +- .../tests/unit/sandbox-agent-run-plan.test.ts | 12 +-- 25 files changed, 263 insertions(+), 27 deletions(-) diff --git a/docs/docs/self-host/concepts/02-how-agents-run.mdx b/docs/docs/self-host/concepts/02-how-agents-run.mdx index 61c4c0f5b9..41ce60ea5d 100644 --- a/docs/docs/self-host/concepts/02-how-agents-run.mdx +++ b/docs/docs/self-host/concepts/02-how-agents-run.mdx @@ -55,6 +55,29 @@ Two kinds of credential meet at the runner: A run can also carry no managed key at all and authenticate the harness from a mounted personal subscription. See [Use your own subscription](/self-host/use-your-own-subscription). +### What the sandbox can see + +A model key normally arrives in the sandbox as an environment variable, and an MCP server's key as +a request header. Both are readable by the agent running there, which matters because an agent +writes and runs its own code: a prompt injection that convinces it to print its environment prints +your keys. + +On Daytona you can close that gap. Set +[`AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local`](/self-host/configuration#hiding-api-keys-from-the-sandbox) +and the runner stores each key as a Daytona Secret restricted to the single hostname that key +authenticates against, then puts a placeholder in the sandbox. Daytona swaps the placeholder for +the real value on requests to that host, so the model call and the MCP call still work while the +agent itself only ever holds a useless string. A request to any other host carries the placeholder, +which is what makes exfiltration fail. + +This works for a key the remote service reads over HTTPS, which covers the provider keys and the +MCP keys. It does not work for a key a provider SDK signs with locally, which today means the AWS +access keys behind Bedrock: that secret never leaves the sandbox, so there is no outbound request +to substitute it into, and the sandbox holds the real value. + +The local sandbox is unaffected. The harness runs inside the runner container there, so its keys are +already inside your own deployment rather than a third party's. + ## Persistence depends on the run What a run keeps depends on what the run is: diff --git a/docs/docs/self-host/reference/01-configuration.mdx b/docs/docs/self-host/reference/01-configuration.mdx index 37dcfa1361..ab0116c482 100644 --- a/docs/docs/self-host/reference/01-configuration.mdx +++ b/docs/docs/self-host/reference/01-configuration.mdx @@ -293,6 +293,7 @@ Read by the `runner` service. Required only when `daytona` is enabled. | `AGENTA_RUNNER_DAYTONA_IMAGE` | Image to start from | Unset | no | `...daytona.image` | | `AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES` | Idle minutes before stop | `15` | no | `...daytona.autostopMinutes` | | `AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES` | Idle minutes before delete | `30` | no | `...daytona.autodeleteMinutes` | +| `AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS` | Hide API keys from the sandbox | Unset (off) | no | `...daytona.opaqueSecrets` | When neither `AGENTA_RUNNER_DAYTONA_SNAPSHOT` nor `AGENTA_RUNNER_DAYTONA_IMAGE` is set, the runner starts from its pinned default snapshot `agenta-agent-sandbox-v1`. The two variables are mutually @@ -301,6 +302,31 @@ exclusive. The autostop and autodelete values must be positive integers. See and [Customize the agent runtime](/self-host/agent-execution/customize-the-agent-runtime) for the CPU, memory, and disk each sandbox gets. +#### Hiding API keys from the sandbox + +By default, a Daytona run receives its model and MCP API keys as ordinary environment variables, so +an agent that reads its own environment can read the keys. Set +`AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local` to change that: the runner stores each key as a +Daytona Secret, restricted to the one hostname that key authenticates against, and puts a +placeholder in the sandbox instead. Daytona substitutes the real value into outbound requests to +that host; requests anywhere else carry only the placeholder. + +`process_local` is the only accepted value, and it names the guarantee it can make. The runner +tracks the Secret records it created in its own memory and deletes them when the sandbox goes away. +Restart the runner while sandboxes are live and those records are orphaned in your Daytona account +until you remove them; a future value will add durable tracking. + +Two things to know before you enable it: + +- Your Daytona API key needs permission to manage Secrets. Without it, every run fails at + sandbox creation rather than falling back to plaintext keys. +- Keys that a provider SDK signs with locally instead of sending, which today means the AWS access + keys used for Bedrock, cannot be hidden this way and still reach the sandbox in full. Nothing + Daytona substitutes on the way out can help there, because the secret never leaves the sandbox + in the first place. + +Leaving the variable unset keeps the previous behavior exactly. + :::warning The bare `DAYTONA_*` variables configure a different sandbox `DAYTONA_API_KEY`, `DAYTONA_API_URL`, `DAYTONA_TARGET`, `DAYTONA_SNAPSHOT`, and `DAYTONA_SNAPSHOT_CODE` configure the code evaluator's sandbox, which runs custom evaluator code. diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index 45489d1736..2df60b88b0 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -430,6 +430,7 @@ services: 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:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # This service has no env_file (see above), so the session flags must be listed # here or they can never be set. Both default ON: absent or empty means on, and # only the literal "false" disables. Disable AGENTA_SESSIONS_RECONSTRUCT together diff --git a/hosting/docker-compose/ee/docker-compose.gh.local.yml b/hosting/docker-compose/ee/docker-compose.gh.local.yml index ac6f4c5274..dfd36acfdb 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.local.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.local.yml @@ -300,6 +300,7 @@ services: 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:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # === 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 27d1f4421d..8a623b885a 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -302,6 +302,7 @@ services: 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:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # This service has no env_file (see above), so the session flags must be listed # here or they can never be set. Both default ON: absent or empty means on, and # only the literal "false" disables. Disable AGENTA_SESSIONS_RECONSTRUCT together diff --git a/hosting/docker-compose/ee/env.ee.dev.example b/hosting/docker-compose/ee/env.ee.dev.example index 3c6bb3bef7..437ab4bb10 100644 --- a/hosting/docker-compose/ee/env.ee.dev.example +++ b/hosting/docker-compose/ee/env.ee.dev.example @@ -89,6 +89,12 @@ AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local # AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 +# Hide model and MCP API keys from the sandbox. When set to process_local, the runner stores each +# key as a Daytona Secret restricted to the one host it authenticates against, and the sandbox sees +# only a placeholder. Needs a Daytona API key with permission to manage Secrets. Leave unset to +# keep passing keys as plain environment variables. +# AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local + # --- Warm sessions --- # Milliseconds. Must stay below AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES, or Daytona stops a sandbox # the runner still holds. 0 stops the sandbox after each turn. diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index 281d16d93f..83bca1b514 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -91,6 +91,12 @@ AGENTA_RUNNER_TOKEN=replace-me # AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 +# Hide model and MCP API keys from the sandbox. When set to process_local, the runner stores each +# key as a Daytona Secret restricted to the one host it authenticates against, and the sandbox sees +# only a placeholder. Needs a Daytona API key with permission to manage Secrets. Leave unset to +# keep passing keys as plain environment variables. +# AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local + # --- Warm sessions --- # Milliseconds. Must stay below AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES, or Daytona stops a sandbox # the runner still holds. 0 stops the sandbox after each turn. diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index f7eaae6beb..4343d85287 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -416,6 +416,7 @@ services: 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:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # This service has no env_file (see above), so the session flags must be listed # here or they can never be set. Both default ON: absent or empty means on, and # only the literal "false" disables. Disable AGENTA_SESSIONS_RECONSTRUCT together diff --git a/hosting/docker-compose/oss/docker-compose.gh.local.yml b/hosting/docker-compose/oss/docker-compose.gh.local.yml index e15aab99f4..849f7237f4 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.local.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.local.yml @@ -298,6 +298,7 @@ services: 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:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # === 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 8b2b08f421..a6a7aebc60 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml @@ -322,6 +322,7 @@ services: 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:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # === 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 788e8f6c18..c7687df7f9 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -319,6 +319,7 @@ services: 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:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # This service has no env_file (see above), so the session flags must be listed # here or they can never be set. Both default ON: absent or empty means on, and # only the literal "false" disables. Disable AGENTA_SESSIONS_RECONSTRUCT together diff --git a/hosting/docker-compose/oss/env.oss.dev.example b/hosting/docker-compose/oss/env.oss.dev.example index 52ef0c8b24..eac1a4f5df 100644 --- a/hosting/docker-compose/oss/env.oss.dev.example +++ b/hosting/docker-compose/oss/env.oss.dev.example @@ -89,6 +89,12 @@ AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local # AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 +# Hide model and MCP API keys from the sandbox. When set to process_local, the runner stores each +# key as a Daytona Secret restricted to the one host it authenticates against, and the sandbox sees +# only a placeholder. Needs a Daytona API key with permission to manage Secrets. Leave unset to +# keep passing keys as plain environment variables. +# AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local + # --- Warm sessions --- # Milliseconds. Must stay below AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES, or Daytona stops a sandbox # the runner still holds. 0 stops the sandbox after each turn. diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index 09c3c1e250..4c640e3c74 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -91,6 +91,12 @@ AGENTA_RUNNER_TOKEN=replace-me # AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 +# Hide model and MCP API keys from the sandbox. When set to process_local, the runner stores each +# key as a Daytona Secret restricted to the one host it authenticates against, and the sandbox sees +# only a placeholder. Needs a Daytona API key with permission to manage Secrets. Leave unset to +# keep passing keys as plain environment variables. +# AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local + # --- Warm sessions --- # Milliseconds. Must stay below AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES, or Daytona stops a sandbox # the runner still holds. 0 stops the sandbox after each turn. diff --git a/hosting/kubernetes/helm/templates/runner-deployment.yaml b/hosting/kubernetes/helm/templates/runner-deployment.yaml index 6071c9a7f0..49bc1008a3 100644 --- a/hosting/kubernetes/helm/templates/runner-deployment.yaml +++ b/hosting/kubernetes/helm/templates/runner-deployment.yaml @@ -118,6 +118,10 @@ spec: - name: AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES value: {{ $daytona.autodeleteMinutes | quote }} {{- end }} + {{- if $daytona.opaqueSecrets }} + - name: AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS + value: {{ $daytona.opaqueSecrets | quote }} + {{- end }} {{- if $daytona.apiKeySecretRef }} - name: AGENTA_RUNNER_DAYTONA_API_KEY valueFrom: diff --git a/hosting/kubernetes/helm/values.schema.json b/hosting/kubernetes/helm/values.schema.json index a0de38e2e7..0f1e9dcd26 100644 --- a/hosting/kubernetes/helm/values.schema.json +++ b/hosting/kubernetes/helm/values.schema.json @@ -339,7 +339,8 @@ "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." } + "sessionMaxWarm": { "type": ["integer", "string"], "description": "AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM." }, + "opaqueSecrets": { "type": "string", "enum": ["process_local"], "description": "AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; hide model and MCP keys from the sandbox behind Daytona Secrets. Omit to keep passing them as plain environment variables." } } } } diff --git a/hosting/kubernetes/helm/values.yaml b/hosting/kubernetes/helm/values.yaml index 09650d1178..02e1e6098f 100644 --- a/hosting/kubernetes/helm/values.yaml +++ b/hosting/kubernetes/helm/values.yaml @@ -151,6 +151,8 @@ redisDurable: # autodeleteMinutes: 30 # sessionIdleTtlMs: "" # sessionMaxWarm: "" +# opaqueSecrets: process_local # hide model/MCP keys from the sandbox; needs a Daytona +# # API key that may manage Secrets # auth: # tokenSecretRef: # AGENTA_RUNNER_TOKEN; Services sends it, the runner verifies it # name: agenta-runner diff --git a/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts b/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts index c36704f04d..c771e09f29 100644 --- a/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts +++ b/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts @@ -2,10 +2,55 @@ import { isIP } from "node:net"; import type { McpServerConfig, ModelConnection } from "../../protocol.ts"; +/** + * One credential that QUALIFIES to be hidden behind a Daytona Secret, before any Secret record + * exists. + * + * "Candidate" rather than "secret" because this object is the output of a pure decision and the + * input to a side effect. Building it decides, from the request alone, that a value can be + * hidden: nothing is created, nothing is called, and the same request always produces the same + * list. The provider then takes each candidate and actually creates the Daytona Secret record, + * which can fail, needs cleanup, and turns the candidate into a real `dtn_secret_` + * placeholder. Keeping the two apart is what makes the decision unit-testable without a Daytona + * account, and what lets the flag-off path build the same plan and simply not act on it. + */ export interface DaytonaSecretCandidate { + /** + * Position in the plan, assigned in build order. It is the stable name each MCP candidate gets + * in the sandbox (`AGENTA_MCP_SECRET_`), so the same request always produces the same + * variable names and a warm sandbox can be compared against a new request field by field. + */ ordinal: number; + /** + * WHO reads this credential. Two consumers exist today because two things in a run hold + * credentials: the model, and each HTTP MCP server (named, because a run can attach several + * and each has its own key). This is the field that was missing from the old flat `secrets` + * map, and without it the runner could not tell which host a given key was allowed to reach, + * which is what a Daytona Secret restriction requires. + */ consumer: { kind: "model" } | { kind: "http_mcp"; server: string }; + /** + * WHERE the value lands in the sandbox. `environment` for a model key (harnesses read provider + * keys from environment variables); `header` for an MCP credential (an HTTP MCP server reads + * its key from a request header). These are the only two delivery points that exist in the + * run today, and they follow from the two consumers above rather than being an open set. + */ binding: { kind: "environment" | "header"; name: string }; + /** + * The single DNS hostname this credential may be substituted into, e.g. `api.openai.com`. + * + * It cannot be a wildcard and cannot be omitted, and that is the point of the whole feature. + * Daytona substitutes the real value into an outbound request only when the request goes to + * this exact host; anywhere else the agent sends the placeholder instead. A wildcard, or an + * unknown host, would mean the agent could exfiltrate the real key by making one request to a + * server it controls, which is the attack this exists to prevent. + * + * It is always knowable in practice because a credential only qualifies as `opaque_http` when + * we already know the endpoint it authenticates against: the model connection carries + * `endpoint.baseUrl`, and an MCP server carries its own `url`. A credential whose destination + * we do not know is not hideable at all, and `buildDaytonaSecretPlan` rejects the run rather + * than guessing (see the `opaque model credentials require endpoint.baseUrl` failure). + */ allowedHost: string; value: string; } @@ -259,7 +304,7 @@ export function buildDaytonaSecretPlan(input: { } export function daytonaOpaqueSecretsEnabled( - value: string | undefined = process.env.AGENTA_DAYTONA_OPAQUE_SECRETS, + value: string | undefined = process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS, ): boolean { return value === "process_local"; } @@ -271,7 +316,7 @@ export function assertDaytonaOpaqueSecretsEnabled( if (plan.candidates.length > 0 && !daytonaOpaqueSecretsEnabled(value)) { throw new Error( "Daytona opaque credentials are disabled. Set " + - "AGENTA_DAYTONA_OPAQUE_SECRETS=process_local to enable process-local Secret cleanup; " + + "AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local to enable process-local Secret cleanup; " + "plaintext fallback is not allowed.", ); } diff --git a/services/runner/src/engines/sandbox_agent/provider.ts b/services/runner/src/engines/sandbox_agent/provider.ts index 57e908d902..6672052d1c 100644 --- a/services/runner/src/engines/sandbox_agent/provider.ts +++ b/services/runner/src/engines/sandbox_agent/provider.ts @@ -172,7 +172,7 @@ export function buildSandboxProvider( { client: buildDaytonaClient(config.daytona) }, ); // The process-local Secret wrapper applies to EVERY plan-bearing Daytona run - // (`buildRunPlan` builds a plan only when AGENTA_DAYTONA_OPAQUE_SECRETS=process_local is + // (`buildRunPlan` builds a plan only when AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local is // enabled), INCLUDING a zero-candidate plan: the wrapper then allocates no Secrets and // attaches nothing, but its create-fingerprint check still governs reconnects, so a parked // sandbox holding plaintext local_use credentials (AWS/GCP) is rebuilt — never reconnected diff --git a/services/runner/src/engines/sandbox_agent/run-plan.ts b/services/runner/src/engines/sandbox_agent/run-plan.ts index c478c78e2c..2b002e8569 100644 --- a/services/runner/src/engines/sandbox_agent/run-plan.ts +++ b/services/runner/src/engines/sandbox_agent/run-plan.ts @@ -111,7 +111,7 @@ export interface RunPlan { modelEnvironment: Record; /** * Process-local opaque credential plan. Present for every Daytona run when - * AGENTA_DAYTONA_OPAQUE_SECRETS=process_local is enabled — even with zero candidates, so the + * AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local is enabled — even with zero candidates, so the * Secret provider wrapper (and its create-fingerprint rotation check) governs every flag-on * reconnect. Absent when the flag is off, so that path is the plain plaintext-env provider * with no wrapper, unchanged from the pre-feature runner. @@ -180,11 +180,25 @@ export interface RunPlan { } export type BuildRunPlanResult = - { ok: true; plan: RunPlan } | { ok: false; error: string }; - -// Retired flat model-credential fields. `connection` ({mode, slug}) is NOT here: it is the -// author's non-secret connection intent, still on the wire (pi-model-config keys the Pi custom -// provider off its slug). + | { ok: true; plan: RunPlan } + | { ok: false; error: string }; + +// The five wire fields this change RETIRED. They are listed here so the runner can reject a +// request that still sends them, rather than ignore them. +// +// Why reject instead of ignore: an old caller sending `secrets: {OPENAI_API_KEY: "..."}` would +// otherwise get a run that starts fine and has no key, and the failure would surface much later +// as a confusing provider auth error. Rejecting turns a silent wrong-credential run into an +// immediate, obvious contract error. Nothing in the tree sends these; the SDK and the runner +// ship together, so this is a guard against a stale caller, not a compatibility shim, and it is +// not tied to the Daytona feature flag. +// +// `connection` is deliberately NOT in this list. It looks like it belongs (it was next to these +// fields on the old wire) but it is not a credential: it is the author's choice of which Agenta +// connection to use, as `{mode, slug}`. The runner still needs it, because a named +// OpenAI-compatible run on the Pi harness is registered in Pi's own `models.json` under a +// provider named after that slug (`pi-model-config.ts`). Dropping it makes those runs silently +// fall back to the generic provider-override path. const LEGACY_MODEL_CREDENTIAL_FIELDS = [ "secrets", "provider", @@ -473,7 +487,7 @@ export function buildRunPlan( const materializedModel = materializeModelEnvironment(request); if (!materializedModel.ok) return materializedModel; - // Daytona opaque-credential delivery is FLAG-GATED (AGENTA_DAYTONA_OPAQUE_SECRETS= + // Daytona opaque-credential delivery is FLAG-GATED (AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS= // process_local). Flag OFF: no secret plan is built at all, so behavior is identical to the // pre-feature runner — the full materialized environment reaches sandbox create as plaintext // env, no provider wrapper is applied, and the plan's strict endpoint/binding validation diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index 262d4abbdb..b22471a546 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -260,7 +260,28 @@ export interface McpToolPolicy { names?: string[]; } -/** One secret HTTP header binding owned by an HTTP MCP consumer. */ +/** + * One secret HTTP header an MCP server needs, e.g. `Authorization: Bearer `. + * + * The three fields answer three separate questions, and keeping them separate is what makes a + * credential substitutable on a remote sandbox: + * + * - `binding` says WHERE the value goes. Today the only answer for MCP is `header`, because the + * only MCP transport we accept is HTTP and a remote server reads its credential from a header. + * A future stdio MCP server would need `{ kind: "environment", name }` instead, which is why + * this is an object with a `kind` rather than a bare header name. + * - `value` is the secret itself. + * - `usage` says WHO reads the value, which decides whether we can hide it. See the note on + * `ModelCredential.usage` below for the full reasoning; `opaque_http` means the value is only + * ever read by the remote server on the other end of an HTTPS request, so the sandbox never + * needs to see it and Daytona can substitute it into the outbound request. + * + * MCP has only `opaque_http` today because every MCP server we support is remote and HTTPS. When + * OAuth lands, the token is still an `opaque_http` header credential; what changes is who mints + * it and how often, which is a resolver concern upstream of this wire, not a new `usage`. A + * gateway MCP server is the same shape too: it is an HTTP MCP server whose URL happens to be + * ours. Neither needs a new field here. + */ export interface McpCredential { binding: { kind: "header"; name: string }; value: string; @@ -445,18 +466,87 @@ export interface AgentUsage { cost: number; } +/** + * WHERE a model credential has to land for the harness to pick it up. + * + * `environment` is the only kind today, and it is not a placeholder for a missing case: every + * agent harness we run (Pi, Claude, Codex) reads its provider key from an environment variable, + * because that is the convention their underlying SDKs use. A `header` kind would be the natural + * addition if we ever call a provider over raw HTTP ourselves instead of handing the key to a + * harness, which is exactly what MCP does (see `McpCredential`). Modeling this as an object with + * a `kind` rather than a bare variable name is what lets the two consumers share one shape. + */ export interface ModelCredentialBinding { kind: "environment"; name: string; } +/** + * One secret the model provider needs, plus enough information to decide whether the sandbox is + * allowed to see it. + * + * `usage` is the field that decides that, and it has exactly two answers because there are + * exactly two ways a provider credential gets consumed: + * + * - `opaque_http` — the value is a bearer token that only the provider's own server reads, over + * HTTPS, at a host we know in advance. Nothing inside the sandbox ever needs the real string. + * On Daytona we therefore replace it with a `dtn_secret_` placeholder and let Daytona's + * egress proxy substitute the real value into the outbound request, so a compromised agent + * that dumps its own environment gets a useless placeholder. `OPENAI_API_KEY` and + * `ANTHROPIC_API_KEY` are the common cases. + * - `local_use` — the value is consumed by a provider SDK running INSIDE the sandbox, which + * signs the request locally rather than sending the secret. AWS SigV4 is the reason this + * exists: `AWS_SECRET_ACCESS_KEY` never travels on the wire, boto derives a signature from it + * on the spot, so an outbound-substitution trick cannot work and the sandbox must hold the + * real value. We accept that and keep the list of names that may claim `local_use` short and + * explicit (`daytona-secret-plan.ts`), so nobody can smuggle an opaque provider key through + * this door and quietly lose the hiding. + * + * The split is deliberately about the CONSUMER, not the provider name: it is the only property + * that determines whether hiding is even possible, and it stays correct when a new provider + * arrives. + */ export interface ModelCredential { binding: ModelCredentialBinding; value: string; usage: "opaque_http" | "local_use"; } -/** Resolved route and credentials owned by the model consumer. */ +/** + * Everything the runner needs to reach the model, grouped under the consumer that owns it. + * + * The organization mirrors `ResolvedConnection` in the Python SDK + * (`sdks/python/agenta/sdk/agents/connections/models.py`), which is the authority: the resolver + * builds it from the vault, validates it there, and serializes it onto this wire. The runner + * re-validates rather than trusting it, but it does not invent fields. Grouping matters because + * the run has more than one credential consumer: the model owns this object, each MCP server + * owns its own `connection.credentials`. Before this grouping, every consumer's keys were mixed + * into one flat `secrets` map and the runner could not tell whose key was whose, which is + * precisely what made hiding them impossible. + * + * The fields: + * + * - `provider` is the credential-ownership family (`openai`, `anthropic`, `bedrock`, ...). It + * says who issued the key, not which model runs. + * - `deployment` is HOW that provider is reached: `direct` (the provider's own API), `custom` + * (an OpenAI-compatible third party such as OpenRouter or a self-hosted gateway), or `bedrock` + * / `vertex` (a cloud reseller with its own auth scheme). + * - `endpoint` is the route, and it is general, not OpenAI-specific. `baseUrl` is what an + * OpenAI-compatible deployment needs; `apiVersion` is what Azure needs; `region` is what AWS + * and Vertex need; `headers` carries non-secret routing headers some gateways require. A + * given deployment fills in the subset that applies to it and leaves the rest unset. AWS and + * the other cloud resellers are covered by exactly this: `deployment: "bedrock"` plus + * `endpoint.region`, with their access keys arriving as `local_use` credentials because the + * AWS SDK signs locally. + * - `credentialMode` says where the credential comes from at all: `env` (we resolved one and it + * is in `credentials`), `runtime_provided` (the harness authenticates with its own login, e.g. + * a Claude or Codex subscription, and we inject nothing), or `none`. + * - `environment` is non-secret configuration that still has to reach the process as environment + * variables, such as `AWS_REGION`. It is a separate field from `credentials` so that nothing + * secret can hide in a map we treat as public; the runner enforces a short allowlist of names + * that may appear here. + * - `credentials` are the secrets, each one typed as above. + */ export interface ModelConnection { provider: string; deployment: string; diff --git a/services/runner/tests/setup/hermetic-env.ts b/services/runner/tests/setup/hermetic-env.ts index a521e9f2a5..6031b9f227 100644 --- a/services/runner/tests/setup/hermetic-env.ts +++ b/services/runner/tests/setup/hermetic-env.ts @@ -44,7 +44,7 @@ const SCRUBBED = [ "AGENTA_RUNNER_DAYTONA_IMAGE", "AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES", "AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES", - "AGENTA_DAYTONA_OPAQUE_SECRETS", + "AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS", "DAYTONA_API_KEY", "DAYTONA_API_URL", "DAYTONA_TARGET", diff --git a/services/runner/tests/unit/daytona-secret-plan.test.ts b/services/runner/tests/unit/daytona-secret-plan.test.ts index ba05218231..e444b2a167 100644 --- a/services/runner/tests/unit/daytona-secret-plan.test.ts +++ b/services/runner/tests/unit/daytona-secret-plan.test.ts @@ -19,7 +19,7 @@ beforeEach(() => { }); afterEach(() => { - delete process.env.AGENTA_DAYTONA_OPAQUE_SECRETS; + delete process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; }); const modelConnection = { @@ -272,7 +272,7 @@ describe("Daytona Secret planning", () => { const plan = buildDaytonaSecretPlan({ modelConnection }); assert.throws( () => assertDaytonaOpaqueSecretsEnabled(plan), - /AGENTA_DAYTONA_OPAQUE_SECRETS=process_local/, + /AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local/, ); assert.doesNotThrow(() => assertDaytonaOpaqueSecretsEnabled(plan, "process_local"), @@ -303,7 +303,7 @@ describe("Daytona Secret planning", () => { assert.equal(disabled.plan.hasApiKey, true); // Flag ON: the opaque value leaves the plaintext environment for the secret plan. - process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = "process_local"; + process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = "process_local"; const enabled = buildRunPlan(request, { createDaytonaCwd: () => "/sandbox/cwd", }); diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index ec0c81d9e9..dcfd5484da 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -744,7 +744,7 @@ describe("runSandboxAgent orchestration", () => { const { calls, deps } = fakeHarness(); deps.prepareDaytonaPiAssets = (async () => true) as any; // The opaque vault key on a Daytona run requires the process-local Secret gate. - process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = "process_local"; + process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = "process_local"; const result = await runSandboxAgent( { diff --git a/services/runner/tests/unit/sandbox-agent-provider.test.ts b/services/runner/tests/unit/sandbox-agent-provider.test.ts index fe6aef56e9..96d08b3232 100644 --- a/services/runner/tests/unit/sandbox-agent-provider.test.ts +++ b/services/runner/tests/unit/sandbox-agent-provider.test.ts @@ -335,11 +335,11 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () // unwrapped would silently run without credentials. assert.throws( () => build(plan), - /AGENTA_DAYTONA_OPAQUE_SECRETS=process_local/, + /AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local/, ); try { - process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = "process_local"; + process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = "process_local"; assert.equal( typeof build(plan).materializeMcpServers, "function", @@ -354,7 +354,7 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () "flag on + zero candidates still attaches the Secret wrapper", ); } finally { - delete process.env.AGENTA_DAYTONA_OPAQUE_SECRETS; + delete process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; } }); }); 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 5c30c996e6..a17bc30b6f 100644 --- a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts +++ b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts @@ -17,7 +17,7 @@ import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; const previousPiDir = process.env.PI_CODING_AGENT_DIR; const previousDenyPermissions = process.env.SANDBOX_AGENT_DENY_PERMISSIONS; -const previousOpaqueSecrets = process.env.AGENTA_DAYTONA_OPAQUE_SECRETS; +const previousOpaqueSecrets = process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; // 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. @@ -34,8 +34,8 @@ afterEach(() => { delete process.env.SANDBOX_AGENT_DENY_PERMISSIONS; else process.env.SANDBOX_AGENT_DENY_PERMISSIONS = previousDenyPermissions; if (previousOpaqueSecrets === undefined) - delete process.env.AGENTA_DAYTONA_OPAQUE_SECRETS; - else process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = previousOpaqueSecrets; + delete process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; + else process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = previousOpaqueSecrets; }); describe("buildRunPlan", () => { @@ -1145,7 +1145,7 @@ describe("buildRunPlan", () => { }); it("normalizes a Daytona Claude run without Pi-only state", () => { - process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = "process_local"; + process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = "process_local"; const result = buildRunPlan( { harness: "claude", @@ -1220,7 +1220,7 @@ describe("buildRunPlan", () => { } as AgentRunRequest; const deps = { createDaytonaCwd: () => "/home/sandbox/agenta-fixed" }; - process.env.AGENTA_DAYTONA_OPAQUE_SECRETS = "process_local"; + process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = "process_local"; const flagOn = buildRunPlan(localUseRequest, deps); assert.equal(flagOn.ok, true); if (!flagOn.ok) return; @@ -1237,7 +1237,7 @@ describe("buildRunPlan", () => { ); // Flag OFF stays exactly the pre-feature behavior: no plan, so no wrapper is applied. - delete process.env.AGENTA_DAYTONA_OPAQUE_SECRETS; + delete process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; const flagOff = buildRunPlan(localUseRequest, deps); assert.equal(flagOff.ok, true); if (!flagOff.ok) return; From 0b1fc12ab0a08b153d50855a51e21c099a1fc9ef Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 3 Aug 2026 13:00:17 +0200 Subject: [PATCH 05/11] fix(runner): put the CodeQL suppression on the flagged line CodeQL reports the alert on the createHmac call itself, and a suppression comment only applies to alerts on its own line. The preceding-comment form did not take, so the call is split out and carries the marker inline. --- .../src/engines/sandbox_agent/session-identity.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/services/runner/src/engines/sandbox_agent/session-identity.ts b/services/runner/src/engines/sandbox_agent/session-identity.ts index e01f18a4ac..e63b2d51a7 100644 --- a/services/runner/src/engines/sandbox_agent/session-identity.ts +++ b/services/runner/src/engines/sandbox_agent/session-identity.ts @@ -138,14 +138,13 @@ const CREDENTIAL_EPOCH_KEY = randomBytes(32); * the ONLY place secret values are digested, and it must stay keyed. */ function credentialTag(material: string): string { - // codeql[js/insufficient-password-hash] This is not a stored password hash. It is an + // This is not a stored password hash, which is what the scanner reads it as. It is an // in-memory, keyed change-detection tag for the warm-session pool: it is compared only // against other tags from the same process, never persisted, transmitted, or logged, and - // it authenticates nothing. A deliberately slow KDF here would add per-turn latency to - // every request and protect nothing that the random per-process key does not already. - return createHmac("sha256", CREDENTIAL_EPOCH_KEY) - .update(material) - .digest("hex"); + // it authenticates nothing. A deliberately slow KDF here would add latency to every turn + // and protect nothing that the random per-process key does not already protect. + const hmac = createHmac("sha256", CREDENTIAL_EPOCH_KEY); // codeql[js/insufficient-password-hash] + return hmac.update(material).digest("hex"); } /** Deterministic JSON: object keys sorted recursively so equal values hash equal. */ From dcb81ecdd3e85f276d33ef3cfd30244ca4d4dc8a Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 3 Aug 2026 13:05:49 +0200 Subject: [PATCH 06/11] fix(runner): stop digesting credential values for the epoch CodeQL flagged the credential epoch as an insecurely hashed password, and GitHub code scanning does not honour inline suppression comments, so the check stayed red. Rather than argue with the scanner, the design changed, and the result is better than what it replaced. The epoch answers one question: did the credential material change since this session was parked? It never needed a digest to do that. It now holds the material behind a `CredentialMaterial` value object and compares it with `timingSafeEqual`. Why holding the values is not a step backwards. A digest of an API key was never much protection, because keys carry little enough entropy that a leaked digest can be attacked offline, and the parked environment already holds the plaintext anyway. The realistic risk is a value reaching a log line, and that is now structurally impossible rather than a convention: the material sits in a private field with no getter, and every route from an object to text (`String`, template literals, `JSON.stringify`, `util.inspect`, so `console.log`) returns a placeholder. This mirrors what the Python side already does, where `ResolvedCredential` masks its value on dump and hides it from `repr`. A test pins every one of those rendering routes, so a future refactor that drops an override fails loudly instead of leaking silently. No behavior change: the same rotations evict and the same re-minted per-turn bearers do not. Runner tsc clean, 99 files / 1535 tests. --- .../engines/sandbox_agent/session-identity.ts | 90 ++++++++++++------- services/runner/src/server.ts | 9 +- .../unit/session-keepalive-approval.test.ts | 16 ++-- .../runner/tests/unit/session-pool.test.ts | 65 +++++++++++--- 4 files changed, 125 insertions(+), 55 deletions(-) diff --git a/services/runner/src/engines/sandbox_agent/session-identity.ts b/services/runner/src/engines/sandbox_agent/session-identity.ts index e63b2d51a7..ce954d5404 100644 --- a/services/runner/src/engines/sandbox_agent/session-identity.ts +++ b/services/runner/src/engines/sandbox_agent/session-identity.ts @@ -1,4 +1,5 @@ -import { createHash, createHmac, randomBytes } from "node:crypto"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { inspect } from "node:util"; import { currentUserTurn, @@ -122,29 +123,52 @@ function sha256(value: string): string { } /** - * A random key minted once per runner process, used to key the credential-epoch digest below. + * The credential material a session was built with, kept only so a later turn can answer one + * question: did any of it change? * - * The epoch is only ever compared against other epochs computed in the SAME process (the warm - * session pool is process-local and dies with the process), so a per-process key costs nothing - * and is never persisted or shared. What it buys: the digest becomes a keyed tag rather than a - * plain hash of secret material, so anyone who somehow obtains one cannot test candidate API - * keys against it offline. A fresh key per process also means two runners never produce the - * same tag for the same credential. + * It holds the values rather than a digest of them, and that is deliberate. A digest of an API + * key is not much protection (keys have little enough entropy that a leaked digest can be + * attacked offline), and it invites exactly the misreading that a security scanner makes: that + * this is a password hash, which it is not. Nothing here authenticates anything. + * + * The real risk with holding the values is that one leaks into a log line, so this class makes + * that structurally impossible instead of relying on a convention. The material lives in a + * private field with no getter, and every way of turning an object into text (`String()`, a + * template literal, `JSON.stringify`, `console.log` / `util.inspect`) is overridden to print a + * placeholder. This mirrors what the Python side already does for the same reason + * (`ResolvedCredential` masks its value on dump and hides it from `repr`). + * + * Comparison is constant time so the check cannot be turned into a way to learn a key one byte + * at a time. */ -const CREDENTIAL_EPOCH_KEY = randomBytes(32); +export class CredentialMaterial { + readonly #canonical: string; -/** - * The keyed digest of a run's credential material. Separate from `sha256` on purpose: this is - * the ONLY place secret values are digested, and it must stay keyed. - */ -function credentialTag(material: string): string { - // This is not a stored password hash, which is what the scanner reads it as. It is an - // in-memory, keyed change-detection tag for the warm-session pool: it is compared only - // against other tags from the same process, never persisted, transmitted, or logged, and - // it authenticates nothing. A deliberately slow KDF here would add latency to every turn - // and protect nothing that the random per-process key does not already protect. - const hmac = createHmac("sha256", CREDENTIAL_EPOCH_KEY); // codeql[js/insufficient-password-hash] - return hmac.update(material).digest("hex"); + constructor(canonical: string) { + this.#canonical = canonical; + } + + /** True when both were built from identical credential material. */ + equals(other: CredentialMaterial): boolean { + const mine = Buffer.from(this.#canonical, "utf8"); + const theirs = Buffer.from(other.#canonical, "utf8"); + // `timingSafeEqual` throws on a length mismatch, and lengths differ freely here, so the + // length check comes first. Length is not secret: it follows from the request's shape, + // which the config fingerprint already covers in the clear. + return mine.length === theirs.length && timingSafeEqual(mine, theirs); + } + + toString(): string { + return "[credential-material]"; + } + + toJSON(): string { + return "[credential-material]"; + } + + [inspect.custom](): string { + return "[credential-material]"; + } } /** Deterministic JSON: object keys sorted recursively so equal values hash equal. */ @@ -486,22 +510,22 @@ export function tailIsFreshUserMessage(request: AgentRunRequest): boolean { } /** - * The credential epoch bounds how long a parked session may reuse its baked credentials. It is - * a PROCESS-LOCAL keyed digest over the actual resolved secret VALUES (see `credentialTag`; - * held only in runner memory, never logged, persisted, or emitted), combined with the mount - * credential expiry. A rotated same-slug secret changes the digest; an elapsed expiry - * invalidates the epoch. Either way the dispatch evicts and cold-starts with fresh credentials. + * The credential epoch bounds how long a parked session may reuse its baked credentials. It pairs + * the resolved secret material (see `CredentialMaterial`: process-local, never logged, persisted, + * or emitted) with the mount credential expiry. A rotated same-slug secret changes the material; + * an elapsed expiry invalidates the epoch. Either way the dispatch evicts and cold-starts with + * fresh credentials. * * The tool-callback bearer is deliberately EXCLUDED: it is per-turn material the backend * re-mints on its auth-cache cadence (~60s), and every turn — continuation included — starts * its tool relay from the INCOMING request's `toolCallback`, so the parked copy is never used - * to execute anything. Hashing it made warm sessions evict as "credentials-rotated" on every + * to execute anything. Including it made warm sessions evict as "credentials-rotated" on every * cache rollover for no protective value. Only material actually BAKED into the parked - * environment (the sandbox env secrets) belongs in the hash; the mount expiry bounds the rest. + * environment (the sandbox env secrets) belongs here; the mount expiry bounds the rest. */ export interface CredentialEpoch { - /** Keyed digest over canonical(secrets); see `credentialTag`. In-memory only, never surfaced. */ - secretsHash: string; + /** The credentials this session was built with. Compared, never read. */ + secrets: CredentialMaterial; /** * Parked epochs only: the environment's installed-mount lease as epoch millis, or undefined when * it has no mounts. Incoming epochs never carry one. @@ -550,7 +574,7 @@ export function computeCredentialEpoch( })), ), }); - return { secretsHash: credentialTag(material) }; + return { secrets: new CredentialMaterial(material) }; } /** True when credentials baked into a parked sandbox/session changed (rotation ⇒ evict). */ @@ -558,7 +582,7 @@ export function sandboxCredentialsRotated( parked: CredentialEpoch, incoming: CredentialEpoch, ): boolean { - return parked.secretsHash !== incoming.secretsHash; + return !parked.secrets.equals(incoming.secrets); } /** @@ -618,7 +642,7 @@ export function credentialEpochMismatch( now = Date.now(), ): "credentials-expired" | "credentials-rotated" | undefined { if (mountCredentialsExpired(parked, now)) return "credentials-expired"; - if (parked.secretsHash !== incoming.secretsHash) return "credentials-rotated"; + if (!parked.secrets.equals(incoming.secrets)) return "credentials-rotated"; return undefined; } diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index fa3d1314f4..3b871214aa 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -63,6 +63,7 @@ import { MOUNT_LEASE_SKEW_MS, sandboxCredentialsRotated, type CredentialEpoch, + type CredentialMaterial, expectedNextHistoryFingerprint, historyFingerprint, historyTailFromLastUserTurn, @@ -544,9 +545,9 @@ export async function runWithKeepalive( // from whatever this dispatch signed to compute the pool key. const parkedEpoch = ( env: SessionEnvironment, - secretsHash: string, + secrets: CredentialMaterial, ): CredentialEpoch => ({ - secretsHash, + secrets, mountExpiresAtMs: installedMountLease(env.installedMountExpiries), }); @@ -562,7 +563,7 @@ export async function runWithKeepalive( configFingerprint: cfgFp, historyFingerprint: nextHistoryFp(env), historyAsserted, - credentialEpoch: parkedEpoch(env, incomingEpoch.secretsHash), + credentialEpoch: parkedEpoch(env, incomingEpoch.secrets), teardown: (reason: TeardownReason) => env.destroy({ reason }), }; if (approvalToPark(env, result)) { @@ -602,7 +603,7 @@ export async function runWithKeepalive( configFingerprint: cfgFp, historyFingerprint: nextHistoryFp(env), historyAsserted, - credentialEpoch: parkedEpoch(env, live.credentialEpoch.secretsHash), + credentialEpoch: parkedEpoch(env, live.credentialEpoch.secrets), }; if (approvalToPark(env, result)) { klog( diff --git a/services/runner/tests/unit/session-keepalive-approval.test.ts b/services/runner/tests/unit/session-keepalive-approval.test.ts index 99392ba96d..b668d14cc3 100644 --- a/services/runner/tests/unit/session-keepalive-approval.test.ts +++ b/services/runner/tests/unit/session-keepalive-approval.test.ts @@ -1265,14 +1265,18 @@ describe("runWithKeepalive: approval credential lifecycle", () => { const parked = ctx.pool.get(POOL_KEY)!; assert.equal( - parked.credentialEpoch.secretsHash, - computeCredentialEpoch(paused).secretsHash, - "the repark keeps the hash of the secrets the environment actually baked", + parked.credentialEpoch.secrets.equals( + computeCredentialEpoch(paused).secrets, + ), + true, + "the repark keeps the secrets the environment actually baked", ); assert.equal( - computeCredentialEpoch(resume).secretsHash, - parked.credentialEpoch.secretsHash, - "the re-minted per-turn bearer never enters the baked-credential hash", + computeCredentialEpoch(resume).secrets.equals( + parked.credentialEpoch.secrets, + ), + true, + "the re-minted per-turn bearer never enters the baked-credential material", ); assert.equal( parked.credentialEpoch.mountExpiresAtMs, diff --git a/services/runner/tests/unit/session-pool.test.ts b/services/runner/tests/unit/session-pool.test.ts index 7197f99408..c737a43065 100644 --- a/services/runner/tests/unit/session-pool.test.ts +++ b/services/runner/tests/unit/session-pool.test.ts @@ -5,6 +5,7 @@ */ import { afterEach, beforeEach, describe, it, vi } from "vitest"; import assert from "node:assert/strict"; +import { inspect } from "node:util"; import type { AgentRunRequest } from "../../src/protocol.ts"; import { @@ -23,6 +24,7 @@ import { resolvesToLocalProvider, tailIsFreshUserMessage, type CredentialEpoch, + CredentialMaterial, } from "../../src/engines/sandbox_agent/session-identity.ts"; import { SessionPool } from "../../src/engines/sandbox_agent/session-pool.ts"; @@ -62,7 +64,7 @@ function fakeEnv() { }; } -const epoch: CredentialEpoch = { secretsHash: "h" }; +const epoch: CredentialEpoch = { secrets: new CredentialMaterial("h") }; function parkInput(key: string, env = fakeEnv()) { return { @@ -608,6 +610,43 @@ describe("tailIsFreshUserMessage", () => { }); describe("credential epoch", () => { + it("never surfaces the credential values, however it is turned into text", () => { + // The epoch holds the real values so it can compare them, so the one thing that must be + // impossible is a value reaching a log line. Every route from an object to a string is + // pinned here, because a future refactor that drops one of these overrides would leak + // silently: the code would still work and the key would appear in stderr. + const epoch = computeCredentialEpoch({ + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "sk-should-never-be-printed", + usage: "opaque_http", + }, + ], + }, + }); + + const renderings = [ + String(epoch.secrets), + `${epoch.secrets}`, + JSON.stringify(epoch), + inspect(epoch, { depth: null }), + inspect(epoch.secrets), + ]; + for (const rendering of renderings) { + assert.equal( + rendering.includes("sk-should-never-be-printed"), + false, + `leaked the credential value through: ${rendering}`, + ); + } + }); + // A typed model connection whose one credential carries `value` under env var `name`. const modelConnection = ( value: string, @@ -639,11 +678,11 @@ describe("credential epoch", () => { modelConnection: modelConnection("2"), toolCallback: { endpoint: "e", authorization: "z" }, }); - assert.equal(a.secretsHash, b.secretsHash); - assert.notEqual( - a.secretsHash, - c.secretsHash, - "a rotated same-slug secret changes the hash", + assert.equal(a.secrets.equals(b.secrets), true); + assert.equal( + a.secrets.equals(c.secrets), + false, + "a rotated same-slug secret changes the material", ); }); @@ -657,12 +696,12 @@ describe("credential epoch", () => { const rotated = computeCredentialEpoch({ modelConnection: modelConnection("sk-new", "OPENAI_API_KEY"), }); - assert.notEqual(parked.secretsHash, rotated.secretsHash); + assert.equal(parked.secrets.equals(rotated.secrets), false); assert.equal(sandboxCredentialsRotated(parked, rotated), true); assert.equal(credentialEpochValid(parked, rotated, Date.now()), false); }); - it("a re-minted tool-callback bearer does NOT change the hash (per-turn material)", () => { + it("a re-minted tool-callback bearer does NOT change the material (per-turn)", () => { // The backend re-mints the callback bearer on its auth-cache cadence (~60s); the turn's // relay always uses the incoming bearer, so a warm continue must not evict over it. const parked = computeCredentialEpoch({ @@ -673,7 +712,7 @@ describe("credential epoch", () => { modelConnection: modelConnection("1"), toolCallback: { endpoint: "e", authorization: "bearer-new" }, }); - assert.equal(parked.secretsHash, incoming.secretsHash); + assert.equal(parked.secrets.equals(incoming.secrets), true); assert.equal(sandboxCredentialsRotated(parked, incoming), false); assert.equal(credentialEpochMismatch(parked, incoming), undefined); }); @@ -698,9 +737,11 @@ describe("credential epoch", () => { }, ], }); - assert.notEqual( - computeCredentialEpoch(withMcp("secret-a")).secretsHash, - computeCredentialEpoch(withMcp("secret-b")).secretsHash, + assert.equal( + computeCredentialEpoch(withMcp("secret-a")).secrets.equals( + computeCredentialEpoch(withMcp("secret-b")).secrets, + ), + false, ); }); From 5189fa8bec3018fa06f14b0c5fb02abf82bbc6d5 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 3 Aug 2026 13:15:39 +0200 Subject: [PATCH 07/11] docs(runner): refresh the auth section for the typed credential wire The README still described `request.secrets`, which this change retired, and said nothing about hiding keys from the sandbox. It now describes the consumer-grouped shape, the reject-on-retired-field guard, and the Daytona Secrets path with its three caveats. --- services/runner/README.md | 43 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/services/runner/README.md b/services/runner/README.md index 4c116a3053..416472bcf0 100644 --- a/services/runner/README.md +++ b/services/runner/README.md @@ -105,9 +105,46 @@ pnpm run build:extension ## Auth -Provider keys arrive as `request.secrets` (resolved from the project vault) or fall back to -the harness's own login: Pi reads `~/.pi/agent/auth.json` (`pnpm exec pi` then `/login`), -Claude Code reads `~/.claude`. Set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` to override. +Credentials arrive grouped by the consumer that owns them, not as one flat map. The model's +key and route ride `request.modelConnection`; each MCP server's key rides its own +`mcpServers[].connection.credentials`. That grouping is what lets the runner know which host a +given key is allowed to reach, which the section below depends on. A request that still sends +the retired flat fields (`secrets`, `provider`, `deployment`, `credentialMode`, `endpoint`) is +rejected outright rather than run without the credential it meant to supply. + +A run can also carry no key and fall back to the harness's own login: Pi reads +`~/.pi/agent/auth.json` (`pnpm exec pi` then `/login`), Claude Code reads `~/.claude`, Codex +reads `CODEX_HOME`. Set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` to override locally. + +### Hiding keys from the sandbox + +By default a key reaches the sandbox as an ordinary environment variable or HTTP header, so the +agent running there can read it. That matters because an agent writes and runs its own code: a +prompt injection that convinces it to print its environment prints the key. + +Set `AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local` and, on Daytona, the runner instead +stores each key as a Daytona Secret restricted to the one hostname that key authenticates +against, and puts a placeholder in the sandbox. Daytona substitutes the real value into outbound +requests to that host, so the model call and the MCP call still work while the agent only ever +holds a placeholder. A request to any other host carries the placeholder, which is what makes +exfiltration fail. + +Three things to know: + +- The runner's Daytona API key needs permission to manage Secrets. Without it, runs fail at + sandbox creation rather than quietly falling back to plaintext keys. +- `process_local` names the guarantee: the runner tracks the Secret records it created in its own + memory and deletes them when the sandbox goes away. Restart the runner while sandboxes are live + and those records are orphaned until someone removes them. +- Keys a provider SDK signs with locally instead of sending, which today means the AWS keys behind + Bedrock, cannot be hidden this way. There is no outbound request to substitute them into, so the + sandbox holds the real value. They are marked `usage: "local_use"` and the set of names allowed + to claim that is a short explicit allowlist in `daytona-secret-plan.ts`. + +The local sandbox is unaffected: the harness runs inside the runner container, so its keys never +leave the deployment. See +[the configuration reference](../../docs/docs/self-host/reference/01-configuration.mdx) for the +operator-facing version. ## config/ From b3c554d1e72e4aa68f379f56ecb7ae4789951191 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 3 Aug 2026 20:02:39 +0200 Subject: [PATCH 08/11] feat(runner): explain an under-permissioned Daytona key instead of a bare 403 A Daytona API key that can create sandboxes does not automatically have the separate permission to manage Secrets. When it does not, enabling AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS makes every run with a hideable credential fail at sandbox creation, and the raw provider error is a bare Forbidden that says nothing about the flag that caused it. This is the first thing an operator hits when turning the feature on, so the create path now recognizes a permission refusal and re-raises it as a message naming the variable to fix, the permission to grant, and the way to revert. The provider's own error is kept as the cause so the logs keep the detail. Every other failure keeps its original error untouched. The docs promote the same point from a bullet to a warning, and say plainly that the runner does not fall back to plaintext, because doing the unprotected thing silently would be worse than stopping. Runner tsc clean, 99 files / 1538 tests. --- .../self-host/reference/01-configuration.mdx | 24 ++++++--- services/runner/README.md | 6 ++- .../engines/sandbox_agent/daytona-secrets.ts | 50 +++++++++++++++--- .../runner/tests/unit/daytona-secrets.test.ts | 52 +++++++++++++++++++ 4 files changed, 116 insertions(+), 16 deletions(-) diff --git a/docs/docs/self-host/reference/01-configuration.mdx b/docs/docs/self-host/reference/01-configuration.mdx index ab0116c482..da64878b01 100644 --- a/docs/docs/self-host/reference/01-configuration.mdx +++ b/docs/docs/self-host/reference/01-configuration.mdx @@ -316,14 +316,22 @@ tracks the Secret records it created in its own memory and deletes them when the Restart the runner while sandboxes are live and those records are orphaned in your Daytona account until you remove them; a future value will add durable tracking. -Two things to know before you enable it: - -- Your Daytona API key needs permission to manage Secrets. Without it, every run fails at - sandbox creation rather than falling back to plaintext keys. -- Keys that a provider SDK signs with locally instead of sending, which today means the AWS access - keys used for Bedrock, cannot be hidden this way and still reach the sandbox in full. Nothing - Daytona substitutes on the way out can help there, because the secret never leaves the sandbox - in the first place. +:::warning Check the API key's permissions first +A Daytona API key is minted with a set of permissions, and a key that can create sandboxes does +not automatically have the separate permission to manage Secrets. Grant that permission to the key +in `AGENTA_RUNNER_DAYTONA_API_KEY` before you set this variable. + +If you skip it, **every Daytona run that carries a model or MCP key fails at sandbox creation.** +The runner does not fall back to sending the key as plaintext, because silently doing the +unprotected thing is worse than stopping. The error names this variable and the permission, so the +failure is recognizable, but the runs still fail until the key is fixed. Unsetting the variable +restores the previous behavior immediately. +::: + +One more thing to know: keys that a provider SDK signs with locally instead of sending, which +today means the AWS access keys used for Bedrock, cannot be hidden this way and still reach the +sandbox in full. Nothing Daytona substitutes on the way out can help there, because the secret +never leaves the sandbox in the first place. Leaving the variable unset keeps the previous behavior exactly. diff --git a/services/runner/README.md b/services/runner/README.md index 416472bcf0..b7672687c6 100644 --- a/services/runner/README.md +++ b/services/runner/README.md @@ -131,8 +131,10 @@ exfiltration fail. Three things to know: -- The runner's Daytona API key needs permission to manage Secrets. Without it, runs fail at - sandbox creation rather than quietly falling back to plaintext keys. +- The runner's Daytona API key needs permission to manage Secrets. A key that can create + sandboxes does not automatically have it. Without it, every run carrying a model or MCP key + fails at sandbox creation; the runner never quietly falls back to plaintext. The error names + the variable and the permission (`DAYTONA_SECRETS_PERMISSION_MESSAGE` in `daytona-secrets.ts`). - `process_local` names the guarantee: the runner tracks the Secret records it created in its own memory and deletes them when the sandbox goes away. Restart the runner while sandboxes are live and those records are orphaned until someone removes them. diff --git a/services/runner/src/engines/sandbox_agent/daytona-secrets.ts b/services/runner/src/engines/sandbox_agent/daytona-secrets.ts index 191c0a63af..c8d9460c7b 100644 --- a/services/runner/src/engines/sandbox_agent/daytona-secrets.ts +++ b/services/runner/src/engines/sandbox_agent/daytona-secrets.ts @@ -45,6 +45,34 @@ export function isDaytonaNotFound(error: unknown): boolean { ); } +/** + * True when a Daytona failure means "this API key is not allowed to do that". + * + * Worth recognizing on its own because it has exactly one cause in practice and a completely + * different fix from every other failure here. A Daytona API key is minted with a set of + * permissions, and a key that can create sandboxes does not necessarily have the separate + * permission to manage Secrets. When it does not, every run with a hideable credential fails at + * sandbox creation, and the raw provider message says nothing about the flag that caused it. + */ +export function isDaytonaPermissionDenied(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const status = "statusCode" in error ? error.statusCode : undefined; + if (status === 401 || status === 403) return true; + const message = "message" in error ? String(error.message) : ""; + return /\b(403|401)\b|forbidden|not authorized|unauthorized|permission/i.test( + message, + ); +} + +/** The message an operator can act on, instead of a bare provider status code. */ +export const DAYTONA_SECRETS_PERMISSION_MESSAGE = + "Daytona refused to manage Secrets with this API key. " + + "AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local stores each model and MCP key as a " + + "Daytona Secret, which needs an API key that is allowed to manage Secrets, not only to " + + "create sandboxes. Grant that permission to the key in AGENTA_RUNNER_DAYTONA_API_KEY, or " + + "unset AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS to pass credentials as plain environment " + + "variables again."; + async function deleteIdempotently( api: DaytonaSecretApi, id: string, @@ -100,12 +128,22 @@ export async function allocateDaytonaSecrets( try { for (const candidate of plan.candidates) { const name = nameFor(candidate); - const rawSecret = await api.create({ - name, - value: candidate.value, - description: "Agenta process-local sandbox credential", - hosts: [candidate.allowedHost], - }); + let rawSecret: DaytonaSecretRecord; + try { + rawSecret = await api.create({ + name, + value: candidate.value, + description: "Agenta process-local sandbox credential", + hosts: [candidate.allowedHost], + }); + } catch (error) { + // Re-raise a permission refusal as an actionable message. Everything else keeps its + // original error, and either way the catch below compensates for what was created. + if (isDaytonaPermissionDenied(error)) { + throw new Error(DAYTONA_SECRETS_PERMISSION_MESSAGE, { cause: error }); + } + throw error; + } // Track the provider record before validating returned metadata. If the provider returns a // malformed placeholder or host list, compensation must still delete the record it made. if (rawSecret.id) created.push(rawSecret); diff --git a/services/runner/tests/unit/daytona-secrets.test.ts b/services/runner/tests/unit/daytona-secrets.test.ts index 8588e91310..5062f4308b 100644 --- a/services/runner/tests/unit/daytona-secrets.test.ts +++ b/services/runner/tests/unit/daytona-secrets.test.ts @@ -5,8 +5,10 @@ import { describe, it } from "vitest"; import type { DaytonaSecretPlan } from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; import { allocateDaytonaSecrets, + DAYTONA_SECRETS_PERMISSION_MESSAGE, deleteDaytonaSecrets, isDaytonaNotFound, + isDaytonaPermissionDenied, type DaytonaSecretApi, } from "../../src/engines/sandbox_agent/daytona-secrets.ts"; @@ -140,4 +142,54 @@ describe("Daytona Secret allocation", () => { assert.equal(isDaytonaNotFound({ statusCode: 500 }), false); assert.equal(isDaytonaNotFound(new Error("gone")), false); }); + + it("recognizes a permission refusal by status code or by message", () => { + assert.equal(isDaytonaPermissionDenied({ statusCode: 403 }), true); + assert.equal(isDaytonaPermissionDenied({ statusCode: 401 }), true); + assert.equal( + isDaytonaPermissionDenied(new Error("Forbidden: missing permission")), + true, + ); + // Not a permission problem: a missing record, a server fault, a plain failure. + assert.equal(isDaytonaPermissionDenied({ statusCode: 404 }), false); + assert.equal(isDaytonaPermissionDenied({ statusCode: 500 }), false); + assert.equal(isDaytonaPermissionDenied(new Error("network reset")), false); + assert.equal(isDaytonaPermissionDenied(undefined), false); + }); + + it("explains an under-permissioned API key instead of surfacing a bare 403", async () => { + // The whole point: a key that can create sandboxes but not manage Secrets fails EVERY run + // with a hideable credential, and the raw provider message never mentions the flag that + // caused it. This is the one failure an operator hits on first enabling the feature. + const api: DaytonaSecretApi = { + async create() { + throw { statusCode: 403, message: "Forbidden" }; + }, + async delete() {}, + }; + + await assert.rejects( + allocateDaytonaSecrets(plan, api), + (error: Error) => { + assert.equal(error.message, DAYTONA_SECRETS_PERMISSION_MESSAGE); + assert.match(error.message, /AGENTA_RUNNER_DAYTONA_API_KEY/); + assert.match(error.message, /AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS/); + // The provider's own error is kept as the cause so the logs still have the detail. + assert.equal((error.cause as { statusCode: number }).statusCode, 403); + return true; + }, + ); + }); + + it("leaves a non-permission create failure with its original error", async () => { + const api: DaytonaSecretApi = { + async create() { + throw new Error("daytona is having a bad day"); + }, + async delete() {}, + }; + await assert.rejects(allocateDaytonaSecrets(plan, api), { + message: "daytona is having a bad day", + }); + }); }); From 9023f8cd7868626b143e02343d0e7eb11877617d Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 3 Aug 2026 20:34:58 +0200 Subject: [PATCH 09/11] fix(sdk): restore `connection` to the run-request schema, and guard the gap Review catch. `wire_connection_ref()` emits a top-level `connection` field for a self-managed or named Agenta connection, and the runner reads its slug to register a custom OpenAI-compatible Pi run in Pi's models.json. But `WireRunRequest` no longer declared the field, so the exported contract schema omitted something the implementation both produces and requires. A client generated from that schema would drop it, and those runs would silently fall back to the generic provider-override path. The field went missing because it sat next to the flat credential fields this change retired and got swept out with them. It is not a credential. It is the author's connection CHOICE, which is non-secret routing config. Restored `WireConnection` and the `connection` field on `WireRunRequest`. Two reasons nothing caught this, both now closed: - `KNOWN_REQUEST_KEYS` was stale in exactly the same way, and its guard is a subset check over three sample payloads, none of which names a connection. Added the key, and added a test asserting the set EQUALS the schema's declared aliases in both directions. A key the schema declares but the producer never emits is dead contract surface, so equality is the honest assertion. - A validation-based test could not have caught it either: `_WireModel` sets `extra="allow"`, so a payload carrying a field the schema forgot still validates cleanly with the field quietly demoted to an extra. The new test is structural rather than validation-based for that reason, and the second new test round-trips a named connection through `model_dump` to prove it survives as a modelled field rather than an extra. I verified both guards fail when the schema field is removed, so neither is a test that can never break. Also corrected the runner's own doc comment on the field. It claimed "the current SDK resolver does NOT send it", which described the regression rather than the contract, and would have led the next reader to delete the models.json path as dead code. Verified: SDK 2069 passed with the 10 pre-existing litellm xfails, services 100 passed, runner tsc clean and 99 files / 1538 tests, ruff clean. --- sdks/python/agenta/sdk/agents/wire_models.py | 22 ++++++- .../pytest/unit/agents/test_wire_contract.py | 66 ++++++++++++++++++- services/runner/src/protocol.ts | 22 +++++-- 3 files changed, 100 insertions(+), 10 deletions(-) diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index 4e4f7cc38e..1c74d9b4d2 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -76,6 +76,23 @@ class WireEndpoint(_WireModel): headers: Optional[Dict[str, str]] = None +class WireConnection(_WireModel): + """The author's connection CHOICE: which connection they picked, not what it resolved to. + + Deliberately separate from :class:`WireModelConnection`. This is non-secret routing config + that the runner reads directly: a named Agenta connection's ``slug`` is the provider name a + custom OpenAI-compatible Pi run is registered under in Pi's own ``models.json`` + (``services/runner/src/engines/sandbox_agent/pi-model-config.ts``). The resolved route and + credentials ride ``modelConnection`` instead. + + Emitted only when the choice carries information: ``self_managed``, or an Agenta connection + naming a slug. The project default (``agenta`` with no slug) is omitted. + """ + + mode: Literal["agenta", "self_managed"] = "agenta" + slug: Optional[str] = None + + class WireCredentialBinding(_WireModel): """Protocol location where the model client consumes one credential.""" @@ -463,8 +480,11 @@ class WireRunRequest(_WireModel): turn_id: Optional[str] = Field(default=None, alias="turnId") project_id: Optional[str] = Field(default=None, alias="projectId") agents_md: Optional[str] = Field(default=None, alias="agentsMd") - # Model id stays scalar; resolved routing and credentials are one consumer-owned object. + # Model id stays scalar. The author's connection CHOICE and what that choice RESOLVED to are + # two separate fields: `connection` is non-secret routing config the runner reads directly, + # `modelConnection` is the resolved route and its credentials. model: Optional[str] = None + connection: Optional[WireConnection] = None model_connection: Optional[WireModelConnection] = Field( default=None, alias="modelConnection" ) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index 862f1b63c3..a95d50fadb 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py @@ -51,18 +51,27 @@ result_from_wire, sanitize_runner_error, ) +from agenta.sdk.agents.wire_models import WireRunRequest from agenta.sdk.agents.pi_builtins import PI_BUILTIN_TOOL_NAMES from agenta.sdk.utils.types import build_agent_v0_default -# The full set of top-level keys ``request_to_wire`` may emit. The TS ``AgentRunRequest`` -# interface must declare a superset of these. Adding a key here without adding it to -# protocol.ts is exactly the drift this set exists to catch. +# The full set of top-level keys ``request_to_wire`` may emit. THREE things must agree on it: +# this set, the ``WireRunRequest`` schema, and the TS ``AgentRunRequest`` interface. Adding a key +# to the producer without adding it here, or here without adding it to protocol.ts, is exactly +# the drift this set exists to catch. +# +# The schema half is checked structurally by ``test_known_request_keys_match_the_wire_schema`` +# below, because a payload-validation test cannot catch it: ``_WireModel`` sets +# ``extra="allow"``, so a payload carrying a field the schema forgot still validates cleanly and +# the field silently becomes an extra. A generated client built from that schema would then drop +# it. That is how ``connection`` went missing once already. KNOWN_REQUEST_KEYS = { "harness", "sandbox", "sessionId", "agentsMd", "model", + "connection", "harnessMode", "modelCapabilities", "modelConnection", @@ -765,6 +774,57 @@ def test_request_to_wire_emits_only_known_keys(): assert {"systemPrompt", "appendSystemPrompt"} <= set(pi) +def test_known_request_keys_match_the_wire_schema(): + """``WireRunRequest`` must declare exactly the keys the producer may emit. + + The subset guard above cannot catch a field the SCHEMA forgot, for two reasons. It only sees + the keys the three sample payloads happen to carry, and ``_WireModel`` sets ``extra="allow"``, + so even a payload that does carry the field validates cleanly with the field demoted to an + extra. The schema is what generated clients are built from, so a field missing here is a + field those clients drop. + + Equality, not a subset, in both directions: a key the schema declares and the producer never + emits is dead contract surface that readers will assume is real. + """ + declared = { + field.alias or name for name, field in WireRunRequest.model_fields.items() + } + assert declared == KNOWN_REQUEST_KEYS + + +def test_named_connection_choice_is_a_declared_schema_field(): + """A named Agenta connection reaches the runner as a first-class field, not as an extra. + + The runner registers a custom OpenAI-compatible Pi run in Pi's ``models.json`` under a + provider named after this slug (``pi-model-config.ts``), so a client that dropped the field + would silently misroute those runs to the generic provider path. + """ + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate( + model={ + "provider": "openai", + "model": "gpt-5.5", + "connection": {"mode": "agenta", "slug": "openrouter-prod"}, + } + ), + messages=[Message(role="user", content="hi")], + ) + assert payload["connection"] == {"mode": "agenta", "slug": "openrouter-prod"} + assert set(payload) <= KNOWN_REQUEST_KEYS + + parsed = WireRunRequest.model_validate(payload) + assert parsed.connection is not None + assert parsed.connection.slug == "openrouter-prod" + # The point of the assertion: `connection` is a MODELLED field, so it survives a schema + # round-trip. An extra would be dropped by `model_dump` without `serialize_as_any`. + assert parsed.model_dump(by_alias=True, exclude_none=True)["connection"] == { + "mode": "agenta", + "slug": "openrouter-prod", + } + + def test_request_to_wire_carries_consumer_owned_model_connection(): config = PiAgentTemplate( model="openai/gpt-5.5", diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index b22471a546..ea7ceb60e7 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -596,12 +596,22 @@ export interface AgentRunRequest { */ harnessMode?: string; /** - * Where the credential comes from, named portably (a slug, never a db id). Non-secret. - * The RESOLVED routing and credential values live in `modelConnection`; this field carries - * only the author's intent (`mode`) and the connection identity (`slug`, which names the Pi - * custom provider in `pi-model-config.ts`). The current SDK resolver does NOT send it — - * custom-endpoint Pi routing rides the extension provider override instead — so the - * models.json path only activates for a direct caller that still supplies it. + * Which connection the author CHOSE, named portably (a slug, never a database id). Non-secret. + * + * Distinct from `modelConnection`, which is what that choice resolved to: the route and the + * credential values. This field carries only the intent (`mode`) and the identity (`slug`). + * + * It is load-bearing, not informational. A named Agenta connection on a custom + * OpenAI-compatible Pi run is registered in Pi's own models.json under a provider named after + * this slug (`pi-model-config.ts` gates on `mode === "agenta"` and reads `slug`). Without it + * those runs fall back to the generic provider-override path and route differently, which is + * a silent behavior change rather than a failure. The SDK emits it from + * `HarnessAgentTemplate.wire_connection_ref()` and the schema declares it on + * `WireRunRequest`; both are pinned by tests, so do not read this field as optional in + * practice for a named connection. + * + * Omitted for the project default (`agenta` with no slug), which carries no information + * beyond the model itself. */ connection?: { mode: string; slug?: string }; /** Resolved model routing and credential bindings, grouped under their consumer. */ From 9015d20f3a2eabff2d1247f4391d67635b402b87 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 3 Aug 2026 20:14:18 +0200 Subject: [PATCH 10/11] refactor(runner): group RunPlan by concern instead of one flat bag `RunPlan` had grown to thirty fields on one flat interface, and every consumer took the whole thing regardless of how little it read. Raised in review of #5670. The fields are grouped into `credentials`, `workspace`, `tools`, and `prompt`. The five identity fields (`harness`, `acpAgent`, `sandboxId`, `isPi`, `isDaytona`) stay at the top level because almost every consumer branches on them, and `sandboxPermission` stays there too since the declared security boundary is its own concern. The payoff is not the tidier interface. It is that a consumer can now say what it actually touches. `prepareWorkspace` used to take a nine-key `Pick` of the flat plan; it now takes three named slices and the parameter type reads as a description of the function. Several other consumers narrowed the same way, which means a future change to, say, credential delivery has a compiler-checked list of what depends on it. The one rename is `plan.prompt` to `plan.prompt.text`, forced by the group taking the name. Nothing else was renamed, no logic changed, and no `any` cast or `@ts-expect-error` was added. Runner tsc clean, 99 files / 1535 tests, the same counts as before the change. --- .../src/engines/sandbox_agent/attachments.ts | 14 +- .../src/engines/sandbox_agent/codex-assets.ts | 43 ++-- .../src/engines/sandbox_agent/daytona.ts | 36 +-- .../sandbox_agent/environment-setup.ts | 42 ++-- .../src/engines/sandbox_agent/environment.ts | 75 ++++--- .../src/engines/sandbox_agent/pi-assets.ts | 60 ++--- .../src/engines/sandbox_agent/run-plan.ts | 151 ++++++++----- .../src/engines/sandbox_agent/run-turn.ts | 27 +-- .../src/engines/sandbox_agent/workspace.ts | 82 ++++--- .../unit/attachment-delivery-events.test.ts | 6 +- .../tests/unit/attachment-materialize.test.ts | 20 +- .../tests/unit/attachment-path-safety.test.ts | 2 +- .../tests/unit/daytona-secret-plan.test.ts | 12 +- .../tests/unit/pi-builtin-activation.test.ts | 10 +- .../unit/sandbox-agent-codex-assets.test.ts | 92 +++++--- .../unit/sandbox-agent-orchestration.test.ts | 4 +- .../unit/sandbox-agent-pi-assets.test.ts | 104 ++++++--- ...sandbox-agent-qa-transcript-replay.test.ts | 12 +- .../tests/unit/sandbox-agent-run-plan.test.ts | 146 ++++++------- .../unit/sandbox-agent-workspace.test.ts | 206 ++++++++++++------ 20 files changed, 688 insertions(+), 456 deletions(-) diff --git a/services/runner/src/engines/sandbox_agent/attachments.ts b/services/runner/src/engines/sandbox_agent/attachments.ts index ac9f75de4b..59398b0cd8 100644 --- a/services/runner/src/engines/sandbox_agent/attachments.ts +++ b/services/runner/src/engines/sandbox_agent/attachments.ts @@ -23,7 +23,7 @@ import { type FetchedAttachment, } from "../../sessions/attachments.ts"; import { attachmentDeliveryUnsupportedMessage } from "./capabilities.ts"; -import type { RunPlan } from "./run-plan.ts"; +import type { RunPlan, RunPlanWorkspace } from "./run-plan.ts"; import { COLD_FRAME_USER_LABEL } from "./transcript.ts"; export type AttachmentDeliveryOutcome = @@ -83,8 +83,10 @@ export interface AttachmentSandbox { }) => Promise<{ exitCode?: number } | undefined>; } -type MaterializePlan = Pick; -type DeliveryPlan = Pick; +type MaterializePlan = Pick & { + workspace: Pick; +}; +type DeliveryPlan = MaterializePlan & Pick; type Auth = () => string; type Log = (message: string) => void; @@ -398,7 +400,7 @@ export async function materializeWorkingCopy( ref: AttachmentRef, bytes: Uint8Array, ): Promise<"written" | "exists"> { - const path = attachmentWorkingPath(plan.cwd, ref); + const path = attachmentWorkingPath(plan.workspace.cwd, ref); return plan.isDaytona ? daytonaMaterialize(sandbox, path, bytes) : localMaterialize(path, bytes); @@ -637,7 +639,7 @@ async function workingCopyExists( plan: MaterializePlan, ref: AttachmentRef, ): Promise { - const path = attachmentWorkingPath(plan.cwd, ref); + const path = attachmentWorkingPath(plan.workspace.cwd, ref); if (plan.isDaytona) { if (typeof sandbox.statFs !== "function") return false; await rejectDaytonaSymlinks(sandbox, [ @@ -839,7 +841,7 @@ export async function resolveCurrentTurnAttachments(input: { const authoritative = verifiedRef(ref, fetched); let path: AttachmentPath; try { - path = attachmentWorkingPath(input.plan.cwd, authoritative); + path = attachmentWorkingPath(input.plan.workspace.cwd, authoritative); await materializeWorkingCopy( input.sandbox, input.plan, diff --git a/services/runner/src/engines/sandbox_agent/codex-assets.ts b/services/runner/src/engines/sandbox_agent/codex-assets.ts index 73c6cc41b8..7990df80d0 100644 --- a/services/runner/src/engines/sandbox_agent/codex-assets.ts +++ b/services/runner/src/engines/sandbox_agent/codex-assets.ts @@ -24,7 +24,11 @@ import { existsSync, mkdirSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; -import type { RunPlan } from "./run-plan.ts"; +import type { + RunPlan, + RunPlanCredentials, + RunPlanWorkspace, +} from "./run-plan.ts"; type Log = (message: string) => void; @@ -44,6 +48,17 @@ export function codexHomeDir(cwd: string): string { * constant across a session's turns and is NOT a config-fingerprint input, preserving warm daemon * reuse. */ +/** The slice that decides which Codex auth mode a run is in. */ +type CodexModePlan = Pick & { + credentials: Pick; +}; + +/** The mode slice plus where the run's Codex home is rooted. */ +type CodexHomePlan = CodexModePlan & + Pick & { + workspace: Pick; + }; + export function codexSqliteHomeDir(cwd: string): string { return join(tmpdir(), "agenta", "codex-sqlite", basename(cwd)); } @@ -54,18 +69,20 @@ export function codexSqliteHomeDir(cwd: string): string { * own mounted OAuth login instead, so it is excluded here. */ export function isManagedCodexRun( - plan: Pick, + plan: CodexModePlan, ): boolean { return ( - plan.acpAgent === "codex" && plan.credentialMode !== "runtime_provided" + plan.acpAgent === "codex" && + plan.credentials.credentialMode !== "runtime_provided" ); } export function isSubscriptionCodexRun( - plan: Pick, + plan: CodexModePlan, ): boolean { return ( - plan.acpAgent === "codex" && plan.credentialMode === "runtime_provided" + plan.acpAgent === "codex" && + plan.credentials.credentialMode === "runtime_provided" ); } @@ -86,7 +103,7 @@ function codexSubscriptionMountDir(): string | undefined { * for best-effort teardown cleanup. Subscription additionally pins the credential store to `file`. */ export function configureCodexHome( - plan: Pick, + plan: CodexHomePlan, env: Record, ): string | undefined { // Local codex only (managed or subscription). Daytona and non-codex runs are no-ops. @@ -94,10 +111,10 @@ export function configureCodexHome( // Runner-owned per-session home in both modes. For subscription this overrides the operator's // mount path that buildDaemonEnv inherited into env.CODEX_HOME, so only the auth.json we symlink // in (see symlinkCodexSubscriptionAuthFile) is visible — not the operator's config/plugins/apps. - env.CODEX_HOME = codexHomeDir(plan.cwd); + env.CODEX_HOME = codexHomeDir(plan.workspace.cwd); // Both modes redirect SQLite off the home so neither the geesefs cwd nor the operator mount // accumulates per-run WAL SQLite. - const sqliteHome = codexSqliteHomeDir(plan.cwd); + const sqliteHome = codexSqliteHomeDir(plan.workspace.cwd); mkdirSync(sqliteHome, { recursive: true }); env.CODEX_SQLITE_HOME = sqliteHome; // Subscription: pin the credential store to `file` so a keyring/auto mode (from any config layer) @@ -133,12 +150,12 @@ export function codexDaytonaSqliteHomeDir(cwd: string): string { * (run-plan.ts); local runs and non-codex runs are no-ops. */ export function configureDaytonaCodexEnv( - plan: Pick, + plan: CodexHomePlan, daytonaEnv: Record, ): void { if (!plan.isDaytona || !isManagedCodexRun(plan)) return; - daytonaEnv.CODEX_HOME = codexHomeDir(plan.cwd); - daytonaEnv.CODEX_SQLITE_HOME = codexDaytonaSqliteHomeDir(plan.cwd); + daytonaEnv.CODEX_HOME = codexHomeDir(plan.workspace.cwd); + daytonaEnv.CODEX_SQLITE_HOME = codexDaytonaSqliteHomeDir(plan.workspace.cwd); } /** @@ -151,7 +168,7 @@ export function configureDaytonaCodexEnv( * file-free and never reach here. */ export function symlinkCodexSubscriptionAuthFile( - plan: Pick, + plan: CodexHomePlan, log: Log = () => {}, ): void { if (!isSubscriptionCodexRun(plan) || plan.isDaytona) return; @@ -162,7 +179,7 @@ export function symlinkCodexSubscriptionAuthFile( return; } - const home = codexHomeDir(plan.cwd); + const home = codexHomeDir(plan.workspace.cwd); mkdirSync(home, { recursive: true, mode: 0o700 }); const linkPath = join(home, "auth.json"); if (existsSync(linkPath)) return; diff --git a/services/runner/src/engines/sandbox_agent/daytona.ts b/services/runner/src/engines/sandbox_agent/daytona.ts index d4fb4c6e9e..d4610b0433 100644 --- a/services/runner/src/engines/sandbox_agent/daytona.ts +++ b/services/runner/src/engines/sandbox_agent/daytona.ts @@ -11,7 +11,11 @@ import { serializePiModelsJson, type PiModelConfigPlan, } from "./pi-model-config.ts"; -import { type RunPlan } from "./run-plan.ts"; +import { + type RunPlan, + type RunPlanPrompt, + type RunPlanWorkspace, +} from "./run-plan.ts"; type Log = (message: string) => void; @@ -190,14 +194,13 @@ export async function removePiModelsConfigFromSandbox( export interface PrepareDaytonaPiAssetsInput { sandbox: any; - plan: Pick< - RunPlan, - | "isPi" - | "skillDirs" - | "hasSystemPrompt" - | "systemPrompt" - | "appendSystemPrompt" - >; + plan: Pick & { + workspace: Pick; + prompt: Pick< + RunPlanPrompt, + "hasSystemPrompt" | "systemPrompt" | "appendSystemPrompt" + >; + }; /** * A managed OpenAI-compatible custom run's Pi provider config. When set, its `models.json` is * uploaded before the ACP session starts; when absent, any stale `models.json` on a reused @@ -243,15 +246,20 @@ export async function prepareDaytonaPiAssets({ } else { await removePiModelsConfigFromSandbox(sandbox, DAYTONA_PI_DIR, log); } - if (plan.skillDirs.length > 0) { - await uploadSkillsToSandbox(sandbox, DAYTONA_PI_DIR, plan.skillDirs, log); + if (plan.workspace.skillDirs.length > 0) { + await uploadSkillsToSandbox( + sandbox, + DAYTONA_PI_DIR, + plan.workspace.skillDirs, + log, + ); } - if (plan.hasSystemPrompt) { + if (plan.prompt.hasSystemPrompt) { await uploadSystemPromptToSandbox( sandbox, DAYTONA_PI_DIR, - plan.systemPrompt, - plan.appendSystemPrompt, + plan.prompt.systemPrompt, + plan.prompt.appendSystemPrompt, log, ); } diff --git a/services/runner/src/engines/sandbox_agent/environment-setup.ts b/services/runner/src/engines/sandbox_agent/environment-setup.ts index 5944af4577..cc8d754041 100644 --- a/services/runner/src/engines/sandbox_agent/environment-setup.ts +++ b/services/runner/src/engines/sandbox_agent/environment-setup.ts @@ -161,22 +161,26 @@ export async function prepareEnvironmentSetup( if (!planResult.ok) return { ok: false as const, error: planResult.error }; const plan = planResult.plan; const piSkillSnapshot = resolvePiSkillSnapshot(plan); - const agentMountDir = agentMountCreds ? agentMountPath(plan.cwd) : undefined; + const agentMountDir = agentMountCreds + ? agentMountPath(plan.workspace.cwd) + : undefined; // Clear-then-apply (Security rule 5): on a managed run (credentialMode "env") the daemon // inherits NONE of the sidecar's own provider keys, so only the resolved - // `plan.modelEnvironment` is present and an inherited key for another provider cannot leak. + // `plan.credentials.modelEnvironment` is present and an inherited key for another provider cannot leak. // "none" asserts NO credential (connections/models.py), so it clears too — otherwise the // daemon would inherit the declared provider's keys (e.g. OPENAI_API_KEY) from the sidecar. // Only runtime_provided keeps the inherited keys: the harness uses its own login there. const clearProviderEnv = - plan.credentialMode === "env" || plan.credentialMode === "none"; + plan.credentials.credentialMode === "env" || + plan.credentials.credentialMode === "none"; const env = (deps.buildDaemonEnv ?? buildDaemonEnv)(plan.acpAgent, { clearProviderEnv, provider: request.modelConnection?.provider, deployment: request.modelConnection?.deployment, }); - Object.assign(env, plan.modelEnvironment); // apply only the resolved provider keys + // apply only the resolved provider keys + Object.assign(env, plan.credentials.modelEnvironment); applyClaudeConnectionEnv(env, request, plan.acpAgent, logger); const piSessionDir = configurePiSessionWorkspace(plan, env); configurePiSkillSnapshot(piSkillSnapshot, env); @@ -187,7 +191,7 @@ export async function prepareEnvironmentSetup( // local Pi's OTLP bearer rides a runner-written 0600 file, never a plain env var — // Daytona never receives telemetry env here at all (`!plan.isDaytona` gates it off above). const otlpAuthFilePath = - plan.isPi && !plan.isDaytona ? `${plan.relayDir}.otlp-auth` : undefined; + plan.isPi && !plan.isDaytona ? `${plan.workspace.relayDir}.otlp-auth` : undefined; const otlpAuthorization = request.telemetry?.exporters?.otlp?.headers?.authorization; if (otlpAuthFilePath && otlpAuthorization) { @@ -195,14 +199,14 @@ export async function prepareEnvironmentSetup( } const piExtEnv = plan.isPi ? buildPiExtensionEnv(request, !plan.isDaytona, { - relayDir: plan.relayDir, - usageOutPath: plan.usageOutPath, + relayDir: plan.workspace.relayDir, + usageOutPath: plan.workspace.usageOutPath, otlpAuthFilePath, - builtinGatingActive: plan.builtinGatingActive, + builtinGatingActive: plan.tools.builtinGatingActive, // The materialized skill names (author + forced `_agenta.*`) so Pi's own agent span // records which skills loaded; local Pi self-instruments, so the runner's sandbox-agent // otel has no span to stamp here. - skills: plan.skillDirs.map((s) => s.name), + skills: plan.workspace.skillDirs.map((s) => s.name), }) : {}; // Daytona's provider is built from `piExtEnv` rather than the local daemon env. Keep the @@ -218,11 +222,11 @@ export async function prepareEnvironmentSetup( configureDaytonaCodexEnv(plan, piExtEnv); Object.assign(env, piExtEnv); // local daemon inherits it; daytona gets it via envVars logger( - `tools=${plan.toolSpecs.length} executableTools=${plan.executableToolSpecs.length} ` + + `tools=${plan.tools.toolSpecs.length} executableTools=${plan.tools.executableToolSpecs.length} ` + `piPublicTools=${piExtEnv.AGENTA_AGENT_TOOLS_PUBLIC_SPECS ? "yes" : "no"}`, ); if (!plan.isPi && plan.isDaytona) { - const clientTools = plan.toolSpecs + const clientTools = plan.tools.toolSpecs .filter((spec) => spec.kind === "client") .map((spec) => spec.name); if (clientTools.length > 0) { @@ -243,12 +247,14 @@ export async function prepareEnvironmentSetup( if (plan.isPi) { try { // The presence check consults the FULL materialized model environment: on a Daytona - // Secrets run the opaque key left `plan.modelEnvironment` for the secret plan, but the - // sandbox still receives its binding (as a Daytona Secret attachment). + // Secrets run the opaque key left `plan.credentials.modelEnvironment` for the + // secret plan, but the sandbox still receives its binding (as a Daytona Secret + // attachment). const fullModelEnvironment: Record = { - ...plan.modelEnvironment, + ...plan.credentials.modelEnvironment, }; - for (const candidate of plan.daytonaSecretPlan?.candidates ?? []) { + const secretCandidates = plan.credentials.daytonaSecretPlan?.candidates; + for (const candidate of secretCandidates ?? []) { if (candidate.consumer.kind === "model") { fullModelEnvironment[candidate.binding.name] = candidate.value; } @@ -295,7 +301,7 @@ export async function prepareEnvironmentSetup( const localBuiltinGatingUnenforceable = plan.isPi && !plan.isDaytona && - plan.builtinGatingActive && + plan.tools.builtinGatingActive && !localPiAssets.extensionInstalled; // Fail closed: a Pi run whose provider routing rides the extension's model endpoint override // (`model-provider-override.ts`, set in `buildPiExtensionEnv`) cannot run without the @@ -315,7 +321,7 @@ export async function prepareEnvironmentSetup( // lifecycle, exactly like a normal local install (interface.md section 6). buildRunPlan already // rejected a runtime_provided Claude run with no configured CLAUDE_CONFIG_DIR. - logger(`harness=${plan.harness} sandbox=${plan.sandboxId} cwd=${plan.cwd}`); + logger(`harness=${plan.harness} sandbox=${plan.sandboxId} cwd=${plan.workspace.cwd}`); // The resolved model ref as it reaches the runner (key NAMES only, never values) — the one // line that answers "what model/provider/deployment/credential did this run actually use". @@ -391,7 +397,7 @@ export async function prepareEnvironmentSetup( ? undefined : { cleanup: async () => - rmSync(plan.cwd, { recursive: true, force: true }), + rmSync(plan.workspace.cwd, { recursive: true, force: true }), }, runtimeRemount: undefined, closeToolMcp: undefined, diff --git a/services/runner/src/engines/sandbox_agent/environment.ts b/services/runner/src/engines/sandbox_agent/environment.ts index 984a39c8b2..954ba90029 100644 --- a/services/runner/src/engines/sandbox_agent/environment.ts +++ b/services/runner/src/engines/sandbox_agent/environment.ts @@ -350,7 +350,7 @@ export async function acquireEnvironment( } if (!environment.durableCwdSafeToDelete) { logger( - `durable cwd unmount not confirmed, skipping workspace cleanup cwd=${plan.cwd}`, + `durable cwd unmount not confirmed, skipping workspace cleanup cwd=${plan.workspace.cwd}`, ); } else { await environment.workspace?.cleanup().catch(() => {}); @@ -375,7 +375,7 @@ export async function acquireEnvironment( if (environment.codexSqliteHome) rmSync(environment.codexSqliteHome, { recursive: true, force: true }); // Remove the per-run skills temp root the materializer created (success or error). - plan.skillsCleanup(); + plan.workspace.skillsCleanup(); }; let agentMountGuidanceActive = false; @@ -394,17 +394,17 @@ export async function acquireEnvironment( } if (!plan.isPi) return; - plan.appendSystemPrompt = combineAppendSystemPrompt( - plan.appendSystemPrompt, + plan.prompt.appendSystemPrompt = combineAppendSystemPrompt( + plan.prompt.appendSystemPrompt, AGENT_MOUNT_SYSTEM_PROMPT_SEGMENT, ); - plan.hasSystemPrompt = true; + plan.prompt.hasSystemPrompt = true; if (plan.isDaytona) { await uploadSystemPromptToSandbox( environment.sandbox, DAYTONA_PI_DIR, - plan.systemPrompt, - plan.appendSystemPrompt, + plan.prompt.systemPrompt, + plan.prompt.appendSystemPrompt, logger, ); return; @@ -412,8 +412,8 @@ export async function acquireEnvironment( if (environment.runAgentDir) { writeSystemPromptLocal( environment.runAgentDir, - plan.systemPrompt, - plan.appendSystemPrompt, + plan.prompt.systemPrompt, + plan.prompt.appendSystemPrompt, logger, ); return; @@ -436,18 +436,18 @@ export async function acquireEnvironment( const mountLocalDurableCwd = async (reason: string): Promise => { if (!environment.mountCreds || plan.isDaytona) return false; logger( - `local durable cwd mount (${reason}) session=${sessionForMount} cwd=${plan.cwd}`, + `local durable cwd mount (${reason}) session=${sessionForMount} cwd=${plan.workspace.cwd}`, ); environment.durableCwdSafeToDelete = false; const mounted = await (deps.mountStorage ?? mountStorage)( - plan.cwd, + plan.workspace.cwd, environment.mountCreds, { log: logger, }, ); if (mounted) { - environment.mountedCwd = plan.cwd; + environment.mountedCwd = plan.workspace.cwd; environment.installedMountExpiries.cwd = mountExpiryMs( environment.mountCreds.expiresAt, ); @@ -459,7 +459,7 @@ export async function acquireEnvironment( }; const mountLocalAgentCwd = async (): Promise => { if (!environment.agentMountCreds || plan.isDaytona) return false; - const mountPath = agentMountPath(plan.cwd); + const mountPath = agentMountPath(plan.workspace.cwd); if (environment.agentMountedPath === mountPath) return true; try { mkdirSync(mountPath, { recursive: true }); @@ -480,7 +480,7 @@ export async function acquireEnvironment( environment.agentMountCreds.expiresAt, ); await seedAgentReadme(mountPath, { log: logger }); - await linkAgentFiles(plan.cwd, mountPath, { log: logger }); + await linkAgentFiles(plan.workspace.cwd, mountPath, { log: logger }); await activateAgentMountGuidance(); return true; } catch (err) { @@ -498,7 +498,7 @@ export async function acquireEnvironment( LOCAL_DURABLE_CWD_ENOTCONN_REMOUNT_LIMIT ) { logger( - `local agent mount ENOTCONN remount limit reached artifact=${artifactId} path=${agentMountPath(plan.cwd)}`, + `local agent mount ENOTCONN remount limit reached artifact=${artifactId} path=${agentMountPath(plan.workspace.cwd)}`, ); return false; } @@ -530,13 +530,13 @@ export async function acquireEnvironment( LOCAL_DURABLE_CWD_ENOTCONN_REMOUNT_LIMIT ) { logger( - `local durable cwd ENOTCONN remount limit reached session=${sessionForMount} cwd=${plan.cwd}`, + `local durable cwd ENOTCONN remount limit reached session=${sessionForMount} cwd=${plan.workspace.cwd}`, ); return false; } localDurableCwdEnotconnRemounts += 1; logger( - `local durable cwd ENOTCONN session=${sessionForMount} cwd=${plan.cwd}; re-signing and remounting`, + `local durable cwd ENOTCONN session=${sessionForMount} cwd=${plan.workspace.cwd}; re-signing and remounting`, ); const fresh = await signMount(sessionForMount, { apiBase: apiBase(), @@ -565,7 +565,7 @@ export async function acquireEnvironment( ) return; logger( - `local durable mount ENOTCONN observed in ACP event session=${sessionForMount} cwd=${plan.cwd}; re-signing and remounting`, + `local durable mount ENOTCONN observed in ACP event session=${sessionForMount} cwd=${plan.workspace.cwd}; re-signing and remounting`, ); environment.runtimeRemount = (async () => { const cwdOk = cwdEligible ? await reSignAndRemountLocalCwd() : true; @@ -627,9 +627,9 @@ export async function acquireEnvironment( env, binaryPath, piExtEnv, - plan.modelEnvironment, + plan.credentials.modelEnvironment, plan.sandboxPermission, - plan.daytonaSecretPlan, + plan.credentials.daytonaSecretPlan, ); const startOptions = { sandbox: sandboxProvider, @@ -708,13 +708,17 @@ export async function acquireEnvironment( deps.prepareDaytonaPiAssets ?? prepareDaytonaPiAssets )({ sandbox: environment.sandbox, - plan: { ...plan, skillDirs: [] }, + plan: { ...plan, workspace: { ...plan.workspace, skillDirs: [] } }, piModelConfig, log: logger, }); // Fail closed (Decision 2): same guarantee as the local path. A genuine upload failure on the // Daytona sandbox stops the run rather than running Pi's built-in tools unprotected. - if (plan.isPi && plan.builtinGatingActive && !daytonaExtensionInstalled) { + if ( + plan.isPi && + plan.tools.builtinGatingActive && + !daytonaExtensionInstalled + ) { throw new Error(PI_PERMISSION_EXTENSION_UNAVAILABLE_MESSAGE); } // Fail closed: the Pi model endpoint override rides the extension; without it the model @@ -726,15 +730,15 @@ export async function acquireEnvironment( ) { throw new Error(PI_MODEL_OVERRIDE_EXTENSION_UNAVAILABLE_MESSAGE); } - if (!plan.isPi && plan.toolSpecs.length > 0) { + if (!plan.isPi && plan.tools.toolSpecs.length > 0) { // Advertise the FULL tool set to the shim, client tools included: a parked client tool // resolves through the relay's paused answer (see startToolRelay / tool-mcp-stdio.ts). internalToolMcp = await ( deps.uploadToolMcpAssets ?? uploadToolMcpAssets )( environment.sandbox, - plan.toolMcpDir, - advertisedToolSpecs(plan.toolSpecs), + plan.workspace.toolMcpDir, + advertisedToolSpecs(plan.tools.toolSpecs), logger, ); } @@ -762,7 +766,7 @@ export async function acquireEnvironment( canMount && (await (deps.mountStorageRemote ?? mountStorageRemote)( environment.sandbox, - plan.cwd, + plan.workspace.cwd, environment.mountCreds, { endpoint, @@ -833,9 +837,12 @@ export async function acquireEnvironment( await seedAgentReadmeRemote(environment.sandbox, mountPath, { log: logger, }); - await linkAgentFilesRemote(environment.sandbox, plan.cwd, mountPath, { - log: logger, - }); + await linkAgentFilesRemote( + environment.sandbox, + plan.workspace.cwd, + mountPath, + { log: logger }, + ); await activateAgentMountGuidance(); logger(`remote agent mount active for artifact=${artifactId}`); } @@ -929,7 +936,7 @@ export async function acquireEnvironment( harness: plan.harness, isPi: plan.isPi, probed, - toolSpecs: plan.toolSpecs, + toolSpecs: plan.tools.toolSpecs, log: logger, }); @@ -938,14 +945,14 @@ export async function acquireEnvironment( capabilities, harness: plan.harness, isDaytona: plan.isDaytona, - toolSpecs: plan.toolSpecs, + toolSpecs: plan.tools.toolSpecs, // On a Daytona Secrets run the provider swaps each MCP credential value for its Daytona // Secret placeholder, so no plaintext secret rides the sandbox-bound session config. userMcpServers: materializeDaytonaMcpServers( sandboxProvider, request.mcpServers, ), - relayDir: plan.relayDir, + relayDir: plan.workspace.relayDir, clientToolRelay: deferredClientToolRelay, executableToolGate: !plan.isPi && !plan.isDaytona ? deferredExecutableToolGate : undefined, @@ -982,7 +989,7 @@ export async function acquireEnvironment( ? { ...(claudeSystemPromptMeta ?? {}), ...(claudeThinking ?? {}) } : undefined; const sessionInit = { - cwd: plan.cwd, + cwd: plan.workspace.cwd, mcpServers: sessionMcp.servers, ...(claudeMeta ? { _meta: claudeMeta } : {}), }; @@ -1058,7 +1065,7 @@ export async function acquireEnvironment( environment.session = await environment.sandbox.createSession({ ...(localSessionId ? { id: localSessionId } : {}), agent: plan.acpAgent, - cwd: plan.cwd, + cwd: plan.workspace.cwd, sessionInit, }); } finally { diff --git a/services/runner/src/engines/sandbox_agent/pi-assets.ts b/services/runner/src/engines/sandbox_agent/pi-assets.ts index d57804fa1a..03d06fa9f9 100644 --- a/services/runner/src/engines/sandbox_agent/pi-assets.ts +++ b/services/runner/src/engines/sandbox_agent/pi-assets.ts @@ -29,7 +29,12 @@ import { serializePiModelsJson, type PiModelConfigPlan, } from "./pi-model-config.ts"; -import type { RunPlan } from "./run-plan.ts"; +import type { + RunPlan, + RunPlanCredentials, + RunPlanPrompt, + RunPlanWorkspace, +} from "./run-plan.ts"; type Log = (message: string) => void; @@ -45,11 +50,13 @@ export function piSessionWorkspaceDir(cwd: string): string { /** Point Pi at the durable conversation-scoped transcript directory. */ export function configurePiSessionWorkspace( - plan: Pick, + plan: Pick & { + workspace: Pick; + }, env: Record, ): string | undefined { if (!plan.isPi) return undefined; - const sessionDir = piSessionWorkspaceDir(plan.cwd); + const sessionDir = piSessionWorkspaceDir(plan.workspace.cwd); env.PI_CODING_AGENT_SESSION_DIR = sessionDir; return sessionDir; } @@ -105,11 +112,13 @@ function hashPart( /** Resolve the immutable project-local snapshot selected for this Pi run. */ export function resolvePiSkillSnapshot( - plan: Pick, + plan: Pick & { + workspace: Pick; + }, ): PiSkillSnapshot | undefined { - if (!plan.isPi || plan.skillDirs.length === 0) return undefined; + if (!plan.isPi || plan.workspace.skillDirs.length === 0) return undefined; - const skills = [...plan.skillDirs].sort((a, b) => + const skills = [...plan.workspace.skillDirs].sort((a, b) => a.name.localeCompare(b.name), ); const hash = createHash("sha256"); @@ -130,7 +139,7 @@ export function resolvePiSkillSnapshot( })}\n`; return { digest, - dir: join(plan.cwd, "agents", "skills", digest), + dir: join(plan.workspace.cwd, "agents", "skills", digest), marker, skills, }; @@ -571,17 +580,14 @@ export function prepareLocalAgentDir( } export interface PrepareLocalPiAssetsInput { - plan: Pick< - RunPlan, - | "isPi" - | "isDaytona" - | "credentialMode" - | "skillDirs" - | "hasSystemPrompt" - | "systemPrompt" - | "appendSystemPrompt" - | "sourcePiAgentDir" - >; + plan: Pick & { + credentials: Pick; + workspace: Pick; + prompt: Pick< + RunPlanPrompt, + "hasSystemPrompt" | "systemPrompt" | "appendSystemPrompt" + >; + }; env: Record; /** * A managed OpenAI-compatible custom run's Pi provider config. When set, the isolated per-run @@ -650,14 +656,14 @@ export function prepareLocalPiAssets({ // buildRunPlan already rejected a local runtime_provided run with no configured // PI_CODING_AGENT_DIR, so `sourcePiAgentDir` here IS the operator's mount. A model-config plan // never reaches here (it requires credentialMode "env"), so there is no models.json to write. - if (plan.credentialMode === "runtime_provided") { - const agentDir = plan.sourcePiAgentDir; + if (plan.credentials.credentialMode === "runtime_provided") { + const agentDir = plan.workspace.sourcePiAgentDir; const extensionInstalled = installPiExtensionLocal(agentDir, log); - if (plan.hasSystemPrompt) { + if (plan.prompt.hasSystemPrompt) { writeSystemPromptLocal( agentDir, - plan.systemPrompt, - plan.appendSystemPrompt, + plan.prompt.systemPrompt, + plan.prompt.appendSystemPrompt, log, ); } @@ -671,15 +677,15 @@ export function prepareLocalPiAssets({ // managed OpenAI-compatible custom run (a model-config plan is present) additionally gets a // models.json and does NOT receive the operator's personal auth.json. const { dir: runAgentDir, extensionInstalled } = prepareLocalAgentDir( - plan.sourcePiAgentDir, + plan.workspace.sourcePiAgentDir, log, { seedCredentials: !piModelConfig }, ); - if (plan.hasSystemPrompt) { + if (plan.prompt.hasSystemPrompt) { writeSystemPromptLocal( runAgentDir, - plan.systemPrompt, - plan.appendSystemPrompt, + plan.prompt.systemPrompt, + plan.prompt.appendSystemPrompt, log, ); } diff --git a/services/runner/src/engines/sandbox_agent/run-plan.ts b/services/runner/src/engines/sandbox_agent/run-plan.ts index 2b002e8569..a27f9848f4 100644 --- a/services/runner/src/engines/sandbox_agent/run-plan.ts +++ b/services/runner/src/engines/sandbox_agent/run-plan.ts @@ -98,15 +98,11 @@ export const LOCAL_SUBSCRIPTION_MOUNT_MISSING_MESSAGE = "runtime_provided local run requires a mounted subscription: set PI_CODING_AGENT_DIR " + "(Pi), CLAUDE_CONFIG_DIR (Claude), or CODEX_HOME (Codex) to a read-write mount of your harness login."; -export interface RunPlan { - harness: string; - acpAgent: string; - sandboxId: string; - isPi: boolean; - isDaytona: boolean; - prompt: string; - turnText: string; - agentsMd?: string; +/** + * How the run authenticates with the model provider. Everything here describes the credential + * itself and how it is delivered, not where the run executes or what it asks the model to do. + */ +export interface RunPlanCredentials { /** Final plaintext model environment, after validating modelConnection. */ modelEnvironment: Record; /** @@ -132,6 +128,13 @@ export interface RunPlan { * that request may still use the harness login. Drives clear-then-apply env (Security rule 5). */ credentialMode?: string; +} + +/** + * Where the run's files live and what gets materialized into them. These are the directories the + * engine creates, mounts, writes into, and cleans up. + */ +export interface RunPlanWorkspace { cwd: string; relayDir: string; /** @@ -144,6 +147,24 @@ export interface RunPlan { */ toolMcpDir: string; usageOutPath?: string; + skillDirs: MaterializedSkill[]; + /** Removes the per-run skills temp root. The engine runs it in its `finally` so it never leaks. */ + skillsCleanup: () => void; + sourcePiAgentDir: string; + /** + * Generic harness-rendered files to materialize in the cwd before the session starts. Each + * `{ path (relative to cwd), content }` was produced by the Python harness adapter (e.g. the + * claude adapter renders `.claude/settings.json` from its permissions slice). `prepareWorkspace` + * writes each entry blind — no harness knowledge on the runner. + */ + harnessFiles?: Array<{ path: string; content: string }>; +} + +/** + * The tools this run offers the model and how they are delivered. It covers both the resolved + * specs and the delivery switches the engine reads when it wires the relay and the gates. + */ +export interface RunPlanTools { toolSpecs: ResolvedToolSpec[]; executableToolSpecs: ResolvedToolSpec[]; /** True when the permission policy needs the extension to intercept Pi builtin calls. */ @@ -156,13 +177,31 @@ export interface RunPlan { * "pi-native", the non-Pi shim (Claude on Daytona) → "cold-acknowledge". */ clientToolPauseDisposition: ClientToolPauseDisposition; +} + +/** + * Everything the model is told for this turn. It holds the user text alongside the rendered + * transcript and the system instructions layered on top of it. + */ +export interface RunPlanPrompt { + text: string; + turnText: string; + agentsMd?: string; systemPrompt?: string; appendSystemPrompt?: string; hasSystemPrompt: boolean; - skillDirs: MaterializedSkill[]; - /** Removes the per-run skills temp root. The engine runs it in its `finally` so it never leaks. */ - skillsCleanup: () => void; - sourcePiAgentDir: string; +} + +export interface RunPlan { + harness: string; + acpAgent: string; + sandboxId: string; + isPi: boolean; + isDaytona: boolean; + credentials: RunPlanCredentials; + workspace: RunPlanWorkspace; + tools: RunPlanTools; + prompt: RunPlanPrompt; /** * The declared sandbox security boundary (Layer 2). `buildSandboxProvider` enforces the * network policy on Daytona (S1b); `buildRunPlan` rejects restricted-network runs the @@ -170,13 +209,6 @@ export interface RunPlan { * MCP) when `enforcement === "strict"`. */ sandboxPermission?: SandboxPermission; - /** - * Generic harness-rendered files to materialize in the cwd before the session starts. Each - * `{ path (relative to cwd), content }` was produced by the Python harness adapter (e.g. the - * claude adapter renders `.claude/settings.json` from its permissions slice). `prepareWorkspace` - * writes each entry blind — no harness knowledge on the runner. - */ - harnessFiles?: Array<{ path: string; content: string }>; } export type BuildRunPlanResult = @@ -670,42 +702,51 @@ export function buildRunPlan( sandboxId, isPi, isDaytona, - prompt, - turnText: buildTurnText(request, log), - agentsMd: request.agentsMd?.trim() || undefined, - modelEnvironment, - daytonaSecretPlan, - harnessApiKeyVar, - // Consult the FULL materialized environment: on a Daytona Secrets run the opaque key is - // delivered as a Secret attachment rather than plaintext env, but the harness still has it. - hasApiKey: !!materializedModel.environment[harnessApiKeyVar], - credentialMode: materializedModel.credentialMode, - cwd, - relayDir, - toolMcpDir, - // Usage capture is ephemeral runner output, not durable session data — keep it off the - // geesefs mount alongside the relay dir (a mount write would risk ENOTCONN). - usageOutPath: isPi ? join(relayDir, ".agenta-usage.json") : undefined, - toolSpecs, - executableToolSpecs: executableToolSpecsForRun, - builtinGatingActive, - // The relay carries tool EXECUTION only (permission gates ride the extension's - // `ctx.ui.confirm` dialog onto the ACP plane), so a builtin-gating-only run needs no relay. - useToolRelay: toolSpecs.length > 0, - // Pi parks through its own extension (no answer file); the non-Pi shim blocks on an answer - // file, so a parked client tool is acknowledged with a benign paused answer. - clientToolPauseDisposition: isPi ? "pi-native" : "cold-acknowledge", - systemPrompt, - appendSystemPrompt, - hasSystemPrompt: !!(systemPrompt || appendSystemPrompt), - skillDirs, - skillsCleanup, - sourcePiAgentDir: - process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), + credentials: { + modelEnvironment, + daytonaSecretPlan, + harnessApiKeyVar, + // Consult the FULL materialized environment: on a Daytona Secrets run the opaque key is + // delivered as a Secret attachment rather than plaintext env, but the harness still has it. + hasApiKey: !!materializedModel.environment[harnessApiKeyVar], + credentialMode: materializedModel.credentialMode, + }, + workspace: { + cwd, + relayDir, + toolMcpDir, + // Usage capture is ephemeral runner output, not durable session data — keep it off the + // geesefs mount alongside the relay dir (a mount write would risk ENOTCONN). + usageOutPath: isPi ? join(relayDir, ".agenta-usage.json") : undefined, + skillDirs, + skillsCleanup, + sourcePiAgentDir: + process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), + // Generic: the Python harness adapter already rendered any harness config files; the + // runner just carries them onto the plan and writes them into the cwd in + // `prepareWorkspace`. + harnessFiles: request.harnessFiles, + }, + tools: { + toolSpecs, + executableToolSpecs: executableToolSpecsForRun, + builtinGatingActive, + // The relay carries tool EXECUTION only (permission gates ride the extension's + // `ctx.ui.confirm` dialog onto the ACP plane), so a builtin-gating-only run needs no relay. + useToolRelay: toolSpecs.length > 0, + // Pi parks through its own extension (no answer file); the non-Pi shim blocks on an answer + // file, so a parked client tool is acknowledged with a benign paused answer. + clientToolPauseDisposition: isPi ? "pi-native" : "cold-acknowledge", + }, + prompt: { + text: prompt, + turnText: buildTurnText(request, log), + agentsMd: request.agentsMd?.trim() || undefined, + systemPrompt, + appendSystemPrompt, + hasSystemPrompt: !!(systemPrompt || appendSystemPrompt), + }, sandboxPermission: request.sandboxPermission, - // Generic: the Python harness adapter already rendered any harness config files; the runner - // just carries them onto the plan and writes them into the cwd in `prepareWorkspace`. - harnessFiles: request.harnessFiles, }, }; } diff --git a/services/runner/src/engines/sandbox_agent/run-turn.ts b/services/runner/src/engines/sandbox_agent/run-turn.ts index de5fda5408..513d9f5c84 100644 --- a/services/runner/src/engines/sandbox_agent/run-turn.ts +++ b/services/runner/src/engines/sandbox_agent/run-turn.ts @@ -86,7 +86,7 @@ import { resolveRunUsage } from "./usage.ts"; * controller / decisions / responder into `env.currentTurn`, restart the tool relay, * send the prompt, resolve usage, and finish + flush the trace. It does NOT tear down the * environment (the caller owns `env.destroy`). On a continuation the prompt is only the new user - * text (`buildTurnText` does not run); on a cold turn it is `plan.turnText`, exactly as before. + * text (`buildTurnText` does not run); on a cold turn it is `plan.prompt.turnText`, exactly as before. */ export async function runTurn( env: SessionEnvironment, @@ -226,7 +226,8 @@ export async function runTurn( ? resolvePromptText(request) : currentUserTurn(request).text; // Cold: replay the full transcript. Continuation or loaded: send only new text. When history - // was rebuilt from records, recompute the transcript from it — the prebuilt plan.turnText + // was rebuilt from records, recompute the transcript from it — the prebuilt + // plan.prompt.turnText // predates the reconstruction. An approval reply has no new text either way, so it sends the // approval-resume frame `buildTurnText` renders (the harness already holds the prior turns // when the session was loaded natively; otherwise the rebuilt transcript comes with it). @@ -236,12 +237,12 @@ export async function runTurn( : promptText : reconstructed || historicalAttachmentsPresent ? buildTurnText(request, logger) - : plan.turnText; + : plan.prompt.turnText; const run = (deps.createOtel ?? createSandboxAgentOtel)({ harness: plan.harness, model: env.model, - skills: plan.skillDirs.map((s) => s.name), + skills: plan.workspace.skillDirs.map((s) => s.name), traceparent: request.context?.propagation?.traceparent, baggage: request.context?.propagation?.baggage, endpoint: request.telemetry?.exporters?.otlp?.endpoint, @@ -640,7 +641,7 @@ export async function runTurn( const serverPermissions = serverPermissionsFromRequest(request); // The SAME name->spec index the relay execute loop hands to the relay execution guard, so // the approval card and the guard cannot disagree about a tool's permission/readOnly. - const specsByName = toolSpecsByName(plan.toolSpecs); + const specsByName = toolSpecsByName(plan.tools.toolSpecs); const settleBufferedPausedCompletions = (): void => { for (const [toolCallId, update] of [ ...bufferedPausedCompletedFrames.entries(), @@ -733,7 +734,7 @@ export async function runTurn( // policy). Absent for Claude, so a title collision there keeps the base path. piToolSpecsByName: plan.isPi ? new Map( - plan.toolSpecs.map((spec) => [ + plan.tools.toolSpecs.map((spec) => [ spec.name, { permission: spec.permission, @@ -816,15 +817,15 @@ export async function runTurn( executionGrants, }); - if (plan.useToolRelay) { + if (plan.tools.useToolRelay) { turn.toolRelay = (deps.startToolRelay ?? startToolRelay)( plan.isDaytona ? (deps.sandboxRelayHost ?? sandboxRelayHost)(env.sandbox, { log: logger, }) : (deps.localRelayHost ?? localRelayHost)(), - plan.relayDir, - plan.toolSpecs, + plan.workspace.relayDir, + plan.tools.toolSpecs, request.toolCallback as ToolCallbackContext | undefined, request.runContext, env.clientToolRelayRef.current, @@ -834,7 +835,7 @@ export async function runTurn( // Derived from the run plan's client-tool pause disposition (the closed set lives at // the client-tool boundary; the relay only needs the boolean). writePausedAnswer: relayWritesPausedAnswer( - plan.clientToolPauseDisposition, + plan.tools.clientToolPauseDisposition, ), }, ); @@ -1031,7 +1032,7 @@ export async function runTurn( const usage = await resolveRunUsage({ sandbox: env.sandbox, - usageOutPath: plan.usageOutPath, + usageOutPath: plan.workspace.usageOutPath, isDaytona: plan.isDaytona, promptResult: result, streamUsage: run.usage(), @@ -1043,9 +1044,9 @@ export async function runTurn( !plan.isDaytona && !run.output().trim() && !run.events().some((e) => e.type === "tool_call") - ? // The helper derives the transcript location from `piSessionWorkspaceDir(plan.cwd)`, + ? // The helper derives the transcript location from `piSessionWorkspaceDir(plan.workspace.cwd)`, // the same shared helper `configurePiSessionWorkspace` used to point Pi at it. - findSwallowedPiError(plan.cwd) + findSwallowedPiError(plan.workspace.cwd) : undefined; let swallowedError: string | undefined; if (swallowedPiError) { diff --git a/services/runner/src/engines/sandbox_agent/workspace.ts b/services/runner/src/engines/sandbox_agent/workspace.ts index 1277bb4f5f..4ff59f4deb 100644 --- a/services/runner/src/engines/sandbox_agent/workspace.ts +++ b/services/runner/src/engines/sandbox_agent/workspace.ts @@ -1,7 +1,12 @@ import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import type { RunPlan } from "./run-plan.ts"; +import type { + RunPlan, + RunPlanPrompt, + RunPlanTools, + RunPlanWorkspace, +} from "./run-plan.ts"; import { materializeDaytonaPiSkillSnapshot, materializeLocalPiSkillSnapshot, @@ -18,18 +23,14 @@ export interface Workspace { export interface PrepareWorkspaceInput { sandbox: any; piSkillSnapshot?: PiSkillSnapshot; - plan: Pick< - RunPlan, - | "isDaytona" - | "isPi" - | "cwd" - | "relayDir" - | "useToolRelay" - | "agentsMd" - | "acpAgent" - | "harnessFiles" - | "skillDirs" - >; + plan: Pick & { + workspace: Pick< + RunPlanWorkspace, + "cwd" | "relayDir" | "harnessFiles" | "skillDirs" + >; + tools: Pick; + prompt: Pick; + }; log?: Log; } @@ -52,7 +53,7 @@ export async function prepareWorkspace({ piSkillSnapshot, log = () => {}, }: PrepareWorkspaceInput): Promise { - const harnessFiles = plan.harnessFiles ?? []; + const harnessFiles = plan.workspace.harnessFiles ?? []; const projectSkillRoot = plan.isPi ? undefined : `.${plan.acpAgent}/skills`; // Claude's memory loader reads CLAUDE.md, never AGENTS.md; Pi (and any other harness) reads // AGENTS.md. See the doc comment above and docs/design/agent-workflows/projects/ @@ -61,32 +62,37 @@ export async function prepareWorkspace({ plan.acpAgent === "claude" ? "CLAUDE.md" : "AGENTS.md"; if (plan.isDaytona) { - await sandbox.mkdirFs({ path: plan.cwd }).catch((err: Error) => { + await sandbox.mkdirFs({ path: plan.workspace.cwd }).catch((err: Error) => { log(`workspace mkdir skipped: ${err.message}`); }); - if (plan.useToolRelay) { + if (plan.tools.useToolRelay) { // Clear stale .req.json/.res.json from a prior turn before recreating: the relay // dir is keyed on the durable cwd and a fresh per-turn `seen` set would otherwise re-execute it. if (typeof sandbox.runProcess === "function") { // Direct argv, no shell, so an arbitrary path can't break or inject. await sandbox - .runProcess({ command: "rm", args: ["-rf", "--", plan.relayDir] }) + .runProcess({ + command: "rm", + args: ["-rf", "--", plan.workspace.relayDir], + }) .catch((err: Error) => { log(`tool relay dir clear skipped: ${err.message}`); }); } - await sandbox.mkdirFs({ path: plan.relayDir }).catch((err: Error) => { - log(`tool relay dir mkdir skipped: ${err.message}`); - }); + await sandbox + .mkdirFs({ path: plan.workspace.relayDir }) + .catch((err: Error) => { + log(`tool relay dir mkdir skipped: ${err.message}`); + }); } - if (plan.agentsMd) { + if (plan.prompt.agentsMd) { await sandbox.writeFsFile( - { path: `${plan.cwd}/${instructionsFile}` }, - plan.agentsMd, + { path: `${plan.workspace.cwd}/${instructionsFile}` }, + plan.prompt.agentsMd, ); } for (const file of harnessFiles) { - const path = `${plan.cwd}/${file.path}`; + const path = `${plan.workspace.cwd}/${file.path}`; const parent = dirname(path); await sandbox.mkdirFs({ path: parent }).catch((err: Error) => { log(`harness file dir mkdir skipped: ${err.message}`); @@ -97,11 +103,11 @@ export async function prepareWorkspace({ await materializeDaytonaPiSkillSnapshot(sandbox, piSkillSnapshot); } if (projectSkillRoot) { - for (const skill of plan.skillDirs) { + for (const skill of plan.workspace.skillDirs) { await uploadDirToSandbox( sandbox, skill.dir, - `${plan.cwd}/${projectSkillRoot}/${skill.name}`, + `${plan.workspace.cwd}/${projectSkillRoot}/${skill.name}`, ).catch((err: Error) => { log( `skill workspace upload skipped for ${skill.name}: ${err.message}`, @@ -115,25 +121,29 @@ export async function prepareWorkspace({ // A durable local cwd mount is best-effort. When geesefs cannot mount (for example, the // runner has no /dev/fuse), acquisition deliberately falls back to an ephemeral cwd. Ensure // that fallback exists before writing CLAUDE.md/AGENTS.md or any harness files into it. - mkdirSync(plan.cwd, { recursive: true }); + mkdirSync(plan.workspace.cwd, { recursive: true }); - if (plan.useToolRelay) { + if (plan.tools.useToolRelay) { // Clear stale .req.json from a prior turn: relayDir is keyed on the durable cwd and // is never otherwise cleared, so an old request would be re-picked-up by the fresh `seen` set. - rmSync(plan.relayDir, { recursive: true, force: true }); - mkdirSync(plan.relayDir, { recursive: true }); + rmSync(plan.workspace.relayDir, { recursive: true, force: true }); + mkdirSync(plan.workspace.relayDir, { recursive: true }); } - if (plan.agentsMd) - writeFileSync(join(plan.cwd, instructionsFile), plan.agentsMd, "utf-8"); + if (plan.prompt.agentsMd) + writeFileSync( + join(plan.workspace.cwd, instructionsFile), + plan.prompt.agentsMd, + "utf-8", + ); for (const file of harnessFiles) { - const path = join(plan.cwd, file.path); + const path = join(plan.workspace.cwd, file.path); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, file.content, "utf-8"); } if (piSkillSnapshot) materializeLocalPiSkillSnapshot(piSkillSnapshot); if (projectSkillRoot) { - for (const skill of plan.skillDirs) { - const dest = join(plan.cwd, projectSkillRoot, skill.name); + for (const skill of plan.workspace.skillDirs) { + const dest = join(plan.workspace.cwd, projectSkillRoot, skill.name); mkdirSync(dirname(dest), { recursive: true }); cpSync(skill.dir, dest, { recursive: true, dereference: true }); } @@ -141,7 +151,7 @@ export async function prepareWorkspace({ return { cleanup: async () => { - rmSync(plan.cwd, { recursive: true, force: true }); + rmSync(plan.workspace.cwd, { recursive: true, force: true }); }, }; } diff --git a/services/runner/tests/unit/attachment-delivery-events.test.ts b/services/runner/tests/unit/attachment-delivery-events.test.ts index fbb81657da..b5a7f5c759 100644 --- a/services/runner/tests/unit/attachment-delivery-events.test.ts +++ b/services/runner/tests/unit/attachment-delivery-events.test.ts @@ -71,7 +71,7 @@ describe("attachment delivery events", () => { auth: () => "ApiKey test", sandbox: {}, plan: { - cwd, + workspace: { cwd }, isDaytona: false, acpAgent: "pi", harness: "pi_core", @@ -151,7 +151,7 @@ describe("attachment delivery events", () => { auth: () => "ApiKey test", sandbox: {}, plan: { - cwd, + workspace: { cwd }, isDaytona: false, acpAgent: "pi", harness: "pi_core", @@ -207,7 +207,7 @@ describe("attachment delivery events", () => { auth: () => "ApiKey test", sandbox: {}, plan: { - cwd, + workspace: { cwd }, isDaytona: false, acpAgent: "pi", harness: "pi_core", diff --git a/services/runner/tests/unit/attachment-materialize.test.ts b/services/runner/tests/unit/attachment-materialize.test.ts index 4611cc624b..ae588fea88 100644 --- a/services/runner/tests/unit/attachment-materialize.test.ts +++ b/services/runner/tests/unit/attachment-materialize.test.ts @@ -39,7 +39,7 @@ describe("attachment materialization", () => { assert.equal( await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, ref, new Uint8Array([1, 2, 3]), ), @@ -56,7 +56,7 @@ describe("attachment materialization", () => { const ref = { attachmentId: ID_ONE, filename: "photo.png" }; await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, ref, new Uint8Array([1]), ); @@ -66,7 +66,7 @@ describe("attachment materialization", () => { assert.equal( await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, ref, new Uint8Array([2]), ), @@ -81,13 +81,13 @@ describe("attachment materialization", () => { const second = { attachmentId: ID_TWO, filename: "same.txt" }; await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, first, new Uint8Array([1]), ); await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, second, new Uint8Array([2]), ); @@ -125,7 +125,7 @@ describe("attachment materialization", () => { assert.equal( await materializeWorkingCopy( sandbox, - { cwd, isDaytona: true }, + { workspace: { cwd }, isDaytona: true }, ref, bytes, ), @@ -159,7 +159,7 @@ describe("attachment materialization", () => { await assert.rejects( materializeWorkingCopy( sandbox, - { cwd: "/home/sandbox/cwd", isDaytona: true }, + { workspace: { cwd: "/home/sandbox/cwd" }, isDaytona: true }, { attachmentId: ID_ONE, filename: "photo.png" }, new Uint8Array([1]), ), @@ -180,7 +180,7 @@ describe("attachment materialization", () => { const existing = { attachmentId: ids[0], filename: `.png` }; await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, existing, new Uint8Array([9]), ); @@ -215,7 +215,7 @@ describe("attachment materialization", () => { const restored = await restoreReferencedWorkingCopies( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, messages, "session-1", () => "ApiKey test", @@ -272,7 +272,7 @@ describe("attachment materialization", () => { const restored = await restoreReferencedWorkingCopies( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, messages, "session-1", () => "ApiKey test", diff --git a/services/runner/tests/unit/attachment-path-safety.test.ts b/services/runner/tests/unit/attachment-path-safety.test.ts index 048355cf35..8b114e9247 100644 --- a/services/runner/tests/unit/attachment-path-safety.test.ts +++ b/services/runner/tests/unit/attachment-path-safety.test.ts @@ -77,7 +77,7 @@ describe("attachment path safety", () => { () => materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, { attachmentId: ATTACHMENT_ID, filename: "safe.txt" }, new Uint8Array([1]), ), diff --git a/services/runner/tests/unit/daytona-secret-plan.test.ts b/services/runner/tests/unit/daytona-secret-plan.test.ts index e444b2a167..13894b026a 100644 --- a/services/runner/tests/unit/daytona-secret-plan.test.ts +++ b/services/runner/tests/unit/daytona-secret-plan.test.ts @@ -296,11 +296,11 @@ describe("Daytona Secret planning", () => { assert.equal(disabled.ok, true); if (!disabled.ok) return; assert.equal( - disabled.plan.modelEnvironment.ANTHROPIC_API_KEY, + disabled.plan.credentials.modelEnvironment.ANTHROPIC_API_KEY, "opaque-model-value", ); - assert.equal(disabled.plan.daytonaSecretPlan, undefined); - assert.equal(disabled.plan.hasApiKey, true); + assert.equal(disabled.plan.credentials.daytonaSecretPlan, undefined); + assert.equal(disabled.plan.credentials.hasApiKey, true); // Flag ON: the opaque value leaves the plaintext environment for the secret plan. process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = "process_local"; @@ -309,12 +309,12 @@ describe("Daytona Secret planning", () => { }); assert.equal(enabled.ok, true); if (!enabled.ok) return; - assert.deepEqual(enabled.plan.modelEnvironment, { + assert.deepEqual(enabled.plan.credentials.modelEnvironment, { AWS_REGION: "us-east-1", }); // hasApiKey consults the FULL materialized environment: the opaque key left the plaintext // env for the secret plan, but the harness still receives it as a Secret attachment. - assert.equal(enabled.plan.hasApiKey, true); - assert.equal(enabled.plan.daytonaSecretPlan?.candidates.length, 1); + assert.equal(enabled.plan.credentials.hasApiKey, true); + assert.equal(enabled.plan.credentials.daytonaSecretPlan?.candidates.length, 1); }); }); diff --git a/services/runner/tests/unit/pi-builtin-activation.test.ts b/services/runner/tests/unit/pi-builtin-activation.test.ts index 5ad1f4c1eb..0ff0b1a490 100644 --- a/services/runner/tests/unit/pi-builtin-activation.test.ts +++ b/services/runner/tests/unit/pi-builtin-activation.test.ts @@ -49,8 +49,8 @@ describe("the deprecated `tools` field does not influence the plan", () => { ]) { it(`produces the same gating decision for tools=${JSON.stringify(tools)}`, () => { const plan = planFor({ permissions, tools } as Partial); - assert.equal(plan.builtinGatingActive, false); - assert.equal(plan.useToolRelay, false); + assert.equal(plan.tools.builtinGatingActive, false); + assert.equal(plan.tools.useToolRelay, false); }); } }); @@ -58,7 +58,7 @@ describe("the deprecated `tools` field does not influence the plan", () => { describe("builtin gating follows the permission policy alone", () => { it("stays off under a blanket allow with no builtin rules", () => { assert.equal( - planFor({ permissions: { default: "allow", rules: [] } }) + planFor({ permissions: { default: "allow", rules: [] } }).tools .builtinGatingActive, false, ); @@ -66,7 +66,7 @@ describe("builtin gating follows the permission policy alone", () => { it("turns on under allow_reads", () => { assert.equal( - planFor({ permissions: { default: "allow_reads", rules: [] } }) + planFor({ permissions: { default: "allow_reads", rules: [] } }).tools .builtinGatingActive, true, ); @@ -79,7 +79,7 @@ describe("builtin gating follows the permission policy alone", () => { default: "allow", rules: [{ pattern: "bash(npm:*)", permission: "deny" }], }, - }).builtinGatingActive, + }).tools.builtinGatingActive, true, ); }); diff --git a/services/runner/tests/unit/sandbox-agent-codex-assets.test.ts b/services/runner/tests/unit/sandbox-agent-codex-assets.test.ts index 6d364062cc..660f0be235 100644 --- a/services/runner/tests/unit/sandbox-agent-codex-assets.test.ts +++ b/services/runner/tests/unit/sandbox-agent-codex-assets.test.ts @@ -43,9 +43,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: false, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -75,9 +75,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "claude", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: false, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -95,9 +95,9 @@ describe("Codex managed-credential assets", () => { }; const plan = { acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, isDaytona: false, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -119,9 +119,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: false, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -135,9 +135,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: true, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -154,9 +154,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: false, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -171,35 +171,35 @@ describe("Codex managed-credential assets", () => { assert.equal( isManagedCodexRun({ acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, } as any), true, ); assert.equal( isManagedCodexRun({ acpAgent: "codex", - credentialMode: "none", + credentials: { credentialMode: "none" }, } as any), true, ); assert.equal( isManagedCodexRun({ acpAgent: "codex", - credentialMode: undefined, + credentials: { credentialMode: undefined }, } as any), true, ); assert.equal( isManagedCodexRun({ acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, } as any), false, ); assert.equal( isManagedCodexRun({ acpAgent: "claude", - credentialMode: "env", + credentials: { credentialMode: "env" }, } as any), false, ); @@ -209,21 +209,21 @@ describe("Codex managed-credential assets", () => { assert.equal( isSubscriptionCodexRun({ acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, } as any), true, ); assert.equal( isSubscriptionCodexRun({ acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, } as any), false, ); assert.equal( isSubscriptionCodexRun({ acpAgent: "claude", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, } as any), false, ); @@ -251,9 +251,9 @@ describe("Codex managed-credential assets", () => { const subPlan = () => ({ acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, isDaytona: false, - cwd, + workspace: { cwd }, }) as any; it("symlinks /.codex/auth.json to the mount's auth.json and links nothing else", () => { @@ -288,18 +288,23 @@ describe("Codex managed-credential assets", () => { it("is a no-op for a managed run, a Daytona subscription run, and a non-codex run", () => { const plans = [ - { acpAgent: "codex", credentialMode: "env", isDaytona: false, cwd }, { acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "env" }, + isDaytona: false, + workspace: { cwd }, + }, + { + acpAgent: "codex", + credentials: { credentialMode: "runtime_provided" }, isDaytona: true, - cwd, + workspace: { cwd }, }, { acpAgent: "claude", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, isDaytona: false, - cwd, + workspace: { cwd }, }, ] as any[]; for (const plan of plans) { @@ -314,9 +319,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: true, - cwd, + workspace: { cwd }, } as any; configureDaytonaCodexEnv(plan, env); @@ -332,15 +337,30 @@ describe("Codex managed-credential assets", () => { it("configureDaytonaCodexEnv is a no-op for local, subscription, and non-codex Daytona runs", () => { const plans = [ - { acpAgent: "codex", credentialMode: "env", isDaytona: false, cwd }, { acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "env" }, + isDaytona: false, + workspace: { cwd }, + }, + { + acpAgent: "codex", + credentials: { credentialMode: "runtime_provided" }, + isDaytona: true, + workspace: { cwd }, + }, + { + acpAgent: "claude", + credentials: { credentialMode: "env" }, + isDaytona: true, + workspace: { cwd }, + }, + { + acpAgent: "pi", + credentials: { credentialMode: "env" }, isDaytona: true, - cwd, + workspace: { cwd }, }, - { acpAgent: "claude", credentialMode: "env", isDaytona: true, cwd }, - { acpAgent: "pi", credentialMode: "env", isDaytona: true, cwd }, ] as any[]; for (const plan of plans) { const env: Record = {}; @@ -357,9 +377,9 @@ describe("Codex managed-credential assets", () => { configureDaytonaCodexEnv( { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: true, - cwd, + workspace: { cwd }, } as any, env, ); diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index dcfd5484da..5d4ee4bc5b 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -836,7 +836,7 @@ describe("runSandboxAgent orchestration", () => { }); let agentDirSkillCount = -1; deps.prepareDaytonaPiAssets = (async ({ plan }: any) => { - agentDirSkillCount = plan.skillDirs.length; + agentDirSkillCount = plan.workspace.skillDirs.length; return true; }) as any; @@ -940,7 +940,7 @@ describe("runSandboxAgent orchestration", () => { (calls.providerArgs[1] as Record).AGENTA_AGENT_MOUNT_DIR, undefined, ); - assert.equal(calls.workspacePlan.appendSystemPrompt, undefined); + assert.equal(calls.workspacePlan.prompt.appendSystemPrompt, undefined); assert.equal(existsSync(`${cwd}-agent`), false); rmSync(cwd, { recursive: true, force: true }); }); diff --git a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts index 690723e53e..264f2ae8b9 100644 --- a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts +++ b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts @@ -56,7 +56,7 @@ describe("Pi session workspace", () => { const env: Record = {}; const sessionDir = configurePiSessionWorkspace( - { isPi: true, cwd: "/work/session-1" }, + { isPi: true, workspace: { cwd: "/work/session-1" } }, env, ); @@ -71,7 +71,10 @@ describe("Pi session workspace", () => { const env: Record = {}; assert.equal( - configurePiSessionWorkspace({ isPi: false, cwd: "/work/session-1" }, env), + configurePiSessionWorkspace( + { isPi: false, workspace: { cwd: "/work/session-1" } }, + env, + ), undefined, ); assert.equal(env.PI_CODING_AGENT_SESSION_DIR, undefined); @@ -543,11 +546,16 @@ describe("prepareLocalPiAssets (managed/none routes through a throwaway dir)", ( const plainPiPlan = { isPi: true, isDaytona: false, - skillDirs: [], - hasSystemPrompt: false, - systemPrompt: undefined, - appendSystemPrompt: undefined, - sourcePiAgentDir: "/unused", + credentials: {}, + workspace: { + skillDirs: [], + sourcePiAgentDir: "/unused", + }, + prompt: { + hasSystemPrompt: false, + systemPrompt: undefined, + appendSystemPrompt: undefined, + }, }; it("installs the extension into a per-run temp dir it owns, independent of PI_CODING_AGENT_DIR", () => { @@ -604,7 +612,10 @@ describe("prepareLocalPiAssets (managed/none routes through a throwaway dir)", ( writeFileSync(join(source, "auth.json"), '{"token":"managed"}', "utf-8"); const { dir: runDir } = prepareLocalPiAssets({ - plan: { ...plainPiPlan, sourcePiAgentDir: source }, + plan: { + ...plainPiPlan, + workspace: { ...plainPiPlan.workspace, sourcePiAgentDir: source }, + }, env: {}, }); assert.ok(runDir); @@ -622,7 +633,10 @@ describe("prepareLocalPiAssets (managed/none routes through a throwaway dir)", ( const env: Record = {}; const { dir: runDir, modelConfigWritten } = prepareLocalPiAssets({ - plan: { ...plainPiPlan, sourcePiAgentDir: source }, + plan: { + ...plainPiPlan, + workspace: { ...plainPiPlan.workspace, sourcePiAgentDir: source }, + }, env, piModelConfig: MODEL_CONFIG_PLAN, }); @@ -660,8 +674,10 @@ describe("Pi skill snapshots", () => { const first = resolvePiSkillSnapshot({ isPi: true, - cwd, - skillDirs: [{ name: "release-notes", dir: skill }], + workspace: { + cwd, + skillDirs: [{ name: "release-notes", dir: skill }], + }, }); assert.ok(first); assert.match(first.dir, new RegExp(`${cwd}/agents/skills/[a-f0-9]{64}$`)); @@ -686,8 +702,10 @@ describe("Pi skill snapshots", () => { writeFileSync(join(skill, "SKILL.md"), "second", "utf-8"); const second = resolvePiSkillSnapshot({ isPi: true, - cwd, - skillDirs: [{ name: "release-notes", dir: skill }], + workspace: { + cwd, + skillDirs: [{ name: "release-notes", dir: skill }], + }, }); assert.ok(second); assert.notEqual(second.dir, first.dir); @@ -705,8 +723,10 @@ describe("Pi skill snapshots", () => { writeFileSync(join(skill, "SKILL.md"), "skill", "utf-8"); const snapshot = resolvePiSkillSnapshot({ isPi: true, - cwd, - skillDirs: [{ name: "release-notes", dir: skill }], + workspace: { + cwd, + skillDirs: [{ name: "release-notes", dir: skill }], + }, }); assert.ok(snapshot); mkdirSync(snapshot.dir, { recursive: true }); @@ -724,11 +744,17 @@ describe("Pi skill snapshots", () => { it("does not configure snapshots for non-Pi or empty-skill runs", () => { assert.equal( - resolvePiSkillSnapshot({ isPi: false, cwd: "/work", skillDirs: [] }), + resolvePiSkillSnapshot({ + isPi: false, + workspace: { cwd: "/work", skillDirs: [] }, + }), undefined, ); assert.equal( - resolvePiSkillSnapshot({ isPi: true, cwd: "/work", skillDirs: [] }), + resolvePiSkillSnapshot({ + isPi: true, + workspace: { cwd: "/work", skillDirs: [] }, + }), undefined, ); const env: Record = {}; @@ -746,17 +772,29 @@ describe("Pi skill snapshots", () => { describe("prepareLocalPiAssets (runtime_provided runs out of the mount, read-write)", () => { const subscriptionPlan = ( mount: string, - over: Record = {}, + over: { + credentials?: Record; + workspace?: Record; + prompt?: Record; + } = {}, ) => ({ isPi: true, isDaytona: false, - credentialMode: "runtime_provided", - skillDirs: [], - hasSystemPrompt: false, - systemPrompt: undefined, - appendSystemPrompt: undefined, - sourcePiAgentDir: mount, - ...over, + credentials: { + credentialMode: "runtime_provided", + ...over.credentials, + }, + workspace: { + skillDirs: [], + sourcePiAgentDir: mount, + ...over.workspace, + }, + prompt: { + hasSystemPrompt: false, + systemPrompt: undefined, + appendSystemPrompt: undefined, + ...over.prompt, + }, }); it("points PI_CODING_AGENT_DIR at the mount itself, not at a per-run copy", () => { @@ -783,9 +821,8 @@ describe("prepareLocalPiAssets (runtime_provided runs out of the mount, read-wri const { dir: runDir } = prepareLocalPiAssets({ plan: subscriptionPlan(mount, { - skillDirs: [], - hasSystemPrompt: true, - appendSystemPrompt: "extra", + workspace: { skillDirs: [] }, + prompt: { hasSystemPrompt: true, appendSystemPrompt: "extra" }, }) as never, env: {}, }); @@ -802,9 +839,8 @@ describe("prepareLocalPiAssets (runtime_provided runs out of the mount, read-wri const { dir: runDir } = prepareLocalPiAssets({ plan: subscriptionPlan(source, { - credentialMode: "env", - hasSystemPrompt: true, - appendSystemPrompt: "extra", + credentials: { credentialMode: "env" }, + prompt: { hasSystemPrompt: true, appendSystemPrompt: "extra" }, }) as never, env, }); @@ -854,8 +890,10 @@ describe("sandbox uploads", () => { writeFileSync(join(skill, "SKILL.md"), "skill", "utf-8"); const snapshot = resolvePiSkillSnapshot({ isPi: true, - cwd: "/workspace", - skillDirs: [{ name: "release-notes", dir: skill }], + workspace: { + cwd: "/workspace", + skillDirs: [{ name: "release-notes", dir: skill }], + }, }); assert.ok(snapshot); diff --git a/services/runner/tests/unit/sandbox-agent-qa-transcript-replay.test.ts b/services/runner/tests/unit/sandbox-agent-qa-transcript-replay.test.ts index 0efc00e938..2c26eedf0c 100644 --- a/services/runner/tests/unit/sandbox-agent-qa-transcript-replay.test.ts +++ b/services/runner/tests/unit/sandbox-agent-qa-transcript-replay.test.ts @@ -156,15 +156,15 @@ describe("runSandboxAgent replays real captured QA transcripts", () => { // The plan is where F-001 actually broke (`pi-assets.ts` never received the override): assert // directly on it, not on a downstream side effect that a fake session cannot reproduce. assert.equal( - calls.workspacePlan.appendSystemPrompt, + calls.workspacePlan.prompt.appendSystemPrompt, request.appendSystemPrompt, "buildRunPlan must carry the recorded append_system override through to the plan", ); - assert.equal(calls.workspacePlan.hasSystemPrompt, true); + assert.equal(calls.workspacePlan.prompt.hasSystemPrompt, true); // The session receives the plan's turnText (the orchestration's framing of the request), // not necessarily just the last message -- assert against it so multi-message captures hold. assert.deepEqual(calls.promptBlocks, [ - { type: "text", text: calls.workspacePlan.turnText }, + { type: "text", text: calls.workspacePlan.prompt.turnText }, ]); }); @@ -186,7 +186,7 @@ describe("runSandboxAgent replays real captured QA transcripts", () => { // `pi_agenta` drive the same "pi" ACP agent (see `run-plan.ts`'s `acpAgent` derivation). assert.equal(calls.createSessionOptions.agent, "pi"); assert.deepEqual(calls.promptBlocks, [ - { type: "text", text: calls.workspacePlan.turnText }, + { type: "text", text: calls.workspacePlan.prompt.turnText }, ]); // The recorded request's deprecated `tools` field is accepted and ignored: built-ins are // activated unconditionally, so replanning without it must reach the same gating decision. @@ -197,8 +197,8 @@ describe("runSandboxAgent replays real captured QA transcripts", () => { assert.equal(withoutTools.ok, true); if (withoutTools.ok) { assert.equal( - withoutTools.plan.builtinGatingActive, - calls.workspacePlan.builtinGatingActive, + withoutTools.plan.tools.builtinGatingActive, + calls.workspacePlan.tools.builtinGatingActive, ); } }, 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 a17bc30b6f..aed997de2e 100644 --- a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts +++ b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts @@ -78,7 +78,7 @@ describe("buildRunPlan", () => { ); assert.equal(result.ok, true); - assert.equal(result.ok && result.plan.prompt, ""); + assert.equal(result.ok && result.plan.prompt.text, ""); }); it("accepts both legacy image-only current user turn shapes", () => { @@ -94,7 +94,7 @@ describe("buildRunPlan", () => { ); assert.equal(result.ok, true); - assert.equal(result.ok && result.plan.prompt, ""); + assert.equal(result.ok && result.plan.prompt.text, ""); } }); @@ -215,9 +215,9 @@ describe("buildRunPlan", () => { ); assert.equal(result.ok, true); - assert.equal(result.ok && result.plan.prompt, ""); + assert.equal(result.ok && result.plan.prompt.text, ""); assert.ok( - result.ok && result.plan.turnText.includes("user APPROVED Write"), + result.ok && result.plan.prompt.turnText.includes("user APPROVED Write"), "the plan's turn text carries the approval-resume frame", ); }); @@ -355,33 +355,33 @@ describe("buildRunPlan", () => { assert.equal(result.plan.harness, "pi_agenta"); assert.equal(result.plan.acpAgent, "pi"); assert.equal(result.plan.sandboxId, "local"); - assert.equal(result.plan.cwd, "/tmp/local-cwd"); + assert.equal(result.plan.workspace.cwd, "/tmp/local-cwd"); // The relay dir + usage capture are ephemeral runner files kept OFF the (possibly geesefs) // cwd: an ephemeral sibling whose leaf is the cwd basename. - assert.ok(!result.plan.relayDir.startsWith(result.plan.cwd)); - assert.ok(result.plan.relayDir.endsWith("/agenta/relay/local-cwd")); + assert.ok(!result.plan.workspace.relayDir.startsWith(result.plan.workspace.cwd)); + assert.ok(result.plan.workspace.relayDir.endsWith("/agenta/relay/local-cwd")); assert.equal( - result.plan.usageOutPath, - `${result.plan.relayDir}/.agenta-usage.json`, + result.plan.workspace.usageOutPath, + `${result.plan.workspace.relayDir}/.agenta-usage.json`, ); - assert.equal(result.plan.prompt, " ship it "); - assert.equal(result.plan.agentsMd, "instructions"); - assert.equal(result.plan.systemPrompt, "system"); - assert.equal(result.plan.appendSystemPrompt, "append"); - assert.equal(result.plan.hasSystemPrompt, true); - assert.equal(result.plan.hasApiKey, true); - assert.deepEqual(result.plan.modelEnvironment, { OPENAI_API_KEY: "key" }); - assert.equal(result.plan.sourcePiAgentDir, "/tmp/pi-agent"); + assert.equal(result.plan.prompt.text, " ship it "); + assert.equal(result.plan.prompt.agentsMd, "instructions"); + assert.equal(result.plan.prompt.systemPrompt, "system"); + assert.equal(result.plan.prompt.appendSystemPrompt, "append"); + assert.equal(result.plan.prompt.hasSystemPrompt, true); + assert.equal(result.plan.credentials.hasApiKey, true); + assert.deepEqual(result.plan.credentials.modelEnvironment, { OPENAI_API_KEY: "key" }); + assert.equal(result.plan.workspace.sourcePiAgentDir, "/tmp/pi-agent"); assert.deepEqual( - result.plan.executableToolSpecs.map((tool) => tool.name), + result.plan.tools.executableToolSpecs.map((tool) => tool.name), ["server_tool"], ); assert.deepEqual( - result.plan.toolSpecs.map((tool) => tool.name), + result.plan.tools.toolSpecs.map((tool) => tool.name), ["server_tool", "client_tool"], ); - assert.equal(result.plan.useToolRelay, true); - assert.deepEqual(result.plan.skillDirs, [ + assert.equal(result.plan.tools.useToolRelay, true); + assert.deepEqual(result.plan.workspace.skillDirs, [ { name: "alpha", dir: "/skills/alpha" }, ]); assert.deepEqual(logs, ["resolved alpha", "skills: alpha"]); @@ -396,8 +396,8 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.deepEqual(result.plan.executableToolSpecs, []); - assert.equal(result.plan.useToolRelay, true); + assert.deepEqual(result.plan.tools.executableToolSpecs, []); + assert.equal(result.plan.tools.useToolRelay, true); }); it("leaves builtin gating off under a blanket allow with no builtin rules", () => { @@ -412,9 +412,9 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.builtinGatingActive, false); + assert.equal(result.plan.tools.builtinGatingActive, false); // Builtin gating rides the ACP dialog plane, not the relay: no custom tools, no relay. - assert.equal(result.plan.useToolRelay, false); + assert.equal(result.plan.tools.useToolRelay, false); }); it("turns builtin gating on under the default allow_reads mode", () => { @@ -431,7 +431,7 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.builtinGatingActive, true); + assert.equal(result.plan.tools.builtinGatingActive, true); }); it("turns builtin gating on when the permission kill switch is set", () => { @@ -448,8 +448,8 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.builtinGatingActive, true); - assert.equal(result.plan.useToolRelay, false); + assert.equal(result.plan.tools.builtinGatingActive, true); + assert.equal(result.plan.tools.useToolRelay, false); }); it("turns builtin gating on when an all-allow policy has a builtin rule", () => { @@ -467,7 +467,7 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.builtinGatingActive, true); + assert.equal(result.plan.tools.builtinGatingActive, true); }); it("carries the sandbox permission onto the plan and leaves an unrestricted run alone", () => { @@ -757,16 +757,16 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; assert.equal( - result.plan.toolMcpDir, + result.plan.workspace.toolMcpDir, "/home/sandbox/agenta/tool-mcp/agenta-fixed", ); - assert.notEqual(result.plan.toolMcpDir, result.plan.relayDir); + assert.notEqual(result.plan.workspace.toolMcpDir, result.plan.workspace.relayDir); assert.ok( - !result.plan.toolMcpDir.startsWith(`${result.plan.relayDir}/`), + !result.plan.workspace.toolMcpDir.startsWith(`${result.plan.workspace.relayDir}/`), "the shim dir is never nested inside the relay dir (the relay loop sweeps it)", ); assert.equal( - result.plan.useToolRelay, + result.plan.tools.useToolRelay, true, "the relay loop still starts (it executes the shim's requests)", ); @@ -887,14 +887,14 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; assert.deepEqual( - result.plan.toolSpecs.map((tool) => tool.name), + result.plan.tools.toolSpecs.map((tool) => tool.name), ["server_tool", "request_connection"], ); assert.deepEqual( - result.plan.executableToolSpecs.map((tool) => tool.name), + result.plan.tools.executableToolSpecs.map((tool) => tool.name), ["server_tool"], ); - assert.equal(result.plan.clientToolPauseDisposition, "cold-acknowledge"); + assert.equal(result.plan.tools.clientToolPauseDisposition, "cold-acknowledge"); }); it("allows claude x daytona x client-ONLY tools (the shim advertises them and the relay parks)", () => { @@ -917,17 +917,17 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; assert.deepEqual( - result.plan.toolSpecs.map((tool) => tool.name), + result.plan.tools.toolSpecs.map((tool) => tool.name), ["request_connection"], ); // No executable tool remains, but the run is no longer refused. - assert.deepEqual(result.plan.executableToolSpecs, []); + assert.deepEqual(result.plan.tools.executableToolSpecs, []); assert.equal( - result.plan.clientToolPauseDisposition, + result.plan.tools.clientToolPauseDisposition, "cold-acknowledge", "non-Pi shim path acknowledges a parked client tool with a paused answer", ); - assert.equal(result.plan.useToolRelay, true); + assert.equal(result.plan.tools.useToolRelay, true); }); it("allows pi x daytona x client-only tools (Pi's extension + file relay deliver them)", () => { @@ -944,7 +944,7 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; // Pi parks through its own extension, so its disposition is "pi-native" (no paused answer). - assert.equal(result.plan.clientToolPauseDisposition, "pi-native"); + assert.equal(result.plan.tools.clientToolPauseDisposition, "pi-native"); }); it("still refuses claude x daytona x executable tools under strict restricted network", () => { @@ -1115,7 +1115,7 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.deepEqual(result.plan.skillDirs, [ + assert.deepEqual(result.plan.workspace.skillDirs, [ { name: "alpha", dir: "/skills/alpha" }, { name: "beta", dir: "/skills/beta" }, ]); @@ -1176,19 +1176,19 @@ describe("buildRunPlan", () => { assert.equal(result.plan.acpAgent, "claude"); assert.equal(result.plan.isPi, false); assert.equal(result.plan.isDaytona, true); - assert.equal(result.plan.cwd, "/home/sandbox/agenta-fixed"); - assert.equal(result.plan.usageOutPath, undefined); - assert.equal(result.plan.harnessApiKeyVar, "ANTHROPIC_API_KEY"); + assert.equal(result.plan.workspace.cwd, "/home/sandbox/agenta-fixed"); + assert.equal(result.plan.workspace.usageOutPath, undefined); + assert.equal(result.plan.credentials.harnessApiKeyVar, "ANTHROPIC_API_KEY"); // The FULL materialized environment sets hasApiKey: on a Daytona Secrets run the opaque key // leaves the plaintext env for the secret plan, but the harness still receives its binding. - assert.equal(result.plan.hasApiKey, true); - assert.equal(result.plan.modelEnvironment.ANTHROPIC_API_KEY, undefined); - assert.equal(result.plan.daytonaSecretPlan?.candidates.length, 1); + assert.equal(result.plan.credentials.hasApiKey, true); + assert.equal(result.plan.credentials.modelEnvironment.ANTHROPIC_API_KEY, undefined); + assert.equal(result.plan.credentials.daytonaSecretPlan?.candidates.length, 1); // The resolved credentialMode is carried onto the plan (drives clear-then-apply). - assert.equal(result.plan.credentialMode, "env"); - assert.equal(result.plan.systemPrompt, undefined); - assert.equal(result.plan.hasSystemPrompt, false); - assert.deepEqual(result.plan.skillDirs, []); + assert.equal(result.plan.credentials.credentialMode, "env"); + assert.equal(result.plan.prompt.systemPrompt, undefined); + assert.equal(result.plan.prompt.hasSystemPrompt, false); + assert.deepEqual(result.plan.workspace.skillDirs, []); }); it("keeps a zero-candidate secret plan when the flag is on, and none when it is off", () => { @@ -1224,15 +1224,15 @@ describe("buildRunPlan", () => { const flagOn = buildRunPlan(localUseRequest, deps); assert.equal(flagOn.ok, true); if (!flagOn.ok) return; - assert.ok(flagOn.plan.daytonaSecretPlan, "flag on keeps the empty plan"); - assert.equal(flagOn.plan.daytonaSecretPlan.candidates.length, 0); + assert.ok(flagOn.plan.credentials.daytonaSecretPlan, "flag on keeps the empty plan"); + assert.equal(flagOn.plan.credentials.daytonaSecretPlan.candidates.length, 0); // local_use values still reach sandbox create as plaintext env (by design). assert.equal( - flagOn.plan.modelEnvironment.AWS_ACCESS_KEY_ID, + flagOn.plan.credentials.modelEnvironment.AWS_ACCESS_KEY_ID, "AKIA-local-use", ); assert.equal( - flagOn.plan.modelEnvironment.AWS_SECRET_ACCESS_KEY, + flagOn.plan.credentials.modelEnvironment.AWS_SECRET_ACCESS_KEY, "aws-secret-local-use", ); @@ -1241,9 +1241,9 @@ describe("buildRunPlan", () => { const flagOff = buildRunPlan(localUseRequest, deps); assert.equal(flagOff.ok, true); if (!flagOff.ok) return; - assert.equal(flagOff.plan.daytonaSecretPlan, undefined); + assert.equal(flagOff.plan.credentials.daytonaSecretPlan, undefined); assert.equal( - flagOff.plan.modelEnvironment.AWS_ACCESS_KEY_ID, + flagOff.plan.credentials.modelEnvironment.AWS_ACCESS_KEY_ID, "AKIA-local-use", ); }); @@ -1278,9 +1278,9 @@ describe("buildRunPlan", () => { assert.equal(result.plan.acpAgent, "codex"); assert.equal(result.plan.isPi, false); assert.equal(result.plan.isDaytona, false); - assert.equal(result.plan.harnessApiKeyVar, "OPENAI_API_KEY"); - assert.equal(result.plan.hasApiKey, true); - assert.equal(result.plan.credentialMode, "env"); + assert.equal(result.plan.credentials.harnessApiKeyVar, "OPENAI_API_KEY"); + assert.equal(result.plan.credentials.hasApiKey, true); + assert.equal(result.plan.credentials.credentialMode, "env"); }); }); @@ -1305,10 +1305,10 @@ describe("buildRunPlan durableCwd (prefix-derived cwd)", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.cwd, "/tmp/agenta/mounts/proj-1/mount-abc"); + assert.equal(result.plan.workspace.cwd, "/tmp/agenta/mounts/proj-1/mount-abc"); // Relay dir is an ephemeral sibling (leaf = cwd basename), NOT inside the durable mount. - assert.ok(!result.plan.relayDir.startsWith(result.plan.cwd)); - assert.ok(result.plan.relayDir.endsWith("/agenta/relay/mount-abc")); + assert.ok(!result.plan.workspace.relayDir.startsWith(result.plan.workspace.cwd)); + assert.ok(result.plan.workspace.relayDir.endsWith("/agenta/relay/mount-abc")); // createLocalCwd received the durableCwd value. assert.deepEqual(localCwdCalls, ["/tmp/agenta/mounts/proj-1/mount-abc"]); }); @@ -1333,7 +1333,7 @@ describe("buildRunPlan durableCwd (prefix-derived cwd)", () => { assert.equal(result.ok, true); if (!result.ok) return; assert.equal( - result.plan.cwd, + result.plan.workspace.cwd, "/home/sandbox/agenta/mounts/proj-1/mount-abc", ); assert.deepEqual(daytonaCwdCalls, [ @@ -1360,7 +1360,7 @@ describe("buildRunPlan durableCwd (prefix-derived cwd)", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.cwd, "/tmp/agenta-sandbox-agent-ephemeral"); + assert.equal(result.plan.workspace.cwd, "/tmp/agenta-sandbox-agent-ephemeral"); assert.deepEqual(localCwdCalls, [undefined]); }); @@ -1389,8 +1389,8 @@ describe("buildRunPlan durableCwd (prefix-derived cwd)", () => { assert.equal(r2.ok, true); if (!r1.ok || !r2.ok) return; // Same prefix -> same cwd across turns. - assert.equal(r1.plan.cwd, r2.plan.cwd); - assert.equal(r1.plan.cwd, localPath); + assert.equal(r1.plan.workspace.cwd, r2.plan.workspace.cwd); + assert.equal(r1.plan.workspace.cwd, localPath); }); }); @@ -1478,7 +1478,7 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.credentialMode, "runtime_provided"); + assert.equal(result.plan.credentials.credentialMode, "runtime_provided"); assert.equal(result.plan.acpAgent, "codex"); }); }); @@ -1564,8 +1564,8 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { }); assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.credentialMode, "runtime_provided"); - assert.equal(result.plan.sourcePiAgentDir, "/agenta/harness/pi"); + assert.equal(result.plan.credentials.credentialMode, "runtime_provided"); + assert.equal(result.plan.workspace.sourcePiAgentDir, "/agenta/harness/pi"); }); }); @@ -1704,7 +1704,7 @@ describe("modelConnection validation", () => { } as AgentRunRequest); assert.equal(result.ok, true); if (!result.ok) return; - assert.deepEqual(result.plan.modelEnvironment, { + assert.deepEqual(result.plan.credentials.modelEnvironment, { AWS_REGION: "us-east-1", AWS_ACCESS_KEY_ID: "AKIA", }); @@ -1732,7 +1732,7 @@ describe("modelConnection validation", () => { assert.equal(result.ok, true); if (!result.ok) return; assert.equal( - result.plan.modelEnvironment.GOOGLE_APPLICATION_CREDENTIALS, + result.plan.credentials.modelEnvironment.GOOGLE_APPLICATION_CREDENTIALS, "/tmp/adc.json", ); }); diff --git a/services/runner/tests/unit/sandbox-agent-workspace.test.ts b/services/runner/tests/unit/sandbox-agent-workspace.test.ts index 8caac377ed..bf96cf78b6 100644 --- a/services/runner/tests/unit/sandbox-agent-workspace.test.ts +++ b/services/runner/tests/unit/sandbox-agent-workspace.test.ts @@ -42,13 +42,19 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - skillDirs: [], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -68,13 +74,19 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: true, - agentsMd: "agent instructions", acpAgent: "pi", isPi: true, - skillDirs: [], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + skillDirs: [], + }, + tools: { + useToolRelay: true, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -102,13 +114,19 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir, - useToolRelay: true, - agentsMd: "agent instructions", acpAgent: "pi", isPi: true, - skillDirs: [], + workspace: { + cwd, + relayDir, + skillDirs: [], + }, + tools: { + useToolRelay: true, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -138,13 +156,19 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: true, - agentsMd: "agent instructions", acpAgent: "pi", isPi: true, - skillDirs: [], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + skillDirs: [], + }, + tools: { + useToolRelay: true, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -178,13 +202,19 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - skillDirs: [], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -218,14 +248,20 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - harnessFiles: [{ path: ".claude/settings.json", content }], - skillDirs: [], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + harnessFiles: [{ path: ".claude/settings.json", content }], + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -244,13 +280,19 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - skillDirs: [], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -271,13 +313,19 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: true, - agentsMd: "agent instructions", acpAgent: "pi", isPi: true, - skillDirs: [], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + skillDirs: [], + }, + tools: { + useToolRelay: true, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); await workspace.cleanup(); @@ -307,13 +355,19 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - skillDirs: [], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -352,14 +406,20 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - harnessFiles: [{ path: ".claude/settings.json", content }], - skillDirs: [], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + harnessFiles: [{ path: ".claude/settings.json", content }], + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -393,13 +453,19 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "pi", isPi: true, - skillDirs: [], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -419,12 +485,17 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: false, acpAgent: "claude", isPi: false, - skillDirs: [{ name: "release-notes", dir: skillDir }], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + skillDirs: [{ name: "release-notes", dir: skillDir }], + }, + tools: { + useToolRelay: false, + }, + prompt: {}, }, }); @@ -453,12 +524,17 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: false, acpAgent: "claude", isPi: false, - skillDirs: [{ name: "release-notes", dir: skillDir }], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + skillDirs: [{ name: "release-notes", dir: skillDir }], + }, + tools: { + useToolRelay: false, + }, + prompt: {}, }, }); From a6879d80e62fe19b98af46a3ff634fb984e1d310 Mon Sep 17 00:00:00 2001 From: Mahmoud Mabrouk Date: Mon, 3 Aug 2026 20:15:01 +0200 Subject: [PATCH 11/11] refactor(runner): rewrap comments the field rename overran --- .../engines/sandbox_agent/environment-setup.ts | 11 ++++++++--- .../runner/src/engines/sandbox_agent/run-turn.ts | 16 +++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/services/runner/src/engines/sandbox_agent/environment-setup.ts b/services/runner/src/engines/sandbox_agent/environment-setup.ts index cc8d754041..765f545f06 100644 --- a/services/runner/src/engines/sandbox_agent/environment-setup.ts +++ b/services/runner/src/engines/sandbox_agent/environment-setup.ts @@ -167,7 +167,8 @@ export async function prepareEnvironmentSetup( // Clear-then-apply (Security rule 5): on a managed run (credentialMode "env") the daemon // inherits NONE of the sidecar's own provider keys, so only the resolved - // `plan.credentials.modelEnvironment` is present and an inherited key for another provider cannot leak. + // `plan.credentials.modelEnvironment` is present and an inherited key for another + // provider cannot leak. // "none" asserts NO credential (connections/models.py), so it clears too — otherwise the // daemon would inherit the declared provider's keys (e.g. OPENAI_API_KEY) from the sidecar. // Only runtime_provided keeps the inherited keys: the harness uses its own login there. @@ -191,7 +192,9 @@ export async function prepareEnvironmentSetup( // local Pi's OTLP bearer rides a runner-written 0600 file, never a plain env var — // Daytona never receives telemetry env here at all (`!plan.isDaytona` gates it off above). const otlpAuthFilePath = - plan.isPi && !plan.isDaytona ? `${plan.workspace.relayDir}.otlp-auth` : undefined; + plan.isPi && !plan.isDaytona + ? `${plan.workspace.relayDir}.otlp-auth` + : undefined; const otlpAuthorization = request.telemetry?.exporters?.otlp?.headers?.authorization; if (otlpAuthFilePath && otlpAuthorization) { @@ -321,7 +324,9 @@ export async function prepareEnvironmentSetup( // lifecycle, exactly like a normal local install (interface.md section 6). buildRunPlan already // rejected a runtime_provided Claude run with no configured CLAUDE_CONFIG_DIR. - logger(`harness=${plan.harness} sandbox=${plan.sandboxId} cwd=${plan.workspace.cwd}`); + logger( + `harness=${plan.harness} sandbox=${plan.sandboxId} cwd=${plan.workspace.cwd}`, + ); // The resolved model ref as it reaches the runner (key NAMES only, never values) — the one // line that answers "what model/provider/deployment/credential did this run actually use". diff --git a/services/runner/src/engines/sandbox_agent/run-turn.ts b/services/runner/src/engines/sandbox_agent/run-turn.ts index 513d9f5c84..893d46a9fe 100644 --- a/services/runner/src/engines/sandbox_agent/run-turn.ts +++ b/services/runner/src/engines/sandbox_agent/run-turn.ts @@ -86,7 +86,8 @@ import { resolveRunUsage } from "./usage.ts"; * controller / decisions / responder into `env.currentTurn`, restart the tool relay, * send the prompt, resolve usage, and finish + flush the trace. It does NOT tear down the * environment (the caller owns `env.destroy`). On a continuation the prompt is only the new user - * text (`buildTurnText` does not run); on a cold turn it is `plan.prompt.turnText`, exactly as before. + * text (`buildTurnText` does not run); on a cold turn it is `plan.prompt.turnText`, + * exactly as before. */ export async function runTurn( env: SessionEnvironment, @@ -227,10 +228,10 @@ export async function runTurn( : currentUserTurn(request).text; // Cold: replay the full transcript. Continuation or loaded: send only new text. When history // was rebuilt from records, recompute the transcript from it — the prebuilt - // plan.prompt.turnText - // predates the reconstruction. An approval reply has no new text either way, so it sends the - // approval-resume frame `buildTurnText` renders (the harness already holds the prior turns - // when the session was loaded natively; otherwise the rebuilt transcript comes with it). + // plan.prompt.turnText predates the reconstruction. An approval reply has no new text + // either way, so it sends the approval-resume frame `buildTurnText` renders (the + // harness already holds the prior turns when the session was loaded natively; + // otherwise the rebuilt transcript comes with it). const turnText = sendLastMessageOnly(opts) ? approvalReplyOnly ? buildTurnText(inboundRequest, logger) @@ -1044,8 +1045,9 @@ export async function runTurn( !plan.isDaytona && !run.output().trim() && !run.events().some((e) => e.type === "tool_call") - ? // The helper derives the transcript location from `piSessionWorkspaceDir(plan.workspace.cwd)`, - // the same shared helper `configurePiSessionWorkspace` used to point Pi at it. + ? // The helper derives the transcript location from + // `piSessionWorkspaceDir(plan.workspace.cwd)`, the same shared helper + // `configurePiSessionWorkspace` used to point Pi at it. findSwallowedPiError(plan.workspace.cwd) : undefined; let swallowedError: string | undefined;