diff --git a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py index f641a994e1..30ae824960 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: HarnessType, - 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 9f1e192cc4..619466ee77 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's cloud deployments (azure/bedrock/vertex) are *declared* but Pi *consumption* of them stages with the @@ -58,9 +58,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 7db5d9716b..341a552060 100644 --- a/sdks/python/agenta/sdk/agents/connections/__init__.py +++ b/sdks/python/agenta/sdk/agents/connections/__init__.py @@ -11,6 +11,8 @@ AmbiguousConnectionError, ConnectionNotFoundError, ConnectionResolutionError, + InvalidConnectionConfigurationError, + MissingCredentialError, MissingProviderError, ProviderMismatchError, UnsupportedConnectionModeError, @@ -21,10 +23,13 @@ from .models import ( Connection, CredentialMode, + CredentialUsage, Deployment, Endpoint, + EnvironmentCredentialBinding, ModelRef, ResolvedConnection, + ResolvedCredential, RuntimeAuthContext, ) from .resolver import EnvConnectionResolver, StaticConnectionResolver @@ -33,10 +38,13 @@ # Contracts "Connection", "Endpoint", + "EnvironmentCredentialBinding", "ModelRef", "ResolvedConnection", + "ResolvedCredential", "RuntimeAuthContext", "CredentialMode", + "CredentialUsage", "Deployment", # Port + adapters "ConnectionResolver", @@ -45,7 +53,9 @@ # Errors "AgentConnectionError", "ConnectionResolutionError", + "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..aeb6d99aea --- /dev/null +++ b/sdks/python/agenta/sdk/agents/connections/endpoints.py @@ -0,0 +1,142 @@ +"""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, +) -> 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, + ) diff --git a/sdks/python/agenta/sdk/agents/connections/errors.py b/sdks/python/agenta/sdk/agents/connections/errors.py index 2d01f0888e..fcd4977acb 100644 --- a/sdks/python/agenta/sdk/agents/connections/errors.py +++ b/sdks/python/agenta/sdk/agents/connections/errors.py @@ -25,6 +25,25 @@ class ConnectionResolutionError(AgentConnectionError): """Raised when a connection cannot be resolved into a credential plan.""" +class MissingCredentialError(ConnectionResolutionError): + """Raised when an Agenta-managed connection has no usable credential.""" + + 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.""" + + 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 570aae6386..d63844365b 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 @@ -16,7 +15,8 @@ from __future__ import annotations -from typing import Any, Dict, Literal, Optional +from typing import Any, Dict, List, Literal, Optional +from urllib.parse import urlparse from uuid import UUID from pydantic import BaseModel, Field, 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,43 @@ 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 + + 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,41 +198,58 @@ def to_model_string(self) -> str: class ResolvedConnection(BaseModel): - """The least-privilege output a :class:`ConnectionResolver` returns for one run. - - ``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. + """Resolved route and credentials for one model consumer. - Serialization safety: ``env`` is masked from ``repr``/``str`` but NOT from - ``model_dump()``/``model_dump_json()``. Use :meth:`to_wire` (which never emits ``env``) for - anything that reaches a trace, a log, or an echoed payload. Never log a raw dump of a - ``ResolvedConnection`` or a ``SessionConfig`` that carries one. + 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. """ 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 - endpoint: Optional[Endpoint] = None # NON-secret connection config only + credentials: List[ResolvedCredential] = Field(default_factory=list, repr=False) + environment: Dict[str, str] = Field(default_factory=dict) + endpoint: Optional[Endpoint] = None - def to_wire(self) -> Dict[str, Any]: - """The NON-secret camelCase fields for the wire. Never emits ``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 - ``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. - """ + 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]: + """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 fbb56efdd3..b4270732dc 100644 --- a/sdks/python/agenta/sdk/agents/connections/resolver.py +++ b/sdks/python/agenta/sdk/agents/connections/resolver.py @@ -18,7 +18,8 @@ from typing import Any, Dict, Optional from ..capabilities import PROVIDER_ENV_VARS -from .errors import UnsupportedProviderError +from .endpoints import build_resolved_connection +from .errors import MissingCredentialError, UnsupportedProviderError from .models import ( Endpoint, ModelRef, @@ -38,8 +39,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. """ @@ -55,11 +56,11 @@ async def resolve( context: RuntimeAuthContext, ) -> ResolvedConnection: if model.connection.mode == "self_managed": - return ResolvedConnection( + return build_resolved_connection( provider=model.provider or "", model=model.model, credential_mode="runtime_provided", - env={}, + values={}, ) provider = model.provider @@ -72,18 +73,15 @@ 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}, ) - # 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={}, + slug=model.connection.slug, ) @@ -128,17 +126,30 @@ 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={}, + ) 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, ) diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index 455e120f99..fa8b58d166 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -694,17 +694,12 @@ class HarnessAgentTemplate(BaseModel): harness: ClassVar[HarnessType] 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) @@ -795,48 +790,14 @@ 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. - - 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. - """ - 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.""" + def wire_model_connection(self) -> Dict[str, Any]: + """The resolved model route and credentials, grouped under their consumer.""" if self.resolved_connection is None: return {} - return self.resolved_connection.to_wire() + return { + "model": self.resolved_connection.model, + "modelConnection": self.resolved_connection.to_wire(), + } class PiAgentTemplate(HarnessAgentTemplate): @@ -854,6 +815,23 @@ class PiAgentTemplate(HarnessAgentTemplate): harness: ClassVar[HarnessType] = HarnessType.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 + builtin_names: List[str] = Field( default_factory=list, validation_alias=AliasChoices("builtin_names", "builtin_tools"), @@ -969,8 +947,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 ``builtin_tools`` / ``custom_tools`` / + ``agent`` is the agent definition. Model routing and credentials are carried by + ``resolved_connection``. The ``builtin_tools`` / ``custom_tools`` / ``tool_callback`` triple is the resolved tool delivery (Agenta produces it server-side; empty for a bare standalone run). The agent config's ``sandbox`` field is a backend/environment concern: the caller reads it to pick a backend BEFORE the session is @@ -979,10 +957,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 a522dceeaf..a9a39acb71 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, degradation policy, and MCP gating: +default composition also owns fail-closed capability/connection gating and MCP 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. """ @@ -21,8 +21,6 @@ harness_allows_provider, ) from agenta.sdk.agents.connections import ( - ConnectionResolutionError, - MissingProviderError, ModelRef, ResolvedConnection, RuntimeAuthContext, @@ -50,6 +48,7 @@ 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 from agenta.sdk.models.workflows import ( WorkflowInvokeRequestFlags, WorkflowServiceRequest, @@ -165,50 +164,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 @@ -277,7 +240,6 @@ 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 @@ -290,7 +252,24 @@ async def _agent( ) ) resolved_connection = await resolve_session_connection(model_ref, ctx) - secrets = resolved_connection.env + + # Seed immediately after trusted resolution and before transport, trace, event, error, or + # result sinks can observe an echoed credential. The ambient redactor is task-local. + get_active_redactor().with_known_secrets( + [ + *( + 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. @@ -303,7 +282,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(), @@ -345,7 +323,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 @@ -382,7 +362,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 28b7ddd36c..22e6ebd505 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 5a8de94d7e..f326ae3f12 100644 --- a/sdks/python/agenta/sdk/agents/mcp/__init__.py +++ b/sdks/python/agenta/sdk/agents/mcp/__init__.py @@ -7,13 +7,20 @@ MissingMCPSecretError, ) from .interfaces import MCPSecretProvider -from .models import MCPServerConfig, ResolvedMCPServer +from .models import ( + HeaderCredentialBinding, + MCPServerConfig, + ResolvedMCPCredential, + ResolvedMCPServer, +) from .parsing import parse_mcp_server_config, parse_mcp_server_configs from .resolver import MCPResolver from .wire import mcp_server_to_wire, mcp_servers_to_wire __all__ = [ "MCPServerConfig", + "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 327278b01c..6d37153302 100644 --- a/sdks/python/agenta/sdk/agents/mcp/models.py +++ b/sdks/python/agenta/sdk/agents/mcp/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any, Dict, List, Literal, Optional +from urllib.parse import urlparse from pydantic import BaseModel, ConfigDict, Field, model_validator @@ -34,8 +35,10 @@ class MCPServerConfig(BaseModel): transport: Literal["stdio", "http"] = "stdio" command: Optional[str] = None args: List[str] = Field(default_factory=list) - env: Dict[str, str] = Field(default_factory=dict, repr=False) + env: Dict[str, str] = Field(default_factory=dict) url: Optional[str] = None + headers: Dict[str, str] = Field(default_factory=dict) + # HTTP header name -> named secret reference. References never cross the runner wire. secrets: Dict[str, str] = Field(default_factory=dict) tools: List[str] = Field(default_factory=list) permission: Optional[Permission] = None @@ -49,11 +52,48 @@ def _ignore_legacy_permission_keys(cls, data: Any) -> Any: def _validate_transport(self) -> "MCPServerConfig": if self.transport == "stdio" and not self.command: raise ValueError("stdio MCP server requires command") - if self.transport == "http" and not self.url: - raise ValueError("http MCP server requires url") + for name, value in {**self.env, **self.headers, **self.secrets}.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 self.secrets + }: + raise ValueError("HTTP MCP public and credential headers must be unique") + if self.transport == "http": + if not self.url: + raise ValueError("http MCP server requires url") + parsed = urlparse(self.url) + if parsed.scheme.lower() != "https" or not parsed.hostname: + raise ValueError("http MCP server requires an absolute HTTPS url") + if self.env: + raise ValueError( + "http MCP server environment is invalid; use headers for public values" + ) + elif self.headers or self.secrets: + raise ValueError( + "stdio MCP header credentials are unsupported; use an HTTP MCP server" + ) return self +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" + + def to_wire(self) -> Dict[str, Any]: + return { + "binding": self.binding.model_dump(), + "value": self.value, + "usage": self.usage, + } + + class ResolvedMCPServer(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) @@ -61,8 +101,10 @@ class ResolvedMCPServer(BaseModel): transport: Literal["stdio", "http"] = "stdio" command: Optional[str] = None args: List[str] = Field(default_factory=list) - env: Dict[str, str] = Field(default_factory=dict, repr=False) + environment: Dict[str, str] = Field(default_factory=dict) url: Optional[str] = None + headers: Dict[str, str] = Field(default_factory=dict) + credentials: List[ResolvedMCPCredential] = Field(default_factory=list, repr=False) tools: List[str] = Field(default_factory=list) permission: Optional[Permission] = None @@ -70,8 +112,25 @@ class ResolvedMCPServer(BaseModel): def _validate_transport(self) -> "ResolvedMCPServer": if self.transport == "stdio" and not self.command: raise ValueError("stdio MCP server requires command") - if self.transport == "http" and not self.url: - raise ValueError("http MCP server requires url") + for name, value in {**self.environment, **self.headers}.items(): + if not name.strip() or not value: + raise ValueError("MCP bindings require non-empty names and values") + if self.transport == "http": + if not self.url: + raise ValueError("http MCP server requires url") + parsed = urlparse(self.url) + if parsed.scheme.lower() != "https" or not parsed.hostname: + raise ValueError("http MCP server requires an absolute HTTPS url") + if self.environment: + raise ValueError("http MCP server cannot carry process environment") + 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") + elif self.headers or self.credentials: + raise ValueError("stdio MCP server cannot carry HTTP headers") return self def to_wire(self) -> Dict[str, Any]: @@ -83,8 +142,12 @@ def to_wire(self) -> Dict[str, Any]: wire["command"] = self.command if self.args: wire["args"] = list(self.args) - if self.env: - wire["env"] = dict(self.env) + if self.environment: + wire["environment"] = dict(self.environment) + if self.headers: + wire["headers"] = dict(self.headers) + if self.credentials: + wire["credentials"] = [item.to_wire() for item in self.credentials] if self.url: wire["url"] = self.url if self.tools: diff --git a/sdks/python/agenta/sdk/agents/mcp/resolver.py b/sdks/python/agenta/sdk/agents/mcp/resolver.py index 1593f9de8d..e89ee78dbe 100644 --- a/sdks/python/agenta/sdk/agents/mcp/resolver.py +++ b/sdks/python/agenta/sdk/agents/mcp/resolver.py @@ -5,8 +5,9 @@ from typing import Mapping, Sequence from agenta.sdk.agents.tools.models import MissingSecretPolicy +from agenta.sdk.utils.net import assert_endpoint_url_allowed -from .errors import MissingMCPSecretError +from .errors import MCPConfigurationError, MissingMCPSecretError from .interfaces import MCPSecretProvider from .models import MCPServerConfig, ResolvedMCPServer @@ -41,7 +42,7 @@ async def resolve( missing = [ secret_name for secret_name in server_config.secrets.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( @@ -49,10 +50,23 @@ async def resolve( secret_names=missing, ) - env = dict(server_config.env) - for env_var, secret_name in server_config.secrets.items(): - if secret_name in secret_values: - env[env_var] = secret_values[secret_name] + if server_config.transport == "http": + try: + assert_endpoint_url_allowed(server_config.url or "") + except ValueError as exc: + raise MCPConfigurationError( + f"HTTP MCP server '{server_config.name}' has an unsafe url: {exc}" + ) from exc + + credentials = [ + { + "binding": {"kind": "header", "name": header_name}, + "value": secret_values[secret_name], + "usage": "opaque_http", + } + for header_name, secret_name in server_config.secrets.items() + if secret_values.get(secret_name) + ] resolved.append( ResolvedMCPServer( @@ -60,8 +74,10 @@ async def resolve( transport=server_config.transport, command=server_config.command, args=list(server_config.args), - env=env, + environment=dict(server_config.env), url=server_config.url, + headers=dict(server_config.headers), + credentials=credentials, tools=list(server_config.tools), permission=server_config.permission, ) diff --git a/sdks/python/agenta/sdk/agents/platform/connections.py b/sdks/python/agenta/sdk/agents/platform/connections.py index 09c131010b..920b6204e4 100644 --- a/sdks/python/agenta/sdk/agents/platform/connections.py +++ b/sdks/python/agenta/sdk/agents/platform/connections.py @@ -24,11 +24,13 @@ HARNESS_CONNECTION_CAPABILITIES, PROVIDER_ENV_VARS, ) +from ..connections.endpoints import build_resolved_connection from ..connections import ( AmbiguousConnectionError, ConnectionNotFoundError, ConnectionResolutionError, Endpoint, + MissingCredentialError, MissingProviderError, ModelRef, ProviderMismatchError, @@ -451,11 +453,11 @@ def _resolve_from_secrets( if inferred: model = model.model_copy(update={"provider": inferred}) if connection.mode == "self_managed": - return ResolvedConnection( + return build_resolved_connection( provider=model.provider or "", model=model.model, credential_mode="runtime_provided", - env={}, + values={}, ) if connection.mode != "agenta": raise UnsupportedConnectionModeError(mode=str(connection.mode)) @@ -469,12 +471,14 @@ def _resolve_from_secrets( ) provider = chosen.resolved_provider(model) env = chosen.resolved_env(provider) - return ResolvedConnection( + if not env: + raise MissingCredentialError(provider=provider, slug=chosen.slug) + return build_resolved_connection( provider=provider, model=chosen.selected_model_id(model), deployment=chosen.deployment, - credential_mode="env" if env else "runtime_provided", - env=env, + credential_mode="env", + values=env, endpoint=chosen.endpoint, ) diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index da7c957d46..d22abacf9c 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -85,7 +85,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, @@ -103,15 +102,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 @@ -131,7 +124,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 @@ -144,8 +136,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: @@ -166,6 +157,7 @@ def result_from_wire(data: Dict[str, Any]) -> AgentResult: clear message rather than handing the model 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 RuntimeError( f"Agent run failed: {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 60ce74d5fc..c8fad35456 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.""" - mode: Literal["agenta", "self_managed"] = "agenta" - slug: Optional[str] = None + 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"] + + +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 WireContentBlock(_WireModel): @@ -284,16 +305,31 @@ 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 WireMcpServer(_WireModel): - """A user-declared MCP server (stdio or http), mirrors ``mcp_servers_to_wire``.""" + """A resolved MCP server, mirrors ``ResolvedMCPServer.to_wire``.""" + + # Retired mixed secret metadata must be dropped rather than preserved as an extra. + model_config = ConfigDict(populate_by_name=True, extra="ignore") name: str transport: Optional[str] = None command: Optional[str] = None args: Optional[List[str]] = None - env: Optional[Dict[str, str]] = None + environment: Optional[Dict[str, str]] = None url: Optional[str] = None headers: Optional[Dict[str, str]] = None + credentials: Optional[List[WireMcpCredential]] = None tools: Optional[List[str]] = None permission: Optional[str] = None @@ -414,18 +450,13 @@ 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" + ) # 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 4d9ae6377f..a35d55900f 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=HarnessType.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=HarnessType.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 0591025149..79e2624a83 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 @@ -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"}, } @@ -162,14 +172,131 @@ def test_resolved_connection_to_wire_omits_endpoint_when_absent(): ) wire = resolved.to_wire() assert "endpoint" 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) + + +@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 10ebaaef03..d0475db901 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"] async def test_env_resolver_reads_the_live_process_env(monkeypatch): @@ -48,19 +55,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(): @@ -75,7 +79,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(): @@ -94,7 +98,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" @@ -103,26 +107,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(): @@ -133,5 +150,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.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 d169cbd59a..4a1d515545 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,48 @@ } } }, - "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", "write"], + "tools": [ + "read", + "write" + ], "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 +81,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 +92,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 7cb093fb13..de07255593 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 @@ -6,6 +6,7 @@ from pydantic import ValidationError from agenta.sdk.agents.mcp import ( + MCPConfigurationError, MCPResolver, MCPServerConfig, MissingMCPSecretError, @@ -21,47 +22,102 @@ async def get_many(self, names: Sequence[str]) -> Mapping[str, str]: return {name: self.values[name] for name in names if name in self.values} -def test_transport_specific_fields_are_required(): +def test_transport_specific_fields_and_roles_are_validated(): with pytest.raises(ValidationError, match="requires command"): MCPServerConfig(name="stdio") with pytest.raises(ValidationError, match="requires url"): MCPServerConfig(name="remote", transport="http") + with pytest.raises(ValidationError, match="absolute HTTPS"): + MCPServerConfig(name="remote", transport="http", url="http://example.com") + with pytest.raises(ValidationError, match="environment is invalid"): + MCPServerConfig( + name="remote", + transport="http", + url="https://example.com", + env={"LOG": "info"}, + ) + with pytest.raises(ValidationError, match="header credentials are unsupported"): + MCPServerConfig(name="stdio", command="npx", secrets={"Authorization": "token"}) + + +async def test_resolves_stdio_non_secret_process_environment(): + servers = await MCPResolver(secret_provider=DictSecretProvider({})).resolve( + [MCPServerConfig(name="github", command="npx", env={"LOG": "info"})] + ) + assert servers[0].to_wire()["environment"] == {"LOG": "info"} + assert "credentials" not in servers[0].to_wire() -async def test_resolves_mcp_environment_in_sibling_subsystem(): +async def test_resolves_http_public_headers_and_typed_secret_credentials(): servers = await MCPResolver( - secret_provider=DictSecretProvider({"github_pat": "ghp"}) + secret_provider=DictSecretProvider({"linear_token": "Bearer secret-value"}) ).resolve( [ MCPServerConfig( - name="github", - command="npx", - env={"LOG": "info"}, - secrets={"GITHUB_TOKEN": "github_pat"}, + name="linear", + transport="http", + url="https://93.184.216.34:8443/mcp", + headers={"X-Client": "agenta"}, + secrets={"Authorization": "linear_token"}, ) ] ) - assert servers[0].to_wire()["env"] == { - "LOG": "info", - "GITHUB_TOKEN": "ghp", + assert servers[0].to_wire() == { + "name": "linear", + "transport": "http", + "url": "https://93.184.216.34:8443/mcp", + "headers": {"X-Client": "agenta"}, + "credentials": [ + { + "binding": {"kind": "header", "name": "Authorization"}, + "value": "Bearer secret-value", + "usage": "opaque_http", + } + ], } + assert "secret-value" not in repr(servers[0]) -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( [ MCPServerConfig( - name="github", - command="npx", - secrets={"GITHUB_TOKEN": "missing"}, + name="linear", + transport="http", + url="https://93.184.216.34/mcp", + secrets={"Authorization": "missing"}, + ) + ] + ) + + +async def test_empty_secret_value_is_treated_as_missing(): + with pytest.raises(MissingMCPSecretError): + await MCPResolver(secret_provider=DictSecretProvider({"token": ""})).resolve( + [ + MCPServerConfig( + name="linear", + transport="http", + url="https://93.184.216.34/mcp", + secrets={"Authorization": "token"}, + ) + ] + ) + + +async def test_unsafe_http_mcp_url_is_a_configuration_error(): + with pytest.raises(MCPConfigurationError, match="unsafe url"): + await MCPResolver(secret_provider=DictSecretProvider({})).resolve( + [ + MCPServerConfig( + name="internal", transport="http", url="https://127.0.0.1/mcp" ) ] ) async def test_permission_rides_the_wire_when_set(): - # An author's per-server permission is carried onto the resolved server and serialized. servers = await MCPResolver(secret_provider=DictSecretProvider({})).resolve( [MCPServerConfig(name="github", command="npx", permission="ask")] ) @@ -70,7 +126,6 @@ async def test_permission_rides_the_wire_when_set(): async def test_permission_absent_from_wire_when_unset(): - # No permission declared -> no `permission` key (a server has no read_only to default from). servers = await MCPResolver(secret_provider=DictSecretProvider({})).resolve( [MCPServerConfig(name="github", command="npx")] ) @@ -85,17 +140,18 @@ def test_legacy_permission_mode_alias_is_ignored(): assert config.permission is None -async def test_mcp_compatibility_policy_can_omit_missing_secret(): +async def test_mcp_compatibility_policy_can_omit_missing_http_secret(): servers = await MCPResolver( secret_provider=DictSecretProvider({}), missing_secret_policy=MissingSecretPolicy.OMIT, ).resolve( [ MCPServerConfig( - name="github", - command="npx", - secrets={"GITHUB_TOKEN": "missing"}, + name="linear", + transport="http", + url="https://93.184.216.34/mcp", + secrets={"Authorization": "missing"}, ) ] ) - assert "env" not in servers[0].to_wire() + assert "credentials" not in servers[0].to_wire() 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 bb574075ca..44a8273edc 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 capture["method"] == "GET" assert capture["url"] == "https://api.x/api/secrets/" assert capture["headers"]["Authorization"] == "Access tok" @@ -99,7 +105,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) == {} async def test_default_connection_requires_unique_provider_match(fake_http, connection): @@ -107,7 +113,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): @@ -146,7 +160,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( @@ -182,7 +196,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 + ) async def test_bare_claude_dated_id_resolves_to_anthropic(fake_http, connection): @@ -304,15 +320,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, @@ -334,11 +383,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): @@ -360,7 +436,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" @@ -377,11 +453,11 @@ async def test_custom_provider_private_url_is_dropped_not_pinned(fake_http, conn ) ], ) - resolved = await VaultConnectionResolver(connection).resolve( - model=_model("internal-gw", provider="anthropic", model="gpt-5.5"), - context=RuntimeAuthContext(harness="claude"), - ) - assert resolved.endpoint is None + with pytest.raises(InvalidConnectionConfigurationError): + await VaultConnectionResolver(connection).resolve( + model=_model("internal-gw", provider="anthropic", model="gpt-5.5"), + context=RuntimeAuthContext(harness="claude"), + ) async def test_custom_provider_loopback_url_is_dropped_not_pinned( @@ -399,11 +475,11 @@ async def test_custom_provider_loopback_url_is_dropped_not_pinned( ) ], ) - resolved = await VaultConnectionResolver(connection).resolve( - model=_model("loopback-gw", provider="anthropic", model="gpt-5.5"), - context=RuntimeAuthContext(harness="claude"), - ) - assert resolved.endpoint is None + with pytest.raises(InvalidConnectionConfigurationError): + await VaultConnectionResolver(connection).resolve( + model=_model("loopback-gw", provider="anthropic", model="gpt-5.5"), + context=RuntimeAuthContext(harness="claude"), + ) async def test_custom_provider_ssrf_guard_defaults_secure(fake_http, connection): @@ -425,12 +501,11 @@ async def test_custom_provider_ssrf_guard_defaults_secure(fake_http, connection) ) ], ) - resolved = await VaultConnectionResolver(connection).resolve( - model=_model("private-gw", provider="anthropic", model="gpt-5.5"), - context=RuntimeAuthContext(harness="claude"), - ) - # Blocked with no env var required — secure by default. - assert resolved.endpoint is None + with pytest.raises(InvalidConnectionConfigurationError): + await VaultConnectionResolver(connection).resolve( + model=_model("private-gw", provider="anthropic", model="gpt-5.5"), + context=RuntimeAuthContext(harness="claude"), + ) async def test_full_custom_model_key_selects_and_strips_to_backend_model( @@ -439,7 +514,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 70ae5cb2d3..a081b74104 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 @@ -105,7 +105,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" ) @@ -170,7 +170,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(): @@ -206,7 +206,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( @@ -226,10 +232,7 @@ 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(): backend = _FakeBackend(output="echo") async def _resolve(*, model, context): @@ -241,13 +244,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_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index 7eaba107b5..8aac13846a 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, ClaudeAgentTemplate, @@ -52,13 +55,8 @@ "sessionId", "agentsMd", "model", - "provider", - "connection", - "deployment", - "endpoint", - "credentialMode", + "modelConnection", "messages", - "secrets", "context", "telemetry", "runContext", @@ -122,6 +120,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"), + ), builtin_tools=["read", "write"], custom_tools=[dict(_CUSTOM_TOOL), dict(_DIRECT_CALL_TOOL)], tool_callback=_CALLBACK, @@ -135,7 +147,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", @@ -168,6 +179,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", @@ -183,7 +211,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, @@ -490,19 +517,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"), ), ) @@ -511,21 +540,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=HarnessType.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=HarnessType.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(): @@ -538,11 +623,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" @@ -667,7 +749,7 @@ def test_request_to_wire_carries_code_client_and_mcp_specs(): "name": "github", "transport": "stdio", "command": "npx", - "env": {"GITHUB_TOKEN": "ghp"}, + "environment": {"LOG_LEVEL": "info"}, "tools": ["create_issue"], } ], @@ -694,7 +776,7 @@ def test_request_to_wire_carries_code_client_and_mcp_specs(): "name": "github", "transport": "stdio", "command": "npx", - "env": {"GITHUB_TOKEN": "ghp"}, + "environment": {"LOG_LEVEL": "info"}, "tools": ["create_issue"], } ] @@ -811,3 +893,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 596ddbf0ad..116e4e71f9 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 @@ -26,6 +26,7 @@ import pytest from agenta.sdk.agents.wire_models import ( + WireMcpServer, WireRunRequest, WireRunResult, run_contract_schemas, @@ -119,3 +120,28 @@ 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_environment_headers_and_credentials(): + server = WireMcpServer.model_validate( + { + "name": "linear", + "transport": "http", + "url": "https://mcp.linear.app/sse", + "headers": {"X-Client": "agenta"}, + "credentials": [ + { + "binding": {"kind": "header", "name": "Authorization"}, + "value": "secret-marker", + "usage": "opaque_http", + } + ], + } + ) + assert server.credentials and server.credentials[0].binding.name == "Authorization" + legacy = WireMcpServer.model_validate( + {"name": "legacy", "transport": "stdio", "env": {"TOKEN": "secret"}} + ) + assert "env" not in legacy.model_dump(), ( + "legacy mixed secret metadata is not in the schema" + ) diff --git a/services/runner/package.json b/services/runner/package.json index ca8e9f68b7..ba3705beb3 100644 --- a/services/runner/package.json +++ b/services/runner/package.json @@ -22,7 +22,7 @@ "dependencies": { "@agentclientprotocol/claude-agent-acp": "0.58.1", "@anthropic-ai/sdk": "0.111.0", - "@daytonaio/sdk": "^0.187.0", + "@daytonaio/sdk": "0.196.0", "@earendil-works/pi-coding-agent": "0.80.6", "@opentelemetry/api": "1.9.0", "@opentelemetry/core": "1.28.0", diff --git a/services/runner/pnpm-lock.yaml b/services/runner/pnpm-lock.yaml index c2f1d93a65..ac5679cb9b 100644 --- a/services/runner/pnpm-lock.yaml +++ b/services/runner/pnpm-lock.yaml @@ -20,8 +20,8 @@ importers: specifier: 0.111.0 version: 0.111.0(zod@4.4.3) '@daytonaio/sdk': - specifier: ^0.187.0 - version: 0.187.0(ws@8.21.0) + specifier: 0.196.0 + version: 0.196.0(ws@8.21.0) '@earendil-works/pi-coding-agent': specifier: 0.80.6 version: 0.80.6(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) @@ -51,7 +51,7 @@ importers: version: 0.0.29 sandbox-agent: specifier: 0.4.2 - version: 0.4.2(patch_hash=ade0985e7ab79fab885a4cd818c0790d5cf319730931bd0a049775c7fbdeb7a4)(@daytonaio/sdk@0.187.0(ws@8.21.0))(zod@4.4.3) + version: 0.4.2(patch_hash=ade0985e7ab79fab885a4cd818c0790d5cf319730931bd0a049775c7fbdeb7a4)(@daytonaio/sdk@0.196.0(ws@8.21.0))(zod@4.4.3) undici: specifier: 8.3.0 version: 8.3.0 @@ -325,14 +325,14 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@daytona/api-client@0.187.0': - resolution: {integrity: sha512-riKOJ6eSuy67DL6iJlAa3Bfjnm4iQmkOdJk0B5hqrYMZeZmVDsgdiZtYvFpyoa+2KCZFNb0Gs5dQwO1d6NhGCw==} + '@daytona/api-client@0.196.0': + resolution: {integrity: sha512-c77YAwlDQw2lQOOLMYpJFQkPrYwJIK1MX3fCjv5OSpDR8FXpH1GGqItvpgOsPG6Qu+KgZFBbKYTjK63EKZYS2g==} - '@daytona/toolbox-api-client@0.187.0': - resolution: {integrity: sha512-T5F+++cakH5Nl67fR53SLkEeTgayEmw5JFXhdMKRgk/mUf6IL30nHC/2kIbc4yK8Iol6YVo9vlG4cLk+4x8y1A==} + '@daytona/toolbox-api-client@0.196.0': + resolution: {integrity: sha512-QjLGLr7NzD8+3SLwUGpIhroi8rdhP6VlUHYUD+mlR09xHHWlZA9QidbaoDyGqhLlH04PthXNB3nuX5UjXNAf4g==} - '@daytonaio/sdk@0.187.0': - resolution: {integrity: sha512-j6PfT6735Uu34t4JoxBi4IMh1JLNrEDg5w3ZUaT0Mgkas2UfoAAhQ2Eg1LqMhy4n1CTffvCyJID9W6Ldi4xEGQ==} + '@daytonaio/sdk@0.196.0': + resolution: {integrity: sha512-ruAEfmQ3mtTttIWvboQPyrGHJXQNguKzYnfq/GUnvUQJDL/kfAYswbgpry7AjODqbpWHK7NeYmsWD/SepxMgOQ==} deprecated: 'Moved to @daytona/sdk, same API, no breaking changes. Please update: npm uninstall @daytonaio/sdk && npm i @daytona/sdk' '@earendil-works/pi-agent-core@0.80.6': @@ -657,8 +657,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@opentelemetry/api-logs@0.217.0': - resolution: {integrity: sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==} + '@opentelemetry/api-logs@0.219.0': + resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} engines: {node: '>=8.0.0'} '@opentelemetry/api-logs@0.54.0': @@ -669,8 +669,8 @@ packages: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} - '@opentelemetry/configuration@0.217.0': - resolution: {integrity: sha512-xCtrYOhBqdy6ZOMfe0Oa73ZKF+2LMhoOv4L5vmwAHVvOXUg+V3fvKuEIr9ZyD0Ow+vxllEjWO6PV1wd0DOtyvw==} + '@opentelemetry/configuration@0.219.0': + resolution: {integrity: sha512-wXZUYv4ngu43nA4WEhuXNacm46LW+17LRM8nKyIhBzroRA24PBYjMnakwzR/w777nFUB5xlgsYTTeuXxumZM1Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.9.0 @@ -681,8 +681,8 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/context-async-hooks@2.7.1': - resolution: {integrity: sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==} + '@opentelemetry/context-async-hooks@2.8.0': + resolution: {integrity: sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -699,74 +699,68 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.7.1': - resolution: {integrity: sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/core@2.8.0': resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/exporter-logs-otlp-grpc@0.217.0': - resolution: {integrity: sha512-vC5S0Dc+noxD86CVtNu1+awCHPA5Kewi1Sg23ps+9lh4YifwsKXh3pe4XTNEKtUJiAcjpJ5dqStGakLbrSE+YQ==} + '@opentelemetry/exporter-logs-otlp-grpc@0.219.0': + resolution: {integrity: sha512-7SvzDCIclHWAcCwZ1MTOLcwn4BVNPGI3QxS/DJraPNe1TTL+4TvUBq5zeQV8tsnYvtDN7wKW2qocVmaCP2l7sQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-http@0.217.0': - resolution: {integrity: sha512-KfLAdt1uilVE+3FxbgVnp2ZrzqbIawzcesnRoi+Kh9ckB5Ld5D8btUgoBvwTbdmuNx1j6b132Wsh72azq+pPNQ==} + '@opentelemetry/exporter-logs-otlp-http@0.219.0': + resolution: {integrity: sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-logs-otlp-proto@0.217.0': - resolution: {integrity: sha512-Se0GG/ZO24mQTlQj7zprR4pNI0nKe4lPDPBsuJmi6508b9TlZEuUd3EfyuHk6oJxzL7fGyDFYAbxNigQvRP2ZQ==} + '@opentelemetry/exporter-logs-otlp-proto@0.219.0': + resolution: {integrity: sha512-Ayw4Gf71PS9jhBVaYywa4WsajnqfDehMkTdVH3TSAVHqPcsAv/AhH/wTNRYNt99szeYr6Gbd/D6RjZD77wAxHg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-grpc@0.217.0': - resolution: {integrity: sha512-0GpJKnCoVaVA1rKBMVPHziznfOQlXgH72S9ktjBAF1AnAVPzX7vVEBGrhwiSxxHDAiefXk+J8znApsMb/K6Z3w==} + '@opentelemetry/exporter-metrics-otlp-grpc@0.219.0': + resolution: {integrity: sha512-6LaaSrPxK5L55bXevWajvOMxGOpNm0n12tG53TeZaUeNzXwLPg6d2KCC1zAlGsojan+xRG71mA4Qqs9K2VVrKQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-http@0.217.0': - resolution: {integrity: sha512-1zkMzzhiNJdVmLxuwkltqWGw4fOOam47bqRxmuQNjyKJe/9NmY5cIrZ4kiQV7sVGxoOgT0ZvGUfLcjvtpC/b9Q==} + '@opentelemetry/exporter-metrics-otlp-http@0.219.0': + resolution: {integrity: sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-metrics-otlp-proto@0.217.0': - resolution: {integrity: sha512-nfxt/KxVGFkjkO/M+58y1ugHu/dwPtxG4eYq0KApcQ7xk5CHzhdn+IuLZfDSvNDrJ3Uy5q++Fj/wbK7i8yryfQ==} + '@opentelemetry/exporter-metrics-otlp-proto@0.219.0': + resolution: {integrity: sha512-DUS7XyIiEnoeccQUvuKy0G2/YqeKhpN8FVIrGbrLNIVMj10yeIFLRzRv0tibCI2kXXvlTTABVexGAk78wHk2ug==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-prometheus@0.217.0': - resolution: {integrity: sha512-U9MCXxJu0sBCh5aEkylYRR4xVIL8D1CW6dGwvYXbfFr0qveSorfD0XJchCAWoW6QfAAIcY/yxjf4Dj8OgkHBPw==} + '@opentelemetry/exporter-prometheus@0.219.0': + resolution: {integrity: sha512-TxOnJ85eWJY5JyOJsNMXiRTYlkDcOv0u3KbXEzWCc+tUS9sjL/BC6BcdxZ0B9r2OFVqsrZFXUzSD2sZUy42Ucw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-grpc@0.217.0': - resolution: {integrity: sha512-fPZs2fw7veLH3pEKu8vSepUa2fQpAE2P7al6qU10aH9GrEJJ8YaPgsd5xON7by5rbcEVS71FOU2aWyK6nzB7VQ==} + '@opentelemetry/exporter-trace-otlp-grpc@0.219.0': + resolution: {integrity: sha512-BkDNv1UD6BscW19MxbAxVmSYSSFuyeqR6buV2/HTYqA7GrR0EbTFzqG6h86T3PtXmpdbsWjMGLDdjG2rikG27Q==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-http@0.217.0': - resolution: {integrity: sha512-38YQoqtYjglz2GV94LGUN/djLvxtvGIQO68o6qAFPVshjmwSdX1F2i0c7vn3lEl1L5B/YqjB/bgKXaVx7KO+RQ==} + '@opentelemetry/exporter-trace-otlp-http@0.219.0': + resolution: {integrity: sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-trace-otlp-proto@0.217.0': - resolution: {integrity: sha512-nPV8gKHUiSuTZpQcnZU3/pBlK7crSyEGpZuh5MtWySB0vv6NNG0QvvfKitQt+Fc2Mc6qfyU54KlZcurwoTbrVg==} + '@opentelemetry/exporter-trace-otlp-proto@0.219.0': + resolution: {integrity: sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -777,26 +771,26 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/exporter-zipkin@2.7.1': - resolution: {integrity: sha512-mfsD9bKAxcKrh5+y08TPodvClBO0CznBE3p79YAGnO81WI4LrdsGA65T53e4iTSbCalW4WaUpkbeJcbpyIUHfg==} + '@opentelemetry/exporter-zipkin@2.8.0': + resolution: {integrity: sha512-Mj84UkEa17BK2o903VTXW3wM8CrSZexGs4tRGVZVIMM9ni1T6TuGx5IrRfoWKAbshx42D5/kc7YV+axypLPYyA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.0.0 - '@opentelemetry/instrumentation-http@0.217.0': - resolution: {integrity: sha512-B88Y7k5A9a60pHUboFoeJlgVwXq2T0rsZKj6dTwzSMKSOsNXR4Jz5ovwprVn3kHLAZrkyLEjQtBJ34DYHs1U4Q==} + '@opentelemetry/instrumentation-http@0.219.0': + resolution: {integrity: sha512-nNt1fqpyah/OKjNHdEOu8xLwISppRU2qJuF8aR+fCcftVwdFkPgtworBLA+TI1HU2iF508jcQBF2gerWczJAXg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/instrumentation@0.217.0': - resolution: {integrity: sha512-24ucQMjz7Y34Kw3trbxL2ZrssbtgWnR+Clpaa+YdeWuuyH3Cvk23Q03PcQvqiZrDvt8AmQmjgg9v6Y9PHoxG7w==} + '@opentelemetry/instrumentation@0.219.0': + resolution: {integrity: sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-exporter-base@0.217.0': - resolution: {integrity: sha512-eYfqnB3UhKu/5frhd1R6+FprKygbhkomuaceMXDyzxbfXB9tKgZOVmjaJ02CkLA6Tdzumxl+e2H+vo2a8jiMPQ==} + '@opentelemetry/otlp-exporter-base@0.219.0': + resolution: {integrity: sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -807,14 +801,14 @@ packages: peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-grpc-exporter-base@0.217.0': - resolution: {integrity: sha512-7RTAdZuOsCDnsyqTCG4+bDzrfnsWdzkRs7z0AVi/V3tEQx0oKeyc+OuRWYxnRsmaJXgxcmB8vb/lfxn58Dj6Ag==} + '@opentelemetry/otlp-grpc-exporter-base@0.219.0': + resolution: {integrity: sha512-iIk/s8QQu39zpTrRRmsW/Eg3SE2+Hg8tLWepr2FLRgmwUpNd0IpCTLJEHJ77hpt4hgIS8MAh44UYI4xQPZwWlw==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-transformer@0.217.0': - resolution: {integrity: sha512-MKK8UHKFUOGAvbZRWh90MhwHG+Fxm6OROBdjKPCF+HQobjuJ/Kuf8Chs8CR45X1aqotxrMj7OxTdsXe8sXuGVA==} + '@opentelemetry/otlp-transformer@0.219.0': + resolution: {integrity: sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': ^1.3.0 @@ -831,8 +825,8 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/propagator-b3@2.7.1': - resolution: {integrity: sha512-RJid6E2CKyeGfKBzXKF21ejabGMHypFkPAh3qZ+NvI+SGjuIye79t3PmiqcDgtRzdKH6ynXzbfslQ8DfpRUg2A==} + '@opentelemetry/propagator-b3@2.8.0': + resolution: {integrity: sha512-SazlvuSKi5533rPHTW2TwBwdMakhjZST4SYs0YauuvfGDkT13KbG1gJS75hV0uWVeevhtVP9sAIlaZLTHdSbMg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -843,8 +837,8 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/propagator-jaeger@2.7.1': - resolution: {integrity: sha512-KMjVBHzP4N60bOzxja76M1F1hZZ43lGPga5ix+mkv9+kk1nx9SbkxSvJsMbuVUxdPQmsPTqGShmhN8ulrMOg6Q==} + '@opentelemetry/propagator-jaeger@2.8.0': + resolution: {integrity: sha512-Xnz9zZvvQzUw+9DrOn0MomR7BxFCkA2pcfXBQuHC28ndJpSbjLs7knzYb05kw5SyCjSsEWombkZMgGcJSk8JVg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -861,20 +855,14 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/resources@2.7.1': - resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/resources@2.8.0': resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-logs@0.217.0': - resolution: {integrity: sha512-BB+PcHItcZDL63dPMW+mJvwN9rk37wuIDjRxbVlg6pPDvDR/7GL7UJHbGsllgoggOoTimsKgENaWPoGch/oE1A==} + '@opentelemetry/sdk-logs@0.219.0': + resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' @@ -891,14 +879,14 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-metrics@2.7.1': - resolution: {integrity: sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==} + '@opentelemetry/sdk-metrics@2.8.0': + resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.9.0 <1.10.0' - '@opentelemetry/sdk-node@0.217.0': - resolution: {integrity: sha512-K/60pSv42+NQiZKy1pAH18nYDkxltsDV4O3SJ233J0E9raU1ksyL9gsKuS8p30bYBb4AMPCfDuutHQaHYpcv0Q==} + '@opentelemetry/sdk-node@0.219.0': + resolution: {integrity: sha512-NWLpWLEb8gV3+JBHYoIrktbM385wyHpRJoh3J/4Q52d4PR+AlPMNGJT3DzBUrDSUEVbKAXoHR+EDAPxtiNcj8g==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' @@ -915,12 +903,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.7.1': - resolution: {integrity: sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': '>=1.3.0 <1.10.0' - '@opentelemetry/sdk-trace-base@2.8.0': resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -933,8 +915,8 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/sdk-trace-node@2.7.1': - resolution: {integrity: sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==} + '@opentelemetry/sdk-trace-node@2.8.0': + resolution: {integrity: sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==} engines: {node: ^18.19.0 || >=20.6.0} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' @@ -972,9 +954,6 @@ packages: '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} - '@protobufjs/inquire@1.1.2': - resolution: {integrity: sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==} - '@protobufjs/path@1.1.2': resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} @@ -2048,10 +2027,6 @@ packages: resolution: {integrity: sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw==} engines: {node: '>=12.0.0'} - protobufjs@8.0.1: - resolution: {integrity: sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w==} - engines: {node: '>=12.0.0'} - proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2860,33 +2835,33 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@daytona/api-client@0.187.0': + '@daytona/api-client@0.196.0': dependencies: axios: 1.18.0 transitivePeerDependencies: - debug - supports-color - '@daytona/toolbox-api-client@0.187.0': + '@daytona/toolbox-api-client@0.196.0': dependencies: axios: 1.18.0 transitivePeerDependencies: - debug - supports-color - '@daytonaio/sdk@0.187.0(ws@8.21.0)': + '@daytonaio/sdk@0.196.0(ws@8.21.0)': dependencies: '@aws-sdk/client-s3': 3.1070.0 '@aws-sdk/lib-storage': 3.1070.0(@aws-sdk/client-s3@3.1070.0) - '@daytona/api-client': 0.187.0 - '@daytona/toolbox-api-client': 0.187.0 + '@daytona/api-client': 0.196.0 + '@daytona/toolbox-api-client': 0.196.0 '@iarna/toml': 2.2.5 '@opentelemetry/api': 1.9.0 - '@opentelemetry/exporter-trace-otlp-http': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation-http': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node': 0.217.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation-http': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-node': 0.219.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.41.1 axios: 1.18.0 @@ -3207,7 +3182,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@opentelemetry/api-logs@0.217.0': + '@opentelemetry/api-logs@0.219.0': dependencies: '@opentelemetry/api': 1.9.0 @@ -3217,17 +3192,17 @@ snapshots: '@opentelemetry/api@1.9.0': {} - '@opentelemetry/configuration@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/configuration@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) yaml: 2.9.0 '@opentelemetry/context-async-hooks@1.28.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks@2.7.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/context-async-hooks@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -3241,113 +3216,108 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.27.0 - '@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/semantic-conventions': 1.41.1 - '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.41.1 - '@opentelemetry/exporter-logs-otlp-grpc@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-grpc@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.4 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-http@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.217.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-logs-otlp-proto@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.217.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-grpc@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.4 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-http@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-metrics-otlp-proto@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-prometheus@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.41.1 - '@opentelemetry/exporter-trace-otlp-grpc@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-grpc@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.4 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-http@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/exporter-trace-otlp-proto@0.54.0(@opentelemetry/api@1.9.0)': dependencies: @@ -3358,38 +3328,38 @@ snapshots: '@opentelemetry/resources': 1.27.0(@opentelemetry/api@1.9.0) '@opentelemetry/sdk-trace-base': 1.27.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin@2.7.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/exporter-zipkin@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.41.1 - '@opentelemetry/instrumentation-http@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation-http@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.217.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.41.1 forwarded-parse: 2.1.2 transitivePeerDependencies: - supports-color - '@opentelemetry/instrumentation@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.217.0 + '@opentelemetry/api-logs': 0.219.0 import-in-the-middle: 3.0.2 require-in-the-middle: 8.0.1 transitivePeerDependencies: - supports-color - '@opentelemetry/otlp-exporter-base@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-exporter-base@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.0) '@opentelemetry/otlp-exporter-base@0.54.0(@opentelemetry/api@1.9.0)': dependencies: @@ -3397,24 +3367,23 @@ snapshots: '@opentelemetry/core': 1.27.0(@opentelemetry/api@1.9.0) '@opentelemetry/otlp-transformer': 0.54.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-grpc-exporter-base@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-grpc-exporter-base@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@grpc/grpc-js': 1.14.4 '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer': 0.217.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-transformer@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/otlp-transformer@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.217.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) - protobufjs: 8.0.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/otlp-transformer@0.54.0(@opentelemetry/api@1.9.0)': dependencies: @@ -3432,20 +3401,20 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-b3@2.7.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-b3@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/propagator-jaeger@1.28.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger@2.7.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/propagator-jaeger@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources@1.27.0(@opentelemetry/api@1.9.0)': dependencies: @@ -3459,24 +3428,18 @@ snapshots: '@opentelemetry/core': 1.28.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.27.0 - '@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.41.1 - '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.41.1 - '@opentelemetry/sdk-logs@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.217.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.41.1 '@opentelemetry/sdk-logs@0.54.0(@opentelemetry/api@1.9.0)': @@ -3492,39 +3455,40 @@ snapshots: '@opentelemetry/core': 1.27.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 1.27.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics@2.7.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-node@0.217.0(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-node@0.219.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/api-logs': 0.217.0 - '@opentelemetry/configuration': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-grpc': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-http': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-logs-otlp-proto': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-grpc': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-http': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-metrics-otlp-proto': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-prometheus': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-grpc': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-http': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-trace-otlp-proto': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/exporter-zipkin': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/instrumentation': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/otlp-exporter-base': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-b3': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/propagator-jaeger': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-logs': 0.217.0(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-metrics': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-node': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/configuration': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-grpc': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-http': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-logs-otlp-proto': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-http': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-metrics-otlp-proto': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-prometheus': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-grpc': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-http': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-trace-otlp-proto': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/exporter-zipkin': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-grpc-exporter-base': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-b3': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/propagator-jaeger': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-node': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.41.1 transitivePeerDependencies: - supports-color @@ -3543,13 +3507,6 @@ snapshots: '@opentelemetry/resources': 1.28.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.27.0 - '@opentelemetry/sdk-trace-base@2.7.1(@opentelemetry/api@1.9.0)': - dependencies: - '@opentelemetry/api': 1.9.0 - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/semantic-conventions': 1.41.1 - '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -3567,12 +3524,12 @@ snapshots: '@opentelemetry/sdk-trace-base': 1.28.0(@opentelemetry/api@1.9.0) semver: 7.8.0 - '@opentelemetry/sdk-trace-node@2.7.1(@opentelemetry/api@1.9.0)': + '@opentelemetry/sdk-trace-node@2.8.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 - '@opentelemetry/context-async-hooks': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.0) - '@opentelemetry/sdk-trace-base': 2.7.1(@opentelemetry/api@1.9.0) + '@opentelemetry/context-async-hooks': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions@1.27.0': {} @@ -3596,8 +3553,6 @@ snapshots: '@protobufjs/float@1.0.2': {} - '@protobufjs/inquire@1.1.2': {} - '@protobufjs/path@1.1.2': {} '@protobufjs/pool@1.1.0': {} @@ -4611,21 +4566,6 @@ snapshots: '@types/node': 24.13.2 long: 5.3.2 - protobufjs@8.0.1: - dependencies: - '@protobufjs/aspromise': 1.1.2 - '@protobufjs/base64': 1.1.2 - '@protobufjs/codegen': 2.0.5 - '@protobufjs/eventemitter': 1.1.1 - '@protobufjs/fetch': 1.1.1 - '@protobufjs/float': 1.0.2 - '@protobufjs/inquire': 1.1.2 - '@protobufjs/path': 1.1.2 - '@protobufjs/pool': 1.1.0 - '@protobufjs/utf8': 1.1.1 - '@types/node': 24.13.2 - long: 5.3.2 - proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -4713,12 +4653,12 @@ snapshots: safer-buffer@2.1.2: {} - sandbox-agent@0.4.2(patch_hash=ade0985e7ab79fab885a4cd818c0790d5cf319730931bd0a049775c7fbdeb7a4)(@daytonaio/sdk@0.187.0(ws@8.21.0))(zod@4.4.3): + sandbox-agent@0.4.2(patch_hash=ade0985e7ab79fab885a4cd818c0790d5cf319730931bd0a049775c7fbdeb7a4)(@daytonaio/sdk@0.196.0(ws@8.21.0))(zod@4.4.3): dependencies: '@sandbox-agent/cli-shared': 0.4.2 acp-http-client: 0.4.2(zod@4.4.3) optionalDependencies: - '@daytonaio/sdk': 0.187.0(ws@8.21.0) + '@daytonaio/sdk': 0.196.0(ws@8.21.0) '@sandbox-agent/cli': 0.4.2 transitivePeerDependencies: - zod diff --git a/services/runner/src/engines/sandbox_agent.ts b/services/runner/src/engines/sandbox_agent.ts index e24b68092c..118694244d 100644 --- a/services/runner/src/engines/sandbox_agent.ts +++ b/services/runner/src/engines/sandbox_agent.ts @@ -33,6 +33,7 @@ import { mkdirSync, rmSync } from "node:fs"; import { apiBase } from "../apiBase.ts"; +import { Redactor, seedFromEnv } from "../redaction.ts"; import { SandboxAgent, InMemorySessionPersistDriver } from "sandbox-agent"; @@ -83,7 +84,10 @@ import { DAYTONA_PI_DIR, } from "./sandbox_agent/daytona.ts"; import { conciseError } from "./sandbox_agent/errors.ts"; -import { buildSessionMcpServers } from "./sandbox_agent/mcp.ts"; +import { + buildSessionMcpServers, + validateUserMcpServers, +} from "./sandbox_agent/mcp.ts"; import { applyModel } from "./sandbox_agent/model.ts"; import { findSwallowedPiError } from "./sandbox_agent/pi-error.ts"; import { @@ -127,6 +131,7 @@ import { } from "./sandbox_agent/teardown.ts"; import { buildSandboxProvider } from "./sandbox_agent/provider.ts"; import { DaytonaReconnectTerminalError } from "./sandbox_agent/daytona-provider.ts"; +import { materializeDaytonaMcpServers } from "./sandbox_agent/daytona-secret-provider.ts"; import { buildRunPlan, type BuildRunPlanDeps, @@ -283,9 +288,9 @@ 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}`); @@ -293,7 +298,7 @@ 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; @@ -534,6 +539,7 @@ export function sendLastMessageOnly(opts: RunTurnOptions): boolean { export interface SessionEnvironment { plan: RunPlan; logger: Log; + redactor: Redactor; deps: SandboxAgentDeps; sandbox: any; session: any; @@ -642,7 +648,24 @@ export async function acquireEnvironment( signal?: AbortSignal, presignedMount?: MountCredentials | null, ): Promise { - const logger = deps.log ?? log; + const redactor = seedFromEnv({ + resolvedSecrets: [ + ...(request.modelConnection?.credentials ?? []).map( + (credential) => credential.value, + ), + ...(request.mcpServers ?? []).flatMap((server) => + (server.credentials ?? []).map((credential) => credential.value), + ), + ], + runCredential: runCredential(request), + extraValues: [ + request.toolCallback?.authorization, + request.telemetry?.exporters?.otlp?.headers?.authorization, + ], + }); + const rawLogger = deps.log ?? log; + const logger: Log = (message) => + rawLogger(redactor.redactString(message, "log") ?? "[ag:redacted]"); const acquireStartedAt = Date.now(); const timingLog = (stage: string, startedAt: number, fields = ""): void => { const sandboxId = environment?.sandbox?.sandboxId ?? "-"; @@ -672,7 +695,10 @@ export async function acquireEnvironment( ownerReplicaId, ); } catch (err) { - return { ok: false, error: conciseError(err, request.harness ?? "") }; + return { + ok: false, + error: redactor.redactError(conciseError(err, request.harness ?? "")), + }; } } @@ -731,14 +757,15 @@ export async function acquireEnvironment( 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 + // inherits NONE of the sidecar's own provider keys, so only the resolved model environment is // 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"; + const clearProviderEnv = + plan.credentialMode === "env" || plan.credentialMode === "none"; const env = (deps.buildDaemonEnv ?? buildDaemonEnv)(plan.acpAgent, { clearProviderEnv, }); - 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 strictModel = modelResolutionStrict(); // Pi self-instruments locally: propagate the trace context + public tool metadata into Pi @@ -790,10 +817,12 @@ export async function acquireEnvironment( // 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 ?? ""} ` + - `connection=${request.connection ? `${request.connection.mode}:${request.connection.slug ?? "-"}` : ""} ` + - `secretKeys=[${Object.keys(request.secrets ?? {}).join(",")}]`, + `resolved model=${request.model ?? ""} provider=${request.modelConnection?.provider ?? ""} ` + + `deployment=${request.modelConnection?.deployment ?? ""} ` + + `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; @@ -816,6 +845,7 @@ export async function acquireEnvironment( const environment: SessionEnvironment = { plan, logger, + redactor, deps, sandbox: undefined, session: undefined, @@ -909,13 +939,13 @@ export async function acquireEnvironment( if (!parked && !plan.isDaytona && environment.agentMountedPath) { const agentMountSafeToDelete = await ( environment.deps.unmountStorage ?? unmountStorage - )( - environment.agentMountedPath, - { log }, - ).catch(() => false); + )(environment.agentMountedPath, { log }).catch(() => false); if (agentMountSafeToDelete) { try { - rmSync(environment.agentMountedPath, { recursive: true, force: true }); + rmSync(environment.agentMountedPath, { + recursive: true, + force: true, + }); } catch (err) { logger( `agent mountpoint cleanup failed path=${environment.agentMountedPath}: ${conciseError(err, plan.harness)}`, @@ -1087,6 +1117,7 @@ export async function acquireEnvironment( }; try { + await validateUserMcpServers(request.mcpServers); // Persist events in-process so a follow-up turn can resume by session id. const persist = deps.createPersist?.() ?? new InMemorySessionPersistDriver(); @@ -1108,8 +1139,9 @@ export async function acquireEnvironment( env, binaryPath, piExtEnv, - plan.secrets, + plan.modelEnvironment, plan.sandboxPermission, + plan.daytonaSecretPlan, ); const startOptions = { sandbox: sandboxProvider, @@ -1204,6 +1236,7 @@ export async function acquireEnvironment( if (plan.isDaytona) { await (deps.prepareDaytonaPiAssets ?? prepareDaytonaPiAssets)({ sandbox: environment.sandbox, + extensionEnv: piExtEnv, plan, log: logger, }); @@ -1308,12 +1341,9 @@ 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.cwd, mountPath, { + log: logger, + }); await activateAgentMountGuidance(); logger(`remote agent mount active for artifact=${artifactId}`); } @@ -1396,7 +1426,10 @@ export async function acquireEnvironment( harness: plan.harness, isDaytona: plan.isDaytona, toolSpecs: plan.toolSpecs, - userMcpServers: request.mcpServers, + userMcpServers: materializeDaytonaMcpServers( + sandboxProvider, + request.mcpServers, + ), relayDir: plan.relayDir, clientToolRelay: deferredClientToolRelay, signal: mcpAbort.signal, @@ -1554,7 +1587,9 @@ 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 = redactor.redactError( + 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" }); @@ -1652,6 +1687,19 @@ export async function runTurn( opts: RunTurnOptions = {}, ): Promise { const { plan, logger, deps } = env; + // A parked environment can resume with freshly minted caller, callback, telemetry, model, and + // MCP credentials. Extend the same mutable redactor before any per-turn log/event/trace sink. + env.redactor.withKnownSecrets([ + ...(request.modelConnection?.credentials ?? []).map( + (credential) => credential.value, + ), + ...(request.mcpServers ?? []).flatMap((server) => + (server.credentials ?? []).map((credential) => credential.value), + ), + runCredential(request), + request.toolCallback?.authorization, + request.telemetry?.exporters?.otlp?.headers?.authorization, + ]); const sessionId = env.sessionId; // Reset the per-turn tool-call id record (the park folds the completed turn's ids into the // expected next-history fingerprint). @@ -1705,18 +1753,27 @@ export async function runTurn( // 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 // through. Per-tool-call timers are driven separately from `handleUpdate` below. - emit: emit && runLimits.wrapEmit(emit), + emit: + emit && + runLimits.wrapEmit((event) => + emit(env.redactor.redactJson(event, "event")), + ), }); otel = run; - run.start({ - prompt: promptText, - sessionId, - messages: [ - ...priorMessages(request), - { role: "user", content: promptText }, - ], - }); + run.start( + env.redactor.redactJson( + { + prompt: promptText, + sessionId, + messages: [ + ...priorMessages(request), + { role: "user", content: promptText }, + ], + }, + "span", + ), + ); const pause = new PendingApprovalPauseController(() => { // The sibling settle runs UNCONDITIONALLY, park mode or not: latch-loser tool calls @@ -1785,7 +1842,7 @@ export async function runTurn( ) { env.lastTurnToolCallIds.push(frame.toolCallId); } - run.handleUpdate(update); + run.handleUpdate(env.redactor.redactJson(update, "event")); // A sibling announced AFTER the pause won the latch can never execute; settle it // immediately so the client never holds an orphaned part (idempotent re-sweep). if (pause.active) { @@ -1984,13 +2041,18 @@ export async function runTurn( // trace with the parked tool call so the completing `tool_call_update` closes it and the FE // approval part flips to output-available even if the adapter re-announces nothing. Then // answer the gate on the live session — the original prompt continues from here. - run.handleUpdate({ - sessionUpdate: "tool_call", - toolCallId: opts.resume.toolCallId, - title: opts.resume.toolName, - kind: opts.resume.toolName, - rawInput: opts.resume.args, - }); + run.handleUpdate( + env.redactor.redactJson( + { + sessionUpdate: "tool_call", + toolCallId: opts.resume.toolCallId, + title: opts.resume.toolName, + kind: opts.resume.toolName, + rawInput: opts.resume.args, + }, + "event", + ), + ); promptPromise = Promise.resolve(opts.resume.promptPromise); promptPromise.catch(() => {}); // A parked Pi dialog gate resumes on a FRESH turn whose relay and grant ledger are new; @@ -2076,9 +2138,10 @@ export async function runTurn( swallowedError = conciseError( new Error(swallowedPiError), plan.harness, - request.provider, + request.modelConnection?.provider, ); - run.recordError(swallowedError, request.provider); + swallowedError = env.redactor.redactError(swallowedError); + run.recordError(swallowedError, request.modelConnection?.provider); run.emitEvent({ type: "error", message: swallowedError }); } @@ -2123,24 +2186,29 @@ export async function runTurn( invalidateContinuity(sessionId, plan.harness, deps); } - return { - ok: true, - output, - messages: output ? [{ role: "assistant", content: output }] : [], - events: emit ? [] : run.events(), - usage, - stopReason, - capabilities: { - ...env.capabilities, - streamingDeltas: !!emit && env.capabilities.streamingDeltas, - }, - sessionId, - model: env.model ?? request.model, - traceId: run.traceId(), - } as AgentRunResult; + return env.redactor.redactJson( + { + ok: true, + output, + messages: output ? [{ role: "assistant", content: output }] : [], + events: emit ? [] : run.events(), + usage, + stopReason, + capabilities: { + ...env.capabilities, + streamingDeltas: !!emit && env.capabilities.streamingDeltas, + }, + sessionId, + model: env.model ?? request.model, + traceId: run.traceId(), + } as AgentRunResult, + "result", + ); } catch (err) { - const error = conciseError(err, plan.harness, request.provider); - otel?.recordError(error, request.provider); + const error = env.redactor.redactError( + 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/daemon.ts b/services/runner/src/engines/sandbox_agent/daemon.ts index a1a75568a1..d43ea4b54e 100644 --- a/services/runner/src/engines/sandbox_agent/daemon.ts +++ b/services/runner/src/engines/sandbox_agent/daemon.ts @@ -122,7 +122,7 @@ 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` + * provider keys (the caller applies only `plan.modelEnvironment`). 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. */ @@ -136,7 +136,7 @@ export interface BuildDaemonEnvOptions { * * 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 + * 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. For a `runtime_provided` * / `none` run the harness uses its own login, so the inherited keys are kept. */ @@ -167,7 +167,7 @@ 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 the sidecar's own keys so its login works. + // `plan.modelEnvironment`. Non-managed run: keep the sidecar's own keys so its login works. if (!clearProviderEnv) { for (const key of KNOWN_PROVIDER_ENV_VARS) { const value = process.env[key]; 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..7c588ad8e1 --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts @@ -0,0 +1,265 @@ +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 hasHeaders = + Object.keys(server.headers ?? {}).length > 0 || + (server.credentials?.length ?? 0) > 0; + if ( + hasHeaders && + ((server.transport ?? "stdio") !== "http" || !server.url) + ) { + fail( + `headers on MCP server '${server.name}' require HTTP transport and URL`, + ); + } + const host = hasHeaders ? exactHttpsHost(server.url!) : undefined; + for (const [name, value] of Object.entries(server.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 server.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..50b3e93751 --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts @@ -0,0 +1,356 @@ +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]; + if ( + Object.keys(server.headers ?? {}).length === 0 && + (server.credentials?.length ?? 0) === 0 + ) { + return server; + } + if (!placeholders) { + throw new Error( + `Daytona Secret allocation is missing MCP placeholders for '${server.name}'.`, + ); + } + return { + ...server, + headers: server.headers + ? Object.fromEntries( + Object.keys(server.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: server.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. This deliberately cannot + * survive a runner crash. Durable reconciliation is PR B. + */ +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 fdcad2d01b..c7c623ad8c 100644 --- a/services/runner/src/engines/sandbox_agent/daytona.ts +++ b/services/runner/src/engines/sandbox_agent/daytona.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { createAcpFetch } from "./acp-fetch.ts"; +import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../extensions/model-provider-override.ts"; import { uploadPiExtensionToSandbox, uploadSkillsToSandbox, @@ -31,13 +32,14 @@ export const DAYTONA_PI_VERSION = */ export function daytonaEnvVars( piExtEnv: Record, - secrets: Record, + environment: Record, ): Record { const env: Record = { PI_CODING_AGENT_DIR: DAYTONA_PI_DIR, ...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, }; // Point pi-acp at the `pi` we install into the sandbox (the image lacks it). if (DAYTONA_PI_INSTALL) { @@ -108,6 +110,7 @@ export async function uploadPiAuthToSandbox( export interface PrepareDaytonaPiAssetsInput { sandbox: any; + extensionEnv?: Record; plan: Pick< RunPlan, | "isPi" @@ -127,6 +130,7 @@ export interface PrepareDaytonaPiAssetsInput { */ export async function prepareDaytonaPiAssets({ sandbox, + extensionEnv = {}, plan, log = () => {}, }: PrepareDaytonaPiAssetsInput): Promise { @@ -137,7 +141,12 @@ export async function prepareDaytonaPiAssets({ // "env") NEVER triggers the fallback. The decision lives in `shouldUploadOwnLogin` so the rule // is in one place and testable. if (shouldUploadOwnLogin(plan)) await uploadPiAuthToSandbox(sandbox, log); - await uploadPiExtensionToSandbox(sandbox, DAYTONA_PI_DIR, log); + await uploadPiExtensionToSandbox( + sandbox, + DAYTONA_PI_DIR, + log, + extensionEnv[PI_MODEL_PROVIDER_OVERRIDE_ENV] !== undefined, + ); if (plan.skillDirs.length > 0) { await uploadSkillsToSandbox(sandbox, DAYTONA_PI_DIR, plan.skillDirs, log); } diff --git a/services/runner/src/engines/sandbox_agent/errors.ts b/services/runner/src/engines/sandbox_agent/errors.ts index 1b9f8b9185..57d777b605 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 a0874503bf..c0c9db2088 100644 --- a/services/runner/src/engines/sandbox_agent/mcp.ts +++ b/services/runner/src/engines/sandbox_agent/mcp.ts @@ -167,13 +167,13 @@ export async function validateUserMcpUrl( } catch { return `http MCP server url is not a valid URL: ${rawUrl}`; } + if (parsed.protocol !== "https:") { + return `http MCP server url must use https (got ${parsed.protocol.replace(":", "")}): ${rawUrl}`; + } if (insecureEgressAllowed()) return undefined; const allowlist = mcpHostAllowlist(); const host = parsed.hostname.toLowerCase(); const allowed = allowlist.has(host); - if (parsed.protocol !== "https:" && !allowed) { - return `http MCP server url must use https (got ${parsed.protocol.replace(":", "")}): ${rawUrl}`; - } if (allowed) return undefined; if (host === "localhost" || host.endsWith(".localhost")) { @@ -194,17 +194,57 @@ export async function validateUserMcpUrl( return undefined; } +export async function validateUserMcpServers( + servers: McpServerConfig[] | undefined, +): Promise { + for (const server of servers ?? []) { + const transport = server.transport ?? "stdio"; + if (transport === "stdio") { + if (server.headers || (server.credentials?.length ?? 0) > 0) { + throw new Error("stdio MCP server cannot carry HTTP headers"); + } + continue; + } + if (!server.url) throw new Error("http MCP server requires url"); + if (Object.keys(server.environment ?? {}).length > 0) { + throw new Error("http MCP server cannot carry process environment"); + } + const names = new Set(); + for (const [name, value] of Object.entries(server.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.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(server.url); + if (urlError) throw new Error(urlError); + } +} + /** * Convert USER-declared MCP servers into ACP entries. (This is the USER MCP capability layer — * distinct from the INTERNAL gateway-tool channel below; see `buildSessionMcpServers`.) * - * - HTTP (`transport: "http"` + `url`) is ENABLED. A remote server has no child process on the - * runner host: the harness connects to the URL and the named secret rides in a request header, - * so it does not bypass the sandbox boundary the way a stdio child does. The resolved secret - * arrives on the `/run` wire in the server's `env` map (the SDK resolver merges named secrets - * into `env` regardless of transport, and the wire has no separate `headers` field), so each - * `env` entry is emitted as an HTTP header (`Authorization: `, etc.). The author names - * the header via the secret-map key, exactly as a stdio server names its env var. + * - HTTP (`transport: "http"` + `url`) is enabled. Public headers and typed secret header + * credentials stay separate until this final local ACP materialization boundary. * - STDIO (`transport: "stdio"` + `command`) is DISABLED. A stdio MCP server launches an * arbitrary process on the runner host, outside the sandbox boundary, so the implementation is * disabled (parity with the removed code execution) until its security is fixed. The wire shape @@ -228,18 +268,21 @@ export async function toAcpMcpServers( log(`skipping http MCP server '${s?.name ?? "?"}' (no url)`); continue; } - // SSRF guard: the resolved named secret rides as a header on this author-supplied URL, so - // reject a non-https / internal / metadata target before any credential is attached. - const urlError = await validateUserMcpUrl(s.url); - if (urlError) throw new Error(urlError); + await validateUserMcpServers([s]); out.push({ type: "http", name: s.name, url: s.url, - headers: Object.entries(s.env ?? {}).map(([name, value]) => ({ - name, - value, - })), + headers: [ + ...Object.entries(s.headers ?? {}).map(([name, value]) => ({ + name, + value, + })), + ...(s.credentials ?? []).map((credential) => ({ + name: credential.binding.name, + value: credential.value, + })), + ], }); continue; } diff --git a/services/runner/src/engines/sandbox_agent/pi-assets.ts b/services/runner/src/engines/sandbox_agent/pi-assets.ts index 63d9340a61..5cfe1df6cd 100644 --- a/services/runner/src/engines/sandbox_agent/pi-assets.ts +++ b/services/runner/src/engines/sandbox_agent/pi-assets.ts @@ -13,6 +13,10 @@ 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"; @@ -64,6 +68,14 @@ export function buildPiExtensionEnv( if (telemetry && opts.skills && opts.skills.length > 0) env.AGENTA_AGENT_SKILLS_LOADED = JSON.stringify(opts.skills); + const modelBaseUrl = request.modelConnection?.endpoint?.baseUrl; + if (modelBaseUrl !== undefined) { + env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = encodePiModelProviderOverride({ + provider: request.modelConnection?.provider, + baseUrl: modelBaseUrl, + }); + } + const specs = advertisedToolSpecs( (request.customTools as ResolvedToolSpec[]) ?? [], ); @@ -108,15 +120,20 @@ export function writeOtlpAuthFile( } } -/** Install the extension bundle into a local Pi agent dir's extensions/. Best-effort. */ +/** Install the extension bundle into a local Pi agent dir's extensions/. */ export function installPiExtensionLocal( agentDir: string, log: Log = () => {}, + required = false, ): void { if (!existsSync(EXTENSION_BUNDLE)) { - log( - `pi extension bundle missing at ${EXTENSION_BUNDLE} (run build:extension)`, - ); + const message = `pi extension bundle missing at ${EXTENSION_BUNDLE} (run build:extension)`; + if (required) { + throw new Error( + `Pi model endpoint override requires the Agenta extension: ${message}`, + ); + } + log(message); return; } try { @@ -124,7 +141,13 @@ export function installPiExtensionLocal( mkdirSync(dir, { recursive: true }); copyFileSync(EXTENSION_BUNDLE, join(dir, "agenta.js")); } catch (err) { - log(`pi extension install skipped: ${(err as Error).message}`); + const message = `pi extension install skipped: ${(err as Error).message}`; + if (required) { + throw new Error( + `Pi model endpoint override requires the Agenta extension: ${message}`, + ); + } + log(message); } } @@ -181,13 +204,22 @@ export async function uploadSystemPromptToSandbox( } } -/** Upload the extension bundle into a Daytona sandbox's Pi extensions dir. Best-effort. */ +/** Upload the extension bundle into a Daytona sandbox's Pi extensions dir. */ export async function uploadPiExtensionToSandbox( sandbox: any, agentDir: string, log: Log = () => {}, + required = false, ): Promise { - if (!existsSync(EXTENSION_BUNDLE)) return; + if (!existsSync(EXTENSION_BUNDLE)) { + const message = `pi extension bundle missing at ${EXTENSION_BUNDLE} (run build:extension)`; + if (required) { + throw new Error( + `Pi model endpoint override requires the Agenta extension: ${message}`, + ); + } + return; + } try { const dir = `${agentDir}/extensions`; await sandbox.mkdirFs({ path: dir }); @@ -196,7 +228,13 @@ export async function uploadPiExtensionToSandbox( readFileSync(EXTENSION_BUNDLE, "utf-8"), ); } catch (err) { - log(`pi extension upload skipped: ${(err as Error).message}`); + const message = `pi extension upload skipped: ${(err as Error).message}`; + if (required) { + throw new Error( + `Pi model endpoint override requires the Agenta extension: ${message}`, + ); + } + log(message); } } @@ -225,6 +263,7 @@ export function prepareLocalAgentDir( sourceAgentDir: string, skillDirs: MaterializedSkill[], log: Log = () => {}, + requireExtension = false, ): string { const dir = mkdtempSync(join(tmpdir(), "agenta-pi-agentdir-")); for (const name of ["auth.json", "settings.json"]) { @@ -235,7 +274,7 @@ export function prepareLocalAgentDir( log(`agent-dir seed skipped for ${name}: ${(err as Error).message}`); } } - installPiExtensionLocal(dir, log); + installPiExtensionLocal(dir, log, requireExtension); installSkillsLocal(dir, skillDirs, log); return dir; } @@ -266,12 +305,14 @@ export function prepareLocalPiAssets({ log = () => {}, }: PrepareLocalPiAssetsInput): string | undefined { if (!plan.isPi || plan.isDaytona) return undefined; + const requireExtension = env[PI_MODEL_PROVIDER_OVERRIDE_ENV] !== undefined; if (plan.skillDirs.length > 0 || plan.hasSystemPrompt) { const runAgentDir = prepareLocalAgentDir( plan.sourcePiAgentDir, plan.skillDirs, log, + requireExtension, ); if (plan.hasSystemPrompt) { writeSystemPromptLocal( @@ -286,8 +327,17 @@ export function prepareLocalPiAssets({ } if (process.env.PI_CODING_AGENT_DIR) { - installPiExtensionLocal(process.env.PI_CODING_AGENT_DIR, log); + installPiExtensionLocal( + process.env.PI_CODING_AGENT_DIR, + log, + requireExtension, + ); } else { + if (requireExtension) { + throw new Error( + "Pi model endpoint override requires the Agenta extension, but PI_CODING_AGENT_DIR is unset", + ); + } // unset here means this run has no Agenta extension (tracing + tools); warn so it's visible. log( "PI_CODING_AGENT_DIR is unset; plain local Pi run has no Agenta extension installed", diff --git a/services/runner/src/engines/sandbox_agent/provider.ts b/services/runner/src/engines/sandbox_agent/provider.ts index c13adb40cc..4883ba9766 100644 --- a/services/runner/src/engines/sandbox_agent/provider.ts +++ b/services/runner/src/engines/sandbox_agent/provider.ts @@ -1,8 +1,16 @@ +import { createHash } from "node:crypto"; + +import { Daytona } from "@daytonaio/sdk"; import { local } from "sandbox-agent/local"; import type { SandboxPermission } from "../../protocol.ts"; import { daytonaEnvVars } from "./daytona.ts"; import { 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 @@ -38,7 +46,10 @@ export function daytonaNetworkFields( export const DEFAULT_DAYTONA_AUTOSTOP_MINUTES = 15; export const DEFAULT_DAYTONA_AUTODELETE_MINUTES = 30; -function positiveMinutes(rawValue: string | undefined, fallback: number): number { +function positiveMinutes( + rawValue: string | undefined, + fallback: number, +): number { const parsed = Number(rawValue); if (!Number.isFinite(parsed) || parsed < 1) return fallback; return Math.floor(parsed); @@ -68,8 +79,9 @@ export function daytonaAutoDeleteMinutes( */ export function buildDaytonaCreate( piExtEnv: Record, - secrets: Record, + environment: Record, sandboxPermission: SandboxPermission | undefined, + secretAttachments: Record = {}, ): Record { const snapshot = process.env.DAYTONA_SNAPSHOT; const target = process.env.DAYTONA_TARGET; @@ -80,7 +92,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. @@ -90,6 +105,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"); +} + /** Sandbox ids this runner can actually provision (the "expected one of" set). */ export const KNOWN_SANDBOX_IDS = ["local", "daytona"] as const; @@ -113,13 +150,47 @@ export function buildSandboxProvider( piExtEnv: Record, secrets: Record, sandboxPermission?: SandboxPermission, + daytonaSecretPlan?: DaytonaSecretPlan, ) { if (sandboxId === "daytona") { const image = process.env.DAYTONA_IMAGE; - return daytonaWithLifecycle({ - ...(image ? { image } : {}), - create: buildDaytonaCreate(piExtEnv, secrets, sandboxPermission) as any, - }); + const createFields = buildDaytonaCreate( + piExtEnv, + secrets, + sandboxPermission, + ); + const buildDaytona = (secretAttachments: Record) => + daytonaWithLifecycle({ + ...(image ? { image } : {}), + create: { + ...createFields, + ...(Object.keys(secretAttachments).length > 0 + ? { secrets: secretAttachments } + : {}), + } as any, + }); + if (daytonaSecretPlan) { + assertDaytonaOpaqueSecretsEnabled(daytonaSecretPlan!); + const client = new 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: daytonaAutoDeleteMinutes() * 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 6c7b9c9445..e4f369849a 100644 --- a/services/runner/src/engines/sandbox_agent/run-plan.ts +++ b/services/runner/src/engines/sandbox_agent/run-plan.ts @@ -32,6 +32,11 @@ import { } from "../skills.ts"; import { assert } from "./capabilities.ts"; import { buildTurnText } from "./transcript.ts"; +import { + assertDaytonaOpaqueSecretsEnabled, + buildDaytonaSecretPlan, + type DaytonaSecretPlan, +} from "./daytona-secret-plan.ts"; type Log = (message: string) => void; @@ -78,19 +83,21 @@ 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 only for Daytona runs. */ + daytonaSecretPlan?: DaytonaSecretPlan; /** - * Back-compat inputs to the OAuth-upload decision (see `shouldUploadOwnLogin`). `legacyHarnessApiKeyVar` - * does not choose the provider; it only feeds the fallback `hasApiKey` heuristic for an un-migrated caller that sends no - * `credentialMode`. + * Harness key name used only to decide whether the harness has an API key after the typed + * connection is materialized. It never selects a provider. */ - legacyHarnessApiKeyVar: string; + harnessApiKeyVar: string; 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; the run then - * falls back to the `hasApiKey` heuristic. Drives clear-then-apply env (Security rule 5) and + * 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) and * the OAuth-upload gate (rule 6). */ credentialMode?: string; @@ -137,7 +144,25 @@ export interface RunPlan { } export type BuildRunPlanResult = - { ok: true; plan: RunPlan } | { ok: false; error: string }; + | { ok: true; plan: RunPlan } + | { ok: false; error: string }; + +const LEGACY_MODEL_CREDENTIAL_FIELDS = [ + "secrets", + "provider", + "deployment", + "credentialMode", + "endpoint", + "connection", +] as const; + +function legacyModelCredentialFields(request: AgentRunRequest): string[] { + if (request.modelConnection) return []; + 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; @@ -245,6 +270,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, { @@ -258,6 +376,15 @@ export function buildRunPlan( ): BuildRunPlanResult { const harness = request.harness || "pi_core"; const sandboxId = request.sandbox || sandboxProvider || "local"; + 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 @@ -290,8 +417,28 @@ export function buildRunPlan( // ships one, and "unknown" must not silently behave like "reachable loopback". const isRemoteSandbox = sandboxId !== "local"; - const secrets = request.secrets ?? {}; - const legacyHarnessApiKeyVar = + const materializedModel = materializeModelEnvironment(request); + if (!materializedModel.ok) return materializedModel; + let daytonaSecretPlan: DaytonaSecretPlan | undefined; + if (isDaytona) { + try { + daytonaSecretPlan = buildDaytonaSecretPlan({ + modelConnection: request.modelConnection, + mcpServers: request.mcpServers, + }); + assertDaytonaOpaqueSecretsEnabled(daytonaSecretPlan); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + // Local keeps its existing direct environment. Daytona removes every opaque_http value and + // passes only non-secret config plus explicitly local_use credentials to sandbox create. + 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); @@ -468,10 +615,11 @@ export function buildRunPlan( prompt, turnText: buildTurnText(request, log), agentsMd: request.agentsMd?.trim() || undefined, - secrets, - legacyHarnessApiKeyVar, - hasApiKey: !!secrets[legacyHarnessApiKeyVar], - credentialMode: request.credentialMode, + modelEnvironment, + daytonaSecretPlan, + harnessApiKeyVar, + hasApiKey: !!modelEnvironment[harnessApiKeyVar], + credentialMode: materializedModel.credentialMode, cwd, relayDir, toolMcpDir, @@ -509,13 +657,12 @@ export function buildRunPlan( * the credential). * - `credentialMode === "runtime_provided"`: upload (the harness authenticates with its login). * - `credentialMode === "none"`: do not upload (no credential asserted). - * - no `credentialMode` on the wire (un-migrated caller): fall back to today's heuristic — - * upload only when no api key was supplied (`!hasApiKey`). + * - no resolved `modelConnection`: use the harness login when no key is materialized. */ export function shouldUploadOwnLogin( plan: Pick, ): boolean { if (plan.credentialMode === "runtime_provided") return true; if (plan.credentialMode) return false; // "env" / "none": a resolved decision, never upload - return !plan.hasApiKey; // back-compat: un-migrated caller, no credentialMode + return !plan.hasApiKey; // direct request with no resolved modelConnection } diff --git a/services/runner/src/engines/sandbox_agent/session-pool.ts b/services/runner/src/engines/sandbox_agent/session-pool.ts index 245bf8c44c..0ec9951879 100644 --- a/services/runner/src/engines/sandbox_agent/session-pool.ts +++ b/services/runner/src/engines/sandbox_agent/session-pool.ts @@ -96,10 +96,7 @@ export function readKeepaliveConfig( approvalTtlMs: ttlMs, // This budgets billed compute (idle warm sandboxes), deliberately separate from the local // pool's host-memory budget; Slice 4 adds the strict warm-slot accounting semantics. - poolMax: positiveIntEnv( - DAYTONA_POOL_MAX_ENV, - DEFAULT_DAYTONA_POOL_MAX, - ), + poolMax: positiveIntEnv(DAYTONA_POOL_MAX_ENV, DEFAULT_DAYTONA_POOL_MAX), }; } return { @@ -148,7 +145,7 @@ 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 + * (`context`), the rotating telemetry headers, and credential VALUES (`modelConnection.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 a credential and * lives in the credential epoch instead. @@ -159,18 +156,35 @@ 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, - credentialMode: request.credentialMode ?? 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, agentsMd: request.agentsMd ?? null, systemPrompt: request.systemPrompt ?? null, appendSystemPrompt: request.appendSystemPrompt ?? null, tools: request.tools ?? null, skills: request.skills ?? null, customTools: request.customTools ?? null, - mcpServers: request.mcpServers ?? null, + mcpServers: + request.mcpServers?.map((server) => ({ + ...server, + credentials: server.credentials?.map((credential) => ({ + binding: credential.binding, + usage: credential.usage, + })), + })) ?? null, toolCallbackEndpoint: request.toolCallback?.endpoint ?? null, permissions: request.permissions ?? null, sandboxPermission: request.sandboxPermission ?? null, @@ -323,17 +337,13 @@ export function tailIsFreshUserMessage(request: AgentRunRequest): boolean { return true; } -/** - * 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 plus the tool-callback auth (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. - */ +/** Credential hashes split by lifecycle so approval resumes ignore only per-turn auth. */ export interface CredentialEpoch { - /** sha256 over canonical(secrets) + tool-callback auth. In-memory only; never surfaced. */ - secretsHash: string; - /** Mount credential expiry as epoch millis, or undefined when the sign response had none. */ + /** Credentials baked into the sandbox/session environment. In-memory only. */ + sandboxCredentialsHash: string; + /** Per-turn callback authorization, expected to be re-minted. In-memory only. */ + transientCredentialsHash: string; + /** Mount credential expiry as epoch millis, or undefined when absent. */ mountExpiresAtMs?: number; } @@ -341,17 +351,43 @@ export function computeCredentialEpoch( request: AgentRunRequest, mountExpiresAt?: string, ): CredentialEpoch { - const material = canonicalJson({ - secrets: request.secrets ?? {}, + const sandboxMaterial = canonicalJson({ + modelCredentials: (request.modelConnection?.credentials ?? []).map( + (credential) => ({ + binding: credential.binding, + value: credential.value, + usage: credential.usage, + }), + ), + mcpCredentials: (request.mcpServers ?? []).flatMap((server) => + (server.credentials ?? []).map((credential) => ({ + server: server.name, + url: server.url ?? null, + binding: credential.binding, + value: credential.value, + usage: credential.usage, + })), + ), + }); + const transientMaterial = canonicalJson({ toolCallbackAuth: request.toolCallback?.authorization ?? null, }); const parsed = mountExpiresAt ? Date.parse(mountExpiresAt) : NaN; return { - secretsHash: sha256(material), + sandboxCredentialsHash: sha256(sandboxMaterial), + transientCredentialsHash: sha256(transientMaterial), mountExpiresAtMs: Number.isFinite(parsed) ? parsed : undefined, }; } +/** True when credentials baked into a parked sandbox/session changed. */ +export function sandboxCredentialsRotated( + parked: CredentialEpoch, + incoming: CredentialEpoch, +): boolean { + return parked.sandboxCredentialsHash !== incoming.sandboxCredentialsHash; +} + /** * Whether a parked session's MOUNT credentials have already expired, ignoring the secret material * hash entirely. This answers only "can the parked environment still write its durable cwd?". @@ -381,7 +417,11 @@ 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 ( + sandboxCredentialsRotated(parked, incoming) || + parked.transientCredentialsHash !== incoming.transientCredentialsHash + ) + return "credentials-rotated"; return undefined; } diff --git a/services/runner/src/extensions/agenta.ts b/services/runner/src/extensions/agenta.ts index 48ce1fb1e0..8d4fcd455f 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 { @@ -356,6 +360,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 = !!( @@ -370,7 +380,22 @@ const factory = (pi: ExtensionAPI): void => { process.env.AGENTA_AGENT_BUILTIN_GRANTS, ); const usageOut = process.env.AGENTA_AGENT_USAGE_CAPTURE_PATH; - if (!hasTracing && !hasTools && !hasBuiltinGating && !usageOut) return; + if ( + !modelProviderOverride && + !hasTracing && + !hasTools && + !hasBuiltinGating && + !usageOut + ) + 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 (hasBuiltinGating) registerBuiltinGating(pi, builtinGrants); 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 5988654c3f..b13301ed56 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -234,23 +234,30 @@ export interface WireSkill { allowExecutableFiles?: boolean; } +/** 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 MCP server attached to the run. `stdio` launches `command`/`args` with - * `env` (secret env already resolved server-side); `tools` is an optional allowlist (empty = - * all). Remote (`http`) carries no auth on the wire by design. + * A user-declared MCP server attached to the run. Non-secret process environment, public HTTP + * headers, and secret HTTP header credentials remain separate by protocol role. */ export interface McpServerConfig { name: string; transport?: "stdio" | "http"; command?: string; args?: string[]; - env?: Record; + environment?: Record; url?: string; + headers?: Record; + credentials?: McpCredential[]; tools?: string[]; /** * Layer-3 permission for the whole server: `allow` / `ask` / `deny`. Absent = - * fall back to the global permission plan. An MCP server has no `readOnly` hint, so there - * is no derived default: an explicit author value or nothing. + * fall back to the global permission plan. */ permission?: ToolPermission; } @@ -384,6 +391,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 @@ -395,8 +428,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; /** @@ -413,39 +444,8 @@ export interface AgentRunRequest { appendSystemPrompt?: string; /** Model id ("gpt-5.5") or "provider/id" ("openai-codex/gpt-5.5"). */ model?: 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). - */ - 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[]; /** Built-in tools to enable. */ diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index a6a8d8bb59..b5712879d3 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -47,6 +47,7 @@ import { configFingerprint, credentialEpochMismatch, mountCredentialsExpired, + sandboxCredentialsRotated, expectedNextHistoryFingerprint, historyFingerprint, poolKeyFor, @@ -642,15 +643,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), and a hard mount-expiry @@ -676,6 +671,10 @@ export async function runWithKeepalive( mismatch = "history"; } else if (mountCredentialsExpired(existing.credentialEpoch)) { mismatch = "credentials-expired"; + } else if ( + sandboxCredentialsRotated(existing.credentialEpoch, incomingEpoch) + ) { + mismatch = "credentials-rotated"; } if (mismatch || !parked || !decision) { diff --git a/services/runner/tests/setup/hermetic-env.ts b/services/runner/tests/setup/hermetic-env.ts index 33d4072156..8845bbfc31 100644 --- a/services/runner/tests/setup/hermetic-env.ts +++ b/services/runner/tests/setup/hermetic-env.ts @@ -12,6 +12,7 @@ const SCRUBBED = [ "AGENTA_INSECURE_EGRESS_ALLOWED", "AGENTA_WEBHOOKS_ALLOW_INSECURE", "AGENTA_WEBHOOK_ALLOW_INSECURE", + "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..390ad865a1 --- /dev/null +++ b/services/runner/tests/unit/daytona-secret-plan.test.ts @@ -0,0 +1,250 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it } from "vitest"; + +import type { AgentRunRequest } 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"; + +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", + transport: "http", + url: "https://mcp.linear.app/rpc", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value: "opaque-mcp-value", + usage: "opaque_http", + }, + ], + }, + ], + }); + + 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", + transport: "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", + }, + ], + }, + ], + }); + 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", + transport: "stdio", + command: "server", + headers: { Accept: "application/json" }, + }, + ], + }), + /require HTTP transport and 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("fails a Daytona run before cwd creation when the gate is off", () => { + let created = false; + const request = { + harness: "claude", + sandbox: "daytona", + messages: [{ role: "user", content: "hello" }], + modelConnection, + } satisfies AgentRunRequest; + const disabled = buildRunPlan(request, { + createDaytonaCwd: () => { + created = true; + return "/unused"; + }, + }); + assert.equal(disabled.ok, false); + assert.equal(created, false); + + 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", + }); + assert.equal(enabled.plan.hasApiKey, false); + 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..9a1a1cc9cb --- /dev/null +++ b/services/runner/tests/unit/daytona-secret-provider.test.ts @@ -0,0 +1,446 @@ +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"; + +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", + transport: "http", + url: "https://mcp.linear.app/rpc", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value: "mcp-plaintext", + usage: "opaque_http", + }, + ], + }, +]; + +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], headers: { "X-Foo": "mcp-public-plaintext" } }, + ])!; + assert.equal(materialized[0].credentials?.[0].value, "dtn_secret_2"); + assert.equal(materialized[0].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].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", + ]); + }); + + for (const [name, oldFingerprint, newFingerprint] of [ + ["local_use rotation", "local-use-old", "local-use-new"], + ["custom endpoint override rotation", "endpoint-old", "endpoint-new"], + ] as const) { + it(`deletes instead of reconnecting after ${name} with no opaque Secret candidates`, async () => { + const events: string[] = []; + const registry = new Map(); + const directPlan: DaytonaSecretPlan = { + environment: { + AWS_REGION: "us-east-1", + AWS_PROFILE: "profile-a", + }, + candidates: [], + }; + const first = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + directPlan, + secretApi(events), + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: oldFingerprint, + }, + ); + const id = await first.create(); + const second = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + directPlan, + secretApi(events), + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: newFingerprint, + }, + ); + + 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"); + }); + } +}); 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 9662aeab1d..c84ee1fa58 100644 --- a/services/runner/tests/unit/extension-tools.test.ts +++ b/services/runner/tests/unit/extension-tools.test.ts @@ -27,6 +27,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", @@ -38,6 +39,7 @@ const TOOL_ENV = [ "AGENTA_AGENT_CONTENT_CAPTURE_ENABLED", "AGENTA_AGENT_BUILTIN_GATING", "AGENTA_AGENT_BUILTIN_GRANTS", + PI_MODEL_PROVIDER_OVERRIDE_ENV, ]; /** A fake extension UI context whose `confirm` records its calls and returns a scripted answer. */ @@ -60,14 +62,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); }, @@ -89,6 +96,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 1bc8bb27c7..65f4f57b56 100644 --- a/services/runner/tests/unit/mcp-servers.test.ts +++ b/services/runner/tests/unit/mcp-servers.test.ts @@ -1,104 +1,177 @@ -/** - * Unit tests for the user-declared MCP server conversion — HTTP enabled, stdio disabled. - * - * `resolve_mcp_servers` (Python) emits the McpServerConfig wire shape; the wire is unchanged. - * - * - HTTP (`transport: "http"` + `url`) is DELIVERED. A remote server has no child process on the - * runner host: the harness connects to the URL with the named secret in a request header, so it - * does not bypass the sandbox boundary. The resolved secret arrives on the wire under the - * server's `env` map (the SDK resolver merges named secrets into `env` regardless of transport, - * and the wire has no separate `headers` field), so each `env` entry becomes an HTTP header. - * - STDIO is DISABLED: a stdio server runs an arbitrary process on the RUNNER HOST, outside the - * sandbox boundary, so it throws `USER_MCP_UNSUPPORTED_MESSAGE` (parity with the removed code - * execution) until its security is fixed. - * - A command-less stdio server or a url-less http server was never deliverable and is skipped. - * - * The SSRF guard resolves hostnames via DNS (`tools/ssrf-guard.ts`), so tests here use IP literals - * to stay network-free; the DNS-resolution path itself is covered in `ssrf-guard.test.ts`. - * - * Run: pnpm test (or: pnpm exec vitest run tests/unit/mcp-servers.test.ts) - */ +/** Unit tests for typed user HTTP-MCP credentials and SSRF validation. */ import { afterEach, 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 { USER_MCP_UNSUPPORTED_MESSAGE } from "../../src/tools/mcp-bridge.ts"; import type { McpServerConfig } from "../../src/protocol.ts"; -describe("toAcpMcpServers (http enabled, stdio disabled)", () => { +const credential = (name = "Authorization", value = "Bearer secret") => ({ + binding: { kind: "header" as const, name }, + value, + usage: "opaque_http" as const, +}); +const http = (url: string): McpServerConfig[] => [ + { + name: "s", + transport: "http", + url, + credentials: [credential()], + }, +]; + +describe("toAcpMcpServers typed credential materialization", () => { it("maps empty input to []", async () => { - assert.deepEqual(await toAcpMcpServers(undefined), [], "undefined -> []"); - assert.deepEqual(await toAcpMcpServers([]), [], "[] -> []"); + assert.deepEqual(await toAcpMcpServers(undefined), []); + assert.deepEqual(await toAcpMcpServers([]), []); }); - it("throws the unsupported error for a stdio server", async () => { - const servers: McpServerConfig[] = [ - { - name: "github", - transport: "stdio", - command: "npx", - args: ["-y", "@modelcontextprotocol/server-github"], - env: { GITHUB_PERSONAL_ACCESS_TOKEN: "ghp_x", LOG_LEVEL: "info" }, - }, - ]; + it("keeps stdio disabled even with non-secret process environment", async () => { await assert.rejects( - () => toAcpMcpServers(servers), + () => + toAcpMcpServers([ + { + name: "github", + transport: "stdio", + command: "npx", + environment: { LOG_LEVEL: "info" }, + }, + ]), new RegExp(USER_MCP_UNSUPPORTED_MESSAGE), ); }); - it("delivers an http server, routing the resolved secret into a header", async () => { + it("combines public headers and secret bindings only at the ACP boundary", async () => { const out = await toAcpMcpServers([ { name: "linear", transport: "http", - url: "https://93.184.216.34/sse", - // The SDK resolver put the resolved `linear-mcp-token` secret here under the - // author-chosen header name "Authorization"; the wire has no separate `headers` key. - env: { Authorization: "Bearer secret-token-value" }, + url: "https://93.184.216.34:8443/sse", + headers: { "X-Client": "agenta" }, + credentials: [credential("Authorization", "Bearer secret-token-value")], }, ]); - - assert.equal(out.length, 1, "one server delivered"); const server = out[0] as McpServerHttp; - assert.equal(server.type, "http", "ACP http variant"); - assert.equal(server.name, "linear"); - assert.equal(server.url, "https://93.184.216.34/sse"); - assert.deepEqual( - server.headers, - [{ name: "Authorization", value: "Bearer secret-token-value" }], - "env entry -> request header", + 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 () => { + it("delivers a public HTTP server with no headers", async () => { const out = await toAcpMcpServers([ { name: "public", transport: "http", url: "https://93.184.216.34/sse" }, ]); - const server = out[0] as McpServerHttp; - assert.equal(server.type, "http"); - assert.deepEqual(server.headers, [], "no env -> no headers"); + assert.deepEqual((out[0] as McpServerHttp).headers, []); }); - it("does not throw on a mix of http (delivered) and command-less stdio (skipped)", async () => { + it("skips structurally incomplete servers", async () => { const out = await toAcpMcpServers([ { name: "remote", transport: "http", url: "https://93.184.216.34/mcp", - env: { "X-Api-Key": "k" }, + credentials: [credential("X-Api-Key", "k-secret")], }, - { name: "broken", transport: "stdio" }, // no command -> never launched, skipped - { name: "no-url", transport: "http" }, // no url -> never deliverable, skipped + { name: "broken", transport: "stdio" }, + { name: "no-url", transport: "http" }, ]); + assert.deepEqual( + out.map((server) => server.name), + ["remote"], + ); + }); +}); + +describe("validateUserMcpServers role validation", () => { + it("rejects process environment on HTTP and HTTP fields on stdio", async () => { + await assert.rejects( + () => + validateUserMcpServers([ + { + name: "s", + transport: "http", + url: "https://93.184.216.34", + environment: { TOKEN: "x" }, + }, + ]), + /cannot carry process environment/, + ); + await assert.rejects( + () => + validateUserMcpServers([ + { name: "s", transport: "stdio", command: "x", headers: { X: "y" } }, + ]), + /cannot carry HTTP headers/, + ); + }); + + it("rejects empty or malformed credentials and headers", async () => { + const cases: McpServerConfig[] = [ + { + name: "s", + transport: "http", + url: "https://93.184.216.34", + headers: { "": "x" }, + }, + { + name: "s", + transport: "http", + url: "https://93.184.216.34", + headers: { X: "" }, + }, + { + name: "s", + transport: "http", + url: "https://93.184.216.34", + credentials: [credential("", "secret")], + }, + { + name: "s", + transport: "http", + url: "https://93.184.216.34", + credentials: [credential("X", "")], + }, + { + name: "s", + transport: "http", + url: "https://93.184.216.34", + credentials: [ + { ...credential("X", "secret"), usage: "environment" as never }, + ], + }, + ]; + for (const server of cases) + await assert.rejects(() => validateUserMcpServers([server])); + }); - assert.equal(out.length, 1, "only the valid http server is delivered"); - assert.equal((out[0] as McpServerHttp).name, "remote"); + it("rejects duplicate public and secret bindings case-insensitively", async () => { + await assert.rejects( + () => + validateUserMcpServers([ + { + name: "s", + transport: "http", + url: "https://93.184.216.34", + headers: { Authorization: "public" }, + credentials: [credential("authorization", "secret")], + }, + ]), + /duplicate HTTP MCP header binding/, + ); }); }); -describe("toAcpMcpServers SSRF guard (http url scheme/host)", () => { +describe("HTTP-MCP SSRF guard", () => { const previousAllowlist = process.env.AGENTA_AGENT_MCPS_HOST_ALLOWLIST; afterEach(() => { if (previousAllowlist === undefined) @@ -106,91 +179,50 @@ describe("toAcpMcpServers SSRF guard (http url scheme/host)", () => { else process.env.AGENTA_AGENT_MCPS_HOST_ALLOWLIST = previousAllowlist; }); - const http = (url: string): McpServerConfig[] => [ - { - name: "s", - transport: "http", - url, - env: { Authorization: "Bearer secret" }, - }, - ]; - - it("rejects a non-https url (the secret would ride in clear text)", async () => { + it("always rejects non-HTTPS, including allowlisted hosts", async () => { + process.env.AGENTA_AGENT_MCPS_HOST_ALLOWLIST = "localhost"; await assert.rejects( - () => toAcpMcpServers(http("http://93.184.216.34/sse")), + () => toAcpMcpServers(http("http://localhost:9000/mcp")), /must use https/, ); }); - it("rejects the cloud metadata host (169.254.169.254)", async () => { - await assert.rejects( - () => toAcpMcpServers(http("https://169.254.169.254/latest/meta-data/")), - /internal\/metadata host/, - ); - }); - - it("rejects localhost and loopback / private literals", async () => { + it("rejects metadata, localhost, private, malformed, and address-evasion URLs", async () => { for (const url of [ + "https://169.254.169.254/latest/meta-data/", "https://localhost/mcp", "https://127.0.0.1/mcp", "https://10.0.0.5/mcp", "https://192.168.1.10/mcp", "https://172.16.4.4/mcp", "https://[::1]/mcp", - ]) { - await assert.rejects( - () => toAcpMcpServers(http(url)), - /internal\/metadata host/, - `should reject ${url}`, - ); - } - }); - - it("rejects hex/octal/integer IPv4 and IPv4-mapped IPv6 evasions", async () => { - for (const url of [ - "https://0x7f000001/mcp", // hex IPv4 -> 127.0.0.1 - "https://017700000001/mcp", // octal IPv4 -> 127.0.0.1 - "https://2130706433/mcp", // integer IPv4 -> 127.0.0.1 - "https://[::ffff:127.0.0.1]/mcp", // IPv4-mapped IPv6 loopback - "https://[::ffff:169.254.169.254]/mcp", // IPv4-mapped IPv6 metadata host - ]) { + "https://0x7f000001/mcp", + "https://017700000001/mcp", + "https://2130706433/mcp", + "https://[::ffff:127.0.0.1]/mcp", + ]) await assert.rejects( () => toAcpMcpServers(http(url)), /internal\/metadata host/, - `should reject ${url}`, ); - } - }); - - it("rejects a malformed url", async () => { await assert.rejects( () => toAcpMcpServers(http("not a url")), /not a valid URL/, ); }); - it("allows a public ip literal unchanged", async () => { - const out = await toAcpMcpServers(http("https://93.184.216.34/sse")); - assert.equal(out.length, 1); - assert.equal((out[0] as McpServerHttp).url, "https://93.184.216.34/sse"); - }); - - 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. - const out = await toAcpMcpServers(http("http://localhost:9000/mcp")); - assert.equal(out.length, 1, "allowlisted host is delivered"); - assert.equal((out[0] as McpServerHttp).url, "http://localhost:9000/mcp"); - // A private IP literal in the allowlist is also permitted. + it("allows a public IP unchanged and an explicitly allowlisted HTTPS private host", async () => { + const publicOut = await toAcpMcpServers( + http("https://93.184.216.34:8443/sse"), + ); + assert.equal( + (publicOut[0] as McpServerHttp).url, + "https://93.184.216.34:8443/sse", + ); + process.env.AGENTA_AGENT_MCPS_HOST_ALLOWLIST = "10.0.0.5"; assert.equal( (await toAcpMcpServers(http("https://10.0.0.5/mcp"))).length, 1, - "allowlisted private literal delivered", - ); - // A host NOT in the allowlist is still rejected. - await assert.rejects( - () => toAcpMcpServers(http("https://127.0.0.1/mcp")), - /internal\/metadata host/, ); }); }); diff --git a/services/runner/tests/unit/sandbox-agent-daemon.test.ts b/services/runner/tests/unit/sandbox-agent-daemon.test.ts index 349ac05daa..d56d761d58 100644 --- a/services/runner/tests/unit/sandbox-agent-daemon.test.ts +++ b/services/runner/tests/unit/sandbox-agent-daemon.test.ts @@ -59,7 +59,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-daytona.test.ts b/services/runner/tests/unit/sandbox-agent-daytona.test.ts index 7582f9aabd..144b29e037 100644 --- a/services/runner/tests/unit/sandbox-agent-daytona.test.ts +++ b/services/runner/tests/unit/sandbox-agent-daytona.test.ts @@ -17,8 +17,10 @@ import { createCookieFetch, daytonaEnvVars, installPiInSandbox, + prepareDaytonaPiAssets, uploadPiAuthToSandbox, } from "../../src/engines/sandbox_agent/daytona.ts"; +import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../src/extensions/model-provider-override.ts"; const envKeys = ["PI_CODING_AGENT_DIR", "AGENTA_AGENT_SANDBOX_PI_INSTALLED"]; const previousEnv = new Map(); @@ -110,6 +112,59 @@ describe("installPiInSandbox", () => { }); }); +describe("prepareDaytonaPiAssets", () => { + const piPlan = { + isPi: true, + hasApiKey: true, + credentialMode: "env" as const, + skillDirs: [], + hasSystemPrompt: false, + systemPrompt: undefined, + appendSystemPrompt: undefined, + }; + + function brokenExtensionUploadSandbox() { + return { + mkdirFs: async () => {}, + writeFsFile: async () => { + throw new Error("remote write denied"); + }, + runProcess: async () => ({ exitCode: 0 }), + }; + } + + it("fails instead of falling back to the provider default when override upload fails", async () => { + await assert.rejects( + () => + prepareDaytonaPiAssets({ + sandbox: brokenExtensionUploadSandbox(), + extensionEnv: { + [PI_MODEL_PROVIDER_OVERRIDE_ENV]: JSON.stringify({ + provider: "anthropic", + baseUrl: "https://proxy.example.test/anthropic", + }), + }, + plan: piPlan, + }), + /model endpoint override requires the Agenta extension.*remote write denied/, + ); + }); + + it("keeps Daytona extension upload best-effort without an endpoint override", async () => { + const logs: string[] = []; + + await assert.doesNotReject(() => + prepareDaytonaPiAssets({ + sandbox: brokenExtensionUploadSandbox(), + extensionEnv: {}, + plan: piPlan, + log: (message) => logs.push(message), + }), + ); + assert.ok(logs.some((message) => message.includes("upload skipped"))); + }); +}); + describe("uploadPiAuthToSandbox", () => { it("uploads local Pi auth and settings when present", async () => { const agentDir = mkdtempSync(join(tmpdir(), "agenta-pi-auth-test-")); diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index 2531ea567a..b2f034d078 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -84,6 +84,7 @@ function fakeHarness(options: FakeOptions = {}) { runFinished: 0, runFlushed: 0, recordedErrors: [] as Array<{ message: string; provider?: string }>, + handledUpdates: [] as unknown[], }; const events: AgentEvent[] = []; let eventHandler: ((event: any) => void) | undefined; @@ -172,7 +173,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); }, @@ -301,6 +304,67 @@ describe("PendingApprovalPauseController", () => { }); describe("runSandboxAgent orchestration", () => { + it("redacts resolved credentials from traces, events, results, errors, and logs", async () => { + const secret = "marker-model-secret-7f31"; + const success = fakeHarness({ + output: `assistant echoed ${secret}`, + promptEvent: { + payload: { update: { kind: "message", text: `event ${secret}` } }, + }, + }); + const logs: string[] = []; + success.deps.log = (line) => logs.push(line); + const request: AgentRunRequest = { + harness: "claude", + messages: [{ role: "user", content: `please use ${secret}` }], + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: secret, + usage: "opaque_http", + }, + ], + }, + }; + const result = await runSandboxAgent( + request, + undefined, + undefined, + success.deps, + ); + const observable = JSON.stringify({ + result, + traceStart: success.calls.runStart, + updates: success.calls.handledUpdates, + logs, + }); + assert.doesNotMatch(observable, new RegExp(secret)); + assert.match(observable, /ag:redacted/); + assert.match( + JSON.stringify(success.calls.promptBlocks), + new RegExp(secret), + "the harness still receives the real prompt", + ); + + const failure = fakeHarness({ + promptError: new Error(`provider rejected ${secret}`), + }); + const failed = await runSandboxAgent( + request, + undefined, + undefined, + failure.deps, + ); + assert.equal(failed.ok, false); + assert.doesNotMatch(JSON.stringify(failed), new RegExp(secret)); + assert.match(JSON.stringify(failed), /ag:redacted/); + }); + it("returns a successful one-shot result and cleans up acquired resources", async () => { const { calls, deps } = fakeHarness(); @@ -380,8 +444,7 @@ describe("runSandboxAgent orchestration", () => { assert.equal(result.ok, true); assert.equal( - (calls.providerArgs[1] as Record) - .AGENTA_AGENT_MOUNT_DIR, + (calls.providerArgs[1] as Record).AGENTA_AGENT_MOUNT_DIR, `${cwd}-agent`, ); assert.match( @@ -424,8 +487,7 @@ describe("runSandboxAgent orchestration", () => { assert.equal(result.ok, true); assert.equal( - (calls.providerArgs[1] as Record) - .AGENTA_AGENT_MOUNT_DIR, + (calls.providerArgs[1] as Record).AGENTA_AGENT_MOUNT_DIR, undefined, ); assert.equal(calls.workspacePlan.appendSystemPrompt, undefined); @@ -1180,9 +1242,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, @@ -1259,10 +1331,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, @@ -1290,11 +1374,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, @@ -1316,7 +1417,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, @@ -1329,6 +1435,27 @@ describe("runSandboxAgent orchestration", () => { const env = calls.providerArgs[1] as Record; assert.equal(env.ANTHROPIC_BASE_URL, undefined); }); + + 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); + assert.deepEqual(calls.daemonOptions, { clearProviderEnv: true }); + }); }); // These exercise the engine's default ApprovalResponder by dropping the `responderFactory` @@ -1597,7 +1724,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", + }, }, }, }, @@ -1612,6 +1742,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, @@ -1636,6 +1779,11 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { isError: true, }, ]); + assert.doesNotMatch( + JSON.stringify(result.events), + /marker-late-secret-9a21/, + ); + assert.match(JSON.stringify(result.events), /ag:redacted/); }); it("emits the deferred sibling result before the paused turn is done", async () => { @@ -1652,21 +1800,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; @@ -1677,6 +1827,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, @@ -1688,22 +1851,51 @@ 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", + ); + assert.doesNotMatch(JSON.stringify(emitted), /marker-live-secret-42bd/); + assert.match(JSON.stringify(emitted), /ag:redacted/); + 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 662f66a324..141a771e25 100644 --- a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts +++ b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts @@ -18,6 +18,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, prepareLocalAgentDir, @@ -42,6 +43,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: { @@ -149,6 +220,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)", () => { @@ -391,6 +463,44 @@ describe("prepareLocalPiAssets (PI_CODING_AGENT_DIR guard)", () => { assert.ok(!logs.some((m) => m.includes("PI_CODING_AGENT_DIR is unset"))); }); + + it("fails instead of falling back to the provider default when an override extension cannot be installed", () => { + const dir = tempDir("agenta-pi-broken-agent-dir-"); + const notADir = join(dir, "agent-file"); + writeFileSync(notADir, "not a directory", "utf-8"); + process.env[ENV_VAR] = notADir; + + assert.throws( + () => + prepareLocalPiAssets({ + plan: plainPiPlan, + env: { + [PI_MODEL_PROVIDER_OVERRIDE_ENV]: JSON.stringify({ + provider: "anthropic", + baseUrl: "https://proxy.example.test/anthropic", + }), + }, + }), + /model endpoint override requires the Agenta extension/, + ); + }); + + it("keeps local extension installation best-effort without an endpoint override", () => { + const dir = tempDir("agenta-pi-broken-agent-dir-"); + const notADir = join(dir, "agent-file"); + writeFileSync(notADir, "not a directory", "utf-8"); + process.env[ENV_VAR] = notADir; + const logs: string[] = []; + + assert.doesNotThrow(() => + prepareLocalPiAssets({ + plan: plainPiPlan, + env: {}, + log: (message) => logs.push(message), + }), + ); + assert.ok(logs.some((message) => message.includes("install skipped"))); + }); }); describe("sandbox uploads", () => { diff --git a/services/runner/tests/unit/sandbox-agent-provider.test.ts b/services/runner/tests/unit/sandbox-agent-provider.test.ts index 62b1b47dfc..531994d330 100644 --- a/services/runner/tests/unit/sandbox-agent-provider.test.ts +++ b/services/runner/tests/unit/sandbox-agent-provider.test.ts @@ -17,13 +17,17 @@ import { DEFAULT_DAYTONA_AUTODELETE_MINUTES, buildDaytonaCreate, buildSandboxProvider, + daytonaCreateFingerprint, daytonaAutoStopMinutes, daytonaAutoDeleteMinutes, daytonaNetworkFields, } from "../../src/engines/sandbox_agent/provider.ts"; +import { buildDaytonaSecretPlan } from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; const LIFECYCLE_ENVS = ["DAYTONA_AUTOSTOP", "DAYTONA_AUTODELETE"]; -const previous = Object.fromEntries(LIFECYCLE_ENVS.map((k) => [k, process.env[k]])); +const previous = Object.fromEntries( + LIFECYCLE_ENVS.map((k) => [k, process.env[k]]), +); afterEach(() => { for (const k of LIFECYCLE_ENVS) { @@ -74,6 +78,48 @@ 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(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("daytona lifecycle interval parsers", () => { it("use the env value when it is a positive integer", () => { assert.equal(daytonaAutoStopMinutes("30"), 30); @@ -85,26 +131,75 @@ describe("daytona lifecycle interval parsers", () => { }); it("fall back to their defaults when the env is unset", () => { - assert.equal(daytonaAutoStopMinutes(undefined), DEFAULT_DAYTONA_AUTOSTOP_MINUTES); - assert.equal(daytonaAutoDeleteMinutes(undefined), DEFAULT_DAYTONA_AUTODELETE_MINUTES); + assert.equal( + daytonaAutoStopMinutes(undefined), + DEFAULT_DAYTONA_AUTOSTOP_MINUTES, + ); + assert.equal( + daytonaAutoDeleteMinutes(undefined), + DEFAULT_DAYTONA_AUTODELETE_MINUTES, + ); }); it("fall back to the default for a non-numeric env value", () => { - assert.equal(daytonaAutoStopMinutes("soon"), DEFAULT_DAYTONA_AUTOSTOP_MINUTES); + assert.equal( + daytonaAutoStopMinutes("soon"), + DEFAULT_DAYTONA_AUTOSTOP_MINUTES, + ); }); it("clamp 0 and negatives to the default (a disabled reaper would leak)", () => { assert.equal(daytonaAutoStopMinutes("0"), DEFAULT_DAYTONA_AUTOSTOP_MINUTES); - assert.equal(daytonaAutoDeleteMinutes("-5"), DEFAULT_DAYTONA_AUTODELETE_MINUTES); + assert.equal( + daytonaAutoDeleteMinutes("-5"), + DEFAULT_DAYTONA_AUTODELETE_MINUTES, + ); }); it("orders the defaults stop before delete", () => { assert.ok(DEFAULT_DAYTONA_AUTOSTOP_MINUTES >= 1); - assert.ok(DEFAULT_DAYTONA_AUTOSTOP_MINUTES < DEFAULT_DAYTONA_AUTODELETE_MINUTES); + assert.ok( + DEFAULT_DAYTONA_AUTOSTOP_MINUTES < DEFAULT_DAYTONA_AUTODELETE_MINUTES, + ); }); }); describe("buildDaytonaCreate (lifecycle 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( + { 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: "/home/sandbox/.pi/agent", + PUBLIC_EXTENSION_CONFIG: "enabled", + AWS_REGION: "us-east-1", + PI_ACP_PI_COMMAND: "/home/sandbox/.agenta-pi/node_modules/.bin/pi", + }); + assert.equal(JSON.stringify(create).includes(opaque), false); + }); + it("carries stop and delete intervals without auto-archive by default", () => { for (const k of LIFECYCLE_ENVS) delete process.env[k]; const create = buildDaytonaCreate({}, {}, undefined); 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 cb2bb516ef..f36cf779b3 100644 --- a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts +++ b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts @@ -63,7 +63,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", @@ -98,6 +110,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), @@ -647,7 +660,10 @@ describe("buildRunPlan", () => { assert.equal(result.ok, false); if (result.ok) return; - assert.match(result.error, /non-Pi harness on this remote sandbox provider/); + assert.match( + result.error, + /non-Pi harness on this remote sandbox provider/, + ); assert.match(result.error, /proven for Daytona only/); assert.match( result.error, @@ -971,13 +987,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", }, { @@ -992,8 +1020,9 @@ 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.hasApiKey, true); + assert.equal(result.plan.harnessApiKeyVar, "ANTHROPIC_API_KEY"); + assert.equal(result.plan.hasApiKey, false); + assert.equal(result.plan.modelEnvironment.ANTHROPIC_API_KEY, undefined); // The resolved credentialMode is carried onto the plan (drives clear-then-apply + the // OAuth-upload gate). assert.equal(result.plan.credentialMode, "env"); @@ -1151,8 +1180,8 @@ describe("shouldUploadOwnLogin", () => { ); }); - it("falls back to the hasApiKey heuristic for an un-migrated caller (no credentialMode)", () => { - // No credentialMode on the wire: upload only when no api key was supplied (today's behavior). + it("falls back to the hasApiKey heuristic when no modelConnection was resolved", () => { + // A direct runner request without modelConnection may still use the harness login. assert.equal( shouldUploadOwnLogin({ credentialMode: undefined, hasApiKey: false }), true, @@ -1163,3 +1192,173 @@ describe("shouldUploadOwnLogin", () => { ); }); }); + +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, () => { + 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", () => { + for (const field of [ + "secrets", + "provider", + "deployment", + "credentialMode", + "endpoint", + "connection", + ]) { + 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); + } + }); +}); diff --git a/services/runner/tests/unit/session-keepalive-approval.test.ts b/services/runner/tests/unit/session-keepalive-approval.test.ts index b24ebf2049..d9a1c3cad4 100644 --- a/services/runner/tests/unit/session-keepalive-approval.test.ts +++ b/services/runner/tests/unit/session-keepalive-approval.test.ts @@ -577,13 +577,27 @@ describe("runWithKeepalive: approval resume validation degrades to cold", () => }); }); -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: { @@ -596,48 +610,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); }); }); diff --git a/services/runner/tests/unit/session-keepalive-dispatch.test.ts b/services/runner/tests/unit/session-keepalive-dispatch.test.ts index 7c3dcb58ca..25a239f30e 100644 --- a/services/runner/tests/unit/session-keepalive-dispatch.test.ts +++ b/services/runner/tests/unit/session-keepalive-dispatch.test.ts @@ -466,7 +466,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-keepalive-engine.test.ts b/services/runner/tests/unit/session-keepalive-engine.test.ts index 28e26decf0..0717a435ea 100644 --- a/services/runner/tests/unit/session-keepalive-engine.test.ts +++ b/services/runner/tests/unit/session-keepalive-engine.test.ts @@ -222,6 +222,81 @@ describe("acquireEnvironment / runTurn split", () => { assert.equal(calls.sandboxDestroyed, 1); }); + it("extends the redactor with every fresh continuation credential before the turn", async () => { + const { deps } = fakeHarness(); + const acquired = await acquireEnvironment(request, deps); + assert.equal(acquired.ok, true); + if (!acquired.ok) return; + const values = { + model: "fresh-model-secret", + mcp: "fresh-mcp-secret", + run: "Bearer fresh-run-secret", + callback: "Bearer fresh-callback-secret", + otlp: "Bearer fresh-otlp-secret", + }; + const continuation: AgentRunRequest = { + harness: "claude", + messages: [{ role: "user", content: "again" }], + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: values.model, + usage: "opaque_http", + }, + ], + }, + mcpServers: [ + { + name: "linear", + transport: "http", + url: "https://mcp.linear.app", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value: values.mcp, + usage: "opaque_http", + }, + ], + }, + ], + toolCallback: { + endpoint: "https://callback.example", + authorization: values.callback, + }, + telemetry: { + exporters: { + otlp: { + endpoint: "https://otel.example/v1/traces", + headers: { authorization: values.otlp }, + }, + }, + }, + }; + // The run credential is the same OTLP authorization field on the current wire. + continuation.telemetry!.exporters!.otlp!.headers!.authorization = + values.run; + await runTurn(acquired.env, continuation, undefined, undefined, { + continuation: true, + }); + for (const value of [ + values.model, + values.mcp, + values.run, + values.callback, + ]) { + assert.doesNotMatch( + acquired.env.redactor.redactString(value)!, + new RegExp(value), + ); + } + await acquired.env.destroy(); + }); + it("destroy is idempotent: a second destroy is a no-op", async () => { const { calls, deps } = fakeHarness(); const acquired = await acquireEnvironment(request, deps); diff --git a/services/runner/tests/unit/session-mcp-layering.test.ts b/services/runner/tests/unit/session-mcp-layering.test.ts index b2cba4867b..3fae90e897 100644 --- a/services/runner/tests/unit/session-mcp-layering.test.ts +++ b/services/runner/tests/unit/session-mcp-layering.test.ts @@ -34,6 +34,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 }; @@ -111,7 +117,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = name: "linear", transport: "http", url: "https://mcp.linear.app/sse", - env: { Authorization: "Bearer x" }, + credentials: [credential("Authorization", "Bearer x")], }, ], relayDir, @@ -468,7 +474,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = name: "linear", transport: "http", url: "https://mcp.linear.app/sse", - env: { Authorization: "Bearer x" }, + credentials: [credential("Authorization", "Bearer x")], }, ], relayDir, diff --git a/services/runner/tests/unit/session-pool.test.ts b/services/runner/tests/unit/session-pool.test.ts index b6c4b88346..194d38b4b7 100644 --- a/services/runner/tests/unit/session-pool.test.ts +++ b/services/runner/tests/unit/session-pool.test.ts @@ -77,7 +77,10 @@ function fakeEnv() { }; } -const epoch: CredentialEpoch = { secretsHash: "h" }; +const epoch: CredentialEpoch = { + sandboxCredentialsHash: "h", + transientCredentialsHash: "t", +}; function parkInput(key: string, env = fakeEnv()) { return { @@ -232,13 +235,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" } } }, }, @@ -251,6 +281,30 @@ describe("configFingerprint", () => { ); }); + it("ignores MCP credential values while retaining their binding contract", () => { + const withMcp = (value: string): AgentRunRequest => ({ + ...base, + mcpServers: [ + { + name: "linear", + transport: "http", + url: "https://mcp.linear.app/sse", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value, + usage: "opaque_http", + }, + ], + }, + ], + }); + assert.equal( + configFingerprint(withMcp("secret-a")), + configFingerprint(withMcp("secret-b")), + ); + }); + it("changes when a config-bearing field changes (model)", () => { assert.notEqual( configFingerprint(base), @@ -409,31 +463,118 @@ describe("tailIsFreshUserMessage", () => { describe("credential epoch", () => { it("same secrets + tool-callback auth hash equal; a changed secret value differs", () => { const a = computeCredentialEpoch({ - secrets: { A: "1" }, + modelConnection: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "1", + usage: "opaque_http", + }, + ], + }, toolCallback: { endpoint: "e", authorization: "z" }, }); const b = computeCredentialEpoch({ - secrets: { A: "1" }, + modelConnection: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "1", + usage: "opaque_http", + }, + ], + }, toolCallback: { endpoint: "e", authorization: "z" }, }); const c = computeCredentialEpoch({ - secrets: { A: "2" }, + modelConnection: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "2", + usage: "opaque_http", + }, + ], + }, toolCallback: { endpoint: "e", authorization: "z" }, }); - assert.equal(a.secretsHash, b.secretsHash); + assert.equal(a.sandboxCredentialsHash, b.sandboxCredentialsHash); assert.notEqual( - a.secretsHash, - c.secretsHash, + a.sandboxCredentialsHash, + c.sandboxCredentialsHash, "a rotated same-slug secret changes the hash", ); }); + it("rotates the sandbox epoch when an MCP header credential changes", () => { + const withMcp = (value: string): AgentRunRequest => ({ + mcpServers: [ + { + name: "linear", + transport: "http", + url: "https://mcp.linear.app/sse", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value, + usage: "opaque_http", + }, + ], + }, + ], + }); + assert.notEqual( + computeCredentialEpoch(withMcp("secret-a")).sandboxCredentialsHash, + computeCredentialEpoch(withMcp("secret-b")).sandboxCredentialsHash, + ); + }); + it("valid until the mount expiry elapses; invalid once expired", () => { const parked = computeCredentialEpoch( - { secrets: { A: "1" } }, + { + modelConnection: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "1", + usage: "opaque_http", + }, + ], + }, + }, "2026-01-01T00:00:10.000Z", ); - const incoming = computeCredentialEpoch({ secrets: { A: "1" } }); + const incoming = computeCredentialEpoch({ + modelConnection: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "1", + usage: "opaque_http", + }, + ], + }, + }); 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); @@ -445,18 +586,88 @@ 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: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "1", + usage: "opaque_http", + }, + ], + }, + }); + const incoming = computeCredentialEpoch({ + modelConnection: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "2", + usage: "opaque_http", + }, + ], + }, + }); assert.equal(credentialEpochValid(parked, incoming, Date.now()), false); }); it("credentialEpochMismatch splits the reason: expired vs rotated vs none", () => { const parked = computeCredentialEpoch( - { secrets: { A: "1" } }, + { + modelConnection: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "1", + usage: "opaque_http", + }, + ], + }, + }, "2026-01-01T00:00:10.000Z", ); - const same = computeCredentialEpoch({ secrets: { A: "1" } }); - const rotated = computeCredentialEpoch({ secrets: { A: "2" } }); + const same = computeCredentialEpoch({ + modelConnection: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "1", + usage: "opaque_http", + }, + ], + }, + }); + const rotated = computeCredentialEpoch({ + modelConnection: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "2", + usage: "opaque_http", + }, + ], + }, + }); 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); @@ -475,9 +686,23 @@ describe("credential epoch", () => { ); }); - it("mountCredentialsExpired checks only the mount lifetime, ignoring the secret hash", () => { + it("mountCredentialsExpired checks only the mount lifetime, ignoring the sandbox credential hash", () => { const parked = computeCredentialEpoch( - { secrets: { A: "1" } }, + { + modelConnection: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "1", + usage: "opaque_http", + }, + ], + }, + }, "2026-01-01T00:00:10.000Z", ); const before = Date.parse("2026-01-01T00:00:05.000Z"); @@ -485,7 +710,21 @@ 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: { + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "A" }, + value: "1", + usage: "opaque_http", + }, + ], + }, + }); assert.equal(mountCredentialsExpired(noExpiry, after), false); }); }); @@ -603,11 +842,9 @@ describe("SessionPool", () => { stoppingEnv.state.reasons.push(reason); }, }; - const pool = new SessionPool( - { poolMax: 1 }, - () => {}, - { strictCapacity: true }, - ); + const pool = new SessionPool({ poolMax: 1 }, () => {}, { + strictCapacity: true, + }); await pool.park(parkInput("a", stoppingEnv).input, 10_000); const replacement = parkInput("b"); @@ -622,17 +859,19 @@ describe("SessionPool", () => { releaseTeardown?.(); assert.equal(await parked, true); - assert.equal(teardownCompleted, true, "teardown completes before park resolves"); + assert.equal( + teardownCompleted, + true, + "teardown completes before park resolves", + ); assert.equal(pool.get("a"), undefined); assert.equal(pool.get("b")?.state, "idle"); }); it("strict capacity returns false at cap when no idle entry exists", async () => { - const pool = new SessionPool( - { poolMax: 1 }, - () => {}, - { strictCapacity: true }, - ); + const pool = new SessionPool({ poolMax: 1 }, () => {}, { + strictCapacity: true, + }); const busy = parkInput("busy"); await pool.park(busy.input, 10_000); pool.checkoutIdle("busy"); @@ -645,16 +884,10 @@ describe("SessionPool", () => { }); it("strict approval checkout stays seated while it is busy", async () => { - const pool = new SessionPool( - { poolMax: 1 }, - () => {}, - { strictCapacity: true }, - ); - await pool.park( - parkInput("approval").input, - 10_000, - "awaiting_approval", - ); + const pool = new SessionPool({ poolMax: 1 }, () => {}, { + strictCapacity: true, + }); + await pool.park(parkInput("approval").input, 10_000, "awaiting_approval"); const live = pool.checkoutApproval("approval"); @@ -674,11 +907,9 @@ describe("SessionPool", () => { releaseTeardown = resolve; }), }; - const pool = new SessionPool( - { poolMax: 1 }, - () => {}, - { strictCapacity: true }, - ); + const pool = new SessionPool({ poolMax: 1 }, () => {}, { + strictCapacity: true, + }); await pool.park(parkInput("a", environment).input, 10_000); const stopping = pool.get("a")!; const replacement = pool.park(parkInput("b").input, 10_000); @@ -688,14 +919,22 @@ describe("SessionPool", () => { assert.equal(pool.checkoutIdle("a"), undefined); assert.equal(pool.checkoutApproval("a"), undefined); assert.equal( - await pool.repark(stopping, { - configFingerprint: "new", - historyFingerprint: "new", - credentialEpoch: epoch, - }, 10_000), + await pool.repark( + stopping, + { + configFingerprint: "new", + historyFingerprint: "new", + credentialEpoch: epoch, + }, + 10_000, + ), false, ); - assert.equal(pool.get("a"), stopping, "repark does not clobber the seated stop"); + assert.equal( + pool.get("a"), + stopping, + "repark does not clobber the seated stop", + ); releaseTeardown?.(); assert.equal(await replacement, true); diff --git a/services/runner/tests/unit/ssrf-guard.test.ts b/services/runner/tests/unit/ssrf-guard.test.ts index 844ae2735c..38973b5a4c 100644 --- a/services/runner/tests/unit/ssrf-guard.test.ts +++ b/services/runner/tests/unit/ssrf-guard.test.ts @@ -199,10 +199,13 @@ describe("insecureEgressAllowed + validateUserMcpUrl bypass", () => { ); }); - it("allows http and private MCP hosts when egress is unrestricted", async () => { + it("keeps HTTPS mandatory while unrestricted mode permits private hosts", async () => { process.env.AGENTA_INSECURE_EGRESS_ALLOWED = "true"; - assert.equal(await validateUserMcpUrl("http://example.com/sse"), undefined); - assert.equal(await validateUserMcpUrl("http://127.0.0.1/sse"), undefined); + assert.match( + (await validateUserMcpUrl("http://example.com/sse")) ?? "", + /must use https/, + ); + assert.equal(await validateUserMcpUrl("https://127.0.0.1/sse"), undefined); }); it("surfaces a DNS-resolution failure distinctly from an SSRF block", async () => { diff --git a/services/runner/tests/unit/wire-contract.test.ts b/services/runner/tests/unit/wire-contract.test.ts index c9700b45e2..da98021851 100644 --- a/services/runner/tests/unit/wire-contract.test.ts +++ b/services/runner/tests/unit/wire-contract.test.ts @@ -37,13 +37,8 @@ const KNOWN_REQUEST_KEYS = [ "sessionId", "agentsMd", "model", - "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) => ({