diff --git a/docs/docs/self-host/concepts/02-how-agents-run.mdx b/docs/docs/self-host/concepts/02-how-agents-run.mdx index 61c4c0f5b9..41ce60ea5d 100644 --- a/docs/docs/self-host/concepts/02-how-agents-run.mdx +++ b/docs/docs/self-host/concepts/02-how-agents-run.mdx @@ -55,6 +55,29 @@ Two kinds of credential meet at the runner: A run can also carry no managed key at all and authenticate the harness from a mounted personal subscription. See [Use your own subscription](/self-host/use-your-own-subscription). +### What the sandbox can see + +A model key normally arrives in the sandbox as an environment variable, and an MCP server's key as +a request header. Both are readable by the agent running there, which matters because an agent +writes and runs its own code: a prompt injection that convinces it to print its environment prints +your keys. + +On Daytona you can close that gap. Set +[`AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local`](/self-host/configuration#hiding-api-keys-from-the-sandbox) +and the runner stores each key as a Daytona Secret restricted to the single hostname that key +authenticates against, then puts a placeholder in the sandbox. Daytona swaps the placeholder for +the real value on requests to that host, so the model call and the MCP call still work while the +agent itself only ever holds a useless string. A request to any other host carries the placeholder, +which is what makes exfiltration fail. + +This works for a key the remote service reads over HTTPS, which covers the provider keys and the +MCP keys. It does not work for a key a provider SDK signs with locally, which today means the AWS +access keys behind Bedrock: that secret never leaves the sandbox, so there is no outbound request +to substitute it into, and the sandbox holds the real value. + +The local sandbox is unaffected. The harness runs inside the runner container there, so its keys are +already inside your own deployment rather than a third party's. + ## Persistence depends on the run What a run keeps depends on what the run is: diff --git a/docs/docs/self-host/reference/01-configuration.mdx b/docs/docs/self-host/reference/01-configuration.mdx index 37dcfa1361..da64878b01 100644 --- a/docs/docs/self-host/reference/01-configuration.mdx +++ b/docs/docs/self-host/reference/01-configuration.mdx @@ -293,6 +293,7 @@ Read by the `runner` service. Required only when `daytona` is enabled. | `AGENTA_RUNNER_DAYTONA_IMAGE` | Image to start from | Unset | no | `...daytona.image` | | `AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES` | Idle minutes before stop | `15` | no | `...daytona.autostopMinutes` | | `AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES` | Idle minutes before delete | `30` | no | `...daytona.autodeleteMinutes` | +| `AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS` | Hide API keys from the sandbox | Unset (off) | no | `...daytona.opaqueSecrets` | When neither `AGENTA_RUNNER_DAYTONA_SNAPSHOT` nor `AGENTA_RUNNER_DAYTONA_IMAGE` is set, the runner starts from its pinned default snapshot `agenta-agent-sandbox-v1`. The two variables are mutually @@ -301,6 +302,39 @@ exclusive. The autostop and autodelete values must be positive integers. See and [Customize the agent runtime](/self-host/agent-execution/customize-the-agent-runtime) for the CPU, memory, and disk each sandbox gets. +#### Hiding API keys from the sandbox + +By default, a Daytona run receives its model and MCP API keys as ordinary environment variables, so +an agent that reads its own environment can read the keys. Set +`AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local` to change that: the runner stores each key as a +Daytona Secret, restricted to the one hostname that key authenticates against, and puts a +placeholder in the sandbox instead. Daytona substitutes the real value into outbound requests to +that host; requests anywhere else carry only the placeholder. + +`process_local` is the only accepted value, and it names the guarantee it can make. The runner +tracks the Secret records it created in its own memory and deletes them when the sandbox goes away. +Restart the runner while sandboxes are live and those records are orphaned in your Daytona account +until you remove them; a future value will add durable tracking. + +:::warning Check the API key's permissions first +A Daytona API key is minted with a set of permissions, and a key that can create sandboxes does +not automatically have the separate permission to manage Secrets. Grant that permission to the key +in `AGENTA_RUNNER_DAYTONA_API_KEY` before you set this variable. + +If you skip it, **every Daytona run that carries a model or MCP key fails at sandbox creation.** +The runner does not fall back to sending the key as plaintext, because silently doing the +unprotected thing is worse than stopping. The error names this variable and the permission, so the +failure is recognizable, but the runs still fail until the key is fixed. Unsetting the variable +restores the previous behavior immediately. +::: + +One more thing to know: keys that a provider SDK signs with locally instead of sending, which +today means the AWS access keys used for Bedrock, cannot be hidden this way and still reach the +sandbox in full. Nothing Daytona substitutes on the way out can help there, because the secret +never leaves the sandbox in the first place. + +Leaving the variable unset keeps the previous behavior exactly. + :::warning The bare `DAYTONA_*` variables configure a different sandbox `DAYTONA_API_KEY`, `DAYTONA_API_URL`, `DAYTONA_TARGET`, `DAYTONA_SNAPSHOT`, and `DAYTONA_SNAPSHOT_CODE` configure the code evaluator's sandbox, which runs custom evaluator code. diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml index 45489d1736..2df60b88b0 100644 --- a/hosting/docker-compose/ee/docker-compose.dev.yml +++ b/hosting/docker-compose/ee/docker-compose.dev.yml @@ -430,6 +430,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # This service has no env_file (see above), so the session flags must be listed # here or they can never be set. Both default ON: absent or empty means on, and # only the literal "false" disables. Disable AGENTA_SESSIONS_RECONSTRUCT together diff --git a/hosting/docker-compose/ee/docker-compose.gh.local.yml b/hosting/docker-compose/ee/docker-compose.gh.local.yml index ac6f4c5274..dfd36acfdb 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.local.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.local.yml @@ -300,6 +300,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # === NETWORK ============================================== # networks: - agenta-ee-gh-network diff --git a/hosting/docker-compose/ee/docker-compose.gh.yml b/hosting/docker-compose/ee/docker-compose.gh.yml index 27d1f4421d..8a623b885a 100644 --- a/hosting/docker-compose/ee/docker-compose.gh.yml +++ b/hosting/docker-compose/ee/docker-compose.gh.yml @@ -302,6 +302,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # This service has no env_file (see above), so the session flags must be listed # here or they can never be set. Both default ON: absent or empty means on, and # only the literal "false" disables. Disable AGENTA_SESSIONS_RECONSTRUCT together diff --git a/hosting/docker-compose/ee/env.ee.dev.example b/hosting/docker-compose/ee/env.ee.dev.example index 3c6bb3bef7..437ab4bb10 100644 --- a/hosting/docker-compose/ee/env.ee.dev.example +++ b/hosting/docker-compose/ee/env.ee.dev.example @@ -89,6 +89,12 @@ AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local # AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 +# Hide model and MCP API keys from the sandbox. When set to process_local, the runner stores each +# key as a Daytona Secret restricted to the one host it authenticates against, and the sandbox sees +# only a placeholder. Needs a Daytona API key with permission to manage Secrets. Leave unset to +# keep passing keys as plain environment variables. +# AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local + # --- Warm sessions --- # Milliseconds. Must stay below AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES, or Daytona stops a sandbox # the runner still holds. 0 stops the sandbox after each turn. diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example index 281d16d93f..83bca1b514 100644 --- a/hosting/docker-compose/ee/env.ee.gh.example +++ b/hosting/docker-compose/ee/env.ee.gh.example @@ -91,6 +91,12 @@ AGENTA_RUNNER_TOKEN=replace-me # AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 +# Hide model and MCP API keys from the sandbox. When set to process_local, the runner stores each +# key as a Daytona Secret restricted to the one host it authenticates against, and the sandbox sees +# only a placeholder. Needs a Daytona API key with permission to manage Secrets. Leave unset to +# keep passing keys as plain environment variables. +# AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local + # --- Warm sessions --- # Milliseconds. Must stay below AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES, or Daytona stops a sandbox # the runner still holds. 0 stops the sandbox after each turn. diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml index f7eaae6beb..4343d85287 100644 --- a/hosting/docker-compose/oss/docker-compose.dev.yml +++ b/hosting/docker-compose/oss/docker-compose.dev.yml @@ -416,6 +416,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # This service has no env_file (see above), so the session flags must be listed # here or they can never be set. Both default ON: absent or empty means on, and # only the literal "false" disables. Disable AGENTA_SESSIONS_RECONSTRUCT together diff --git a/hosting/docker-compose/oss/docker-compose.gh.local.yml b/hosting/docker-compose/oss/docker-compose.gh.local.yml index e15aab99f4..849f7237f4 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.local.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.local.yml @@ -298,6 +298,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # === NETWORK ============================================== # networks: - agenta-oss-gh-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml index 8b2b08f421..a6a7aebc60 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.ssl.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.ssl.yml @@ -322,6 +322,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # === NETWORK ============================================== # networks: - agenta-gh-ssl-network diff --git a/hosting/docker-compose/oss/docker-compose.gh.yml b/hosting/docker-compose/oss/docker-compose.gh.yml index 788e8f6c18..c7687df7f9 100644 --- a/hosting/docker-compose/oss/docker-compose.gh.yml +++ b/hosting/docker-compose/oss/docker-compose.gh.yml @@ -319,6 +319,7 @@ services: AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS: ${AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS:-} AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM: ${AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM:-} AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES: ${AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES:-} + AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS: ${AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS:-} # This service has no env_file (see above), so the session flags must be listed # here or they can never be set. Both default ON: absent or empty means on, and # only the literal "false" disables. Disable AGENTA_SESSIONS_RECONSTRUCT together diff --git a/hosting/docker-compose/oss/env.oss.dev.example b/hosting/docker-compose/oss/env.oss.dev.example index 52ef0c8b24..eac1a4f5df 100644 --- a/hosting/docker-compose/oss/env.oss.dev.example +++ b/hosting/docker-compose/oss/env.oss.dev.example @@ -89,6 +89,12 @@ AGENTA_RUNNER_DEFAULT_SANDBOX_PROVIDER=local # AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 +# Hide model and MCP API keys from the sandbox. When set to process_local, the runner stores each +# key as a Daytona Secret restricted to the one host it authenticates against, and the sandbox sees +# only a placeholder. Needs a Daytona API key with permission to manage Secrets. Leave unset to +# keep passing keys as plain environment variables. +# AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local + # --- Warm sessions --- # Milliseconds. Must stay below AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES, or Daytona stops a sandbox # the runner still holds. 0 stops the sandbox after each turn. diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example index 09c3c1e250..4c640e3c74 100644 --- a/hosting/docker-compose/oss/env.oss.gh.example +++ b/hosting/docker-compose/oss/env.oss.gh.example @@ -91,6 +91,12 @@ AGENTA_RUNNER_TOKEN=replace-me # AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES=15 # AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES=30 +# Hide model and MCP API keys from the sandbox. When set to process_local, the runner stores each +# key as a Daytona Secret restricted to the one host it authenticates against, and the sandbox sees +# only a placeholder. Needs a Daytona API key with permission to manage Secrets. Leave unset to +# keep passing keys as plain environment variables. +# AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local + # --- Warm sessions --- # Milliseconds. Must stay below AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES, or Daytona stops a sandbox # the runner still holds. 0 stops the sandbox after each turn. diff --git a/hosting/kubernetes/helm/templates/runner-deployment.yaml b/hosting/kubernetes/helm/templates/runner-deployment.yaml index 6071c9a7f0..49bc1008a3 100644 --- a/hosting/kubernetes/helm/templates/runner-deployment.yaml +++ b/hosting/kubernetes/helm/templates/runner-deployment.yaml @@ -118,6 +118,10 @@ spec: - name: AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES value: {{ $daytona.autodeleteMinutes | quote }} {{- end }} + {{- if $daytona.opaqueSecrets }} + - name: AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS + value: {{ $daytona.opaqueSecrets | quote }} + {{- end }} {{- if $daytona.apiKeySecretRef }} - name: AGENTA_RUNNER_DAYTONA_API_KEY valueFrom: diff --git a/hosting/kubernetes/helm/values.schema.json b/hosting/kubernetes/helm/values.schema.json index a0de38e2e7..0f1e9dcd26 100644 --- a/hosting/kubernetes/helm/values.schema.json +++ b/hosting/kubernetes/helm/values.schema.json @@ -339,7 +339,8 @@ "autostopMinutes": { "type": ["integer", "string"], "description": "AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES." }, "autodeleteMinutes": { "type": ["integer", "string"], "description": "AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES." }, "sessionIdleTtlMs": { "type": ["integer", "string"], "description": "AGENTA_RUNNER_DAYTONA_SESSION_IDLE_TTL_MS." }, - "sessionMaxWarm": { "type": ["integer", "string"], "description": "AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM." } + "sessionMaxWarm": { "type": ["integer", "string"], "description": "AGENTA_RUNNER_DAYTONA_SESSION_MAX_WARM." }, + "opaqueSecrets": { "type": "string", "enum": ["process_local"], "description": "AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; hide model and MCP keys from the sandbox behind Daytona Secrets. Omit to keep passing them as plain environment variables." } } } } diff --git a/hosting/kubernetes/helm/values.yaml b/hosting/kubernetes/helm/values.yaml index 09650d1178..02e1e6098f 100644 --- a/hosting/kubernetes/helm/values.yaml +++ b/hosting/kubernetes/helm/values.yaml @@ -151,6 +151,8 @@ redisDurable: # autodeleteMinutes: 30 # sessionIdleTtlMs: "" # sessionMaxWarm: "" +# opaqueSecrets: process_local # hide model/MCP keys from the sandbox; needs a Daytona +# # API key that may manage Secrets # auth: # tokenSecretRef: # AGENTA_RUNNER_TOKEN; Services sends it, the runner verifies it # name: agenta-runner diff --git a/sdks/python/agenta/sdk/agents/adapters/harnesses.py b/sdks/python/agenta/sdk/agents/adapters/harnesses.py index dc82792117..1783655903 100644 --- a/sdks/python/agenta/sdk/agents/adapters/harnesses.py +++ b/sdks/python/agenta/sdk/agents/adapters/harnesses.py @@ -64,9 +64,9 @@ def _to_harness_config(self, config: SessionConfig) -> PiAgentTemplate: return PiAgentTemplate( agents_md=config.agent.instructions, model=config.agent.model, - # Thread the structured ref so the author's connection {mode, slug} reaches the /run - # wire (via wire_model_ref). Without it a named custom (OpenAI-compatible) connection - # loses its slug and the runner cannot build its models.json plan. + # Thread the structured ref so the author's connection {mode, slug} reaches the + # connection resolver. Without it a named custom (OpenAI-compatible) connection + # loses its slug and cannot be selected; only the resolved connection rides the wire. model_ref=config.agent.model_ref, resolved_connection=config.resolved_connection, tool_specs=list(config.tool_specs), diff --git a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py index d6d2e2325c..8456d6e770 100644 --- a/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py +++ b/sdks/python/agenta/sdk/agents/adapters/sandbox_agent.py @@ -64,7 +64,6 @@ def __init__( config: HarnessAgentTemplate, *, harness: HarnessKind, - secrets: Optional[Mapping[str, str]], trace: Optional[TraceContext], run_context: Optional[RunContext], session_id: Optional[str], @@ -73,7 +72,6 @@ def __init__( self._sandbox = sandbox self._config = config self._harness = harness - self._secrets = dict(secrets or {}) self._trace = trace self._run_context = run_context self._session_id = session_id @@ -89,7 +87,6 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: sandbox=self._sandbox.sandbox_id, config=self._config, messages=messages, - secrets=self._secrets, trace=self._trace, run_context=self._run_context, session_id=self._session_id, @@ -175,7 +172,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 95adc72a98..69b9dbcecf 100644 --- a/sdks/python/agenta/sdk/agents/capabilities.py +++ b/sdks/python/agenta/sdk/agents/capabilities.py @@ -14,8 +14,8 @@ - **Pi** reaches eight Agenta-vault-mapped providers directly (the ones whose ``provider_key`` secret drives a Pi provider via its env-key map), plus ``openai-codex`` (OpenAI's ChatGPT/Codex - subscription), which Pi reaches through its own OAuth login rather than a vault key — usable - under ``self_managed`` (and the ``agenta`` default's ``runtime_provided`` fallback). Pi also + subscription), which Pi reaches through its own OAuth login rather than a vault key, usable + under ``self_managed``. Pi also reaches ~24 more providers that have no Agenta vault kind; those are out of scope unless a ``custom_provider`` secret is made for them, so they are not enumerated here. Pi consumes the ``direct`` deployment for all of them, plus the ``custom`` (OpenAI-compatible) deployment for @@ -64,9 +64,8 @@ # ``/login``), NOT an Agenta vault ``provider_key`` (no vault secret kind maps to it). ``self_managed`` # is broader than this one provider: it covers any way a harness signs itself in without an # Agenta-stored key, including machine credentials such as environment variables. This provider's -# on-ramp under ``self_managed`` happens to be the subscription OAuth. It is also reachable under -# the ``agenta`` default's ``runtime_provided`` fallback, so it belongs in Pi's reachable providers -# even though it carries no vault key. Its model ids are carried explicitly below because they are +# on-ramp under ``self_managed`` happens to be the subscription OAuth. Its model ids are carried +# explicitly below because they are # not in the litellm-derived ``supported_llm_models`` catalog. See # ``docs/design/agent-workflows/projects/provider-model-auth/harness-provider-matrix.md`` and the # subscription-sidecar recipe. diff --git a/sdks/python/agenta/sdk/agents/connections/__init__.py b/sdks/python/agenta/sdk/agents/connections/__init__.py index 7254684804..fe89d186aa 100644 --- a/sdks/python/agenta/sdk/agents/connections/__init__.py +++ b/sdks/python/agenta/sdk/agents/connections/__init__.py @@ -12,6 +12,8 @@ ConnectionNotFoundError, ConnectionResolutionError, EndpointResolutionError, + InvalidConnectionConfigurationError, + MissingCredentialError, MissingProviderError, ProviderMismatchError, UnsupportedConnectionModeError, @@ -22,10 +24,13 @@ from .models import ( Connection, CredentialMode, + CredentialUsage, Deployment, Endpoint, + EnvironmentCredentialBinding, ModelRef, ResolvedConnection, + ResolvedCredential, RuntimeAuthContext, ) from .resolver import EnvConnectionResolver, StaticConnectionResolver @@ -34,10 +39,13 @@ # Contracts "Connection", "Endpoint", + "EnvironmentCredentialBinding", "ModelRef", "ResolvedConnection", + "ResolvedCredential", "RuntimeAuthContext", "CredentialMode", + "CredentialUsage", "Deployment", # Port + adapters "ConnectionResolver", @@ -47,7 +55,9 @@ "AgentConnectionError", "ConnectionResolutionError", "EndpointResolutionError", + "InvalidConnectionConfigurationError", "ConnectionNotFoundError", + "MissingCredentialError", "MissingProviderError", "AmbiguousConnectionError", "ProviderMismatchError", diff --git a/sdks/python/agenta/sdk/agents/connections/endpoints.py b/sdks/python/agenta/sdk/agents/connections/endpoints.py new file mode 100644 index 0000000000..aa3cfd7c76 --- /dev/null +++ b/sdks/python/agenta/sdk/agents/connections/endpoints.py @@ -0,0 +1,149 @@ +"""Effective model routes and credential-role classification.""" + +from __future__ import annotations + +from typing import Dict, Iterable, List, Optional, Tuple +from urllib.parse import urlparse + +from .errors import InvalidConnectionConfigurationError +from .models import Endpoint, ResolvedConnection, ResolvedCredential + +_DIRECT_ENDPOINTS: Dict[str, str] = { + "openai": "https://api.openai.com/v1", + "anthropic": "https://api.anthropic.com", + "gemini": "https://generativelanguage.googleapis.com", + "mistral": "https://api.mistral.ai/v1", + "mistralai": "https://api.mistral.ai/v1", + "minimax": "https://api.minimax.io/v1", + "groq": "https://api.groq.com/openai/v1", + "together_ai": "https://api.together.xyz/v1", + "openrouter": "https://openrouter.ai/api/v1", +} +_NON_SECRET_ENV = { + "AWS_REGION", + "AWS_DEFAULT_REGION", + "GOOGLE_CLOUD_PROJECT", + "GOOGLE_CLOUD_LOCATION", +} +_LOCAL_USE_ENV = { + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "GOOGLE_APPLICATION_CREDENTIALS", +} + + +def effective_endpoint( + *, + provider: str, + deployment: str, + endpoint: Optional[Endpoint], + environment: Dict[str, str], +) -> Endpoint: + """Return the exact HTTPS route used by this resolved provider deployment.""" + if endpoint and endpoint.base_url: + resolved = endpoint + elif deployment == "direct" or deployment.lower() == provider.lower(): + base_url = _DIRECT_ENDPOINTS.get(provider.lower()) + if not base_url: + raise ValueError( + f"no effective endpoint is registered for provider '{provider}'" + ) + resolved = Endpoint(base_url=base_url) + elif deployment == "bedrock": + region = environment.get("AWS_REGION") or environment.get("AWS_DEFAULT_REGION") + if not region: + raise ValueError("bedrock model connection requires an AWS region") + resolved = Endpoint( + base_url=f"https://bedrock-runtime.{region}.amazonaws.com", region=region + ) + elif deployment in {"vertex", "vertex_ai"}: + location = environment.get("GOOGLE_CLOUD_LOCATION") + if not location: + raise ValueError("vertex model connection requires GOOGLE_CLOUD_LOCATION") + resolved = Endpoint( + base_url=f"https://{location}-aiplatform.googleapis.com", region=location + ) + else: + raise ValueError(f"deployment '{deployment}' requires an explicit endpoint") + + parsed = urlparse(resolved.base_url or "") + if parsed.scheme.lower() != "https" or not parsed.hostname: + raise ValueError("model connection endpoint must be an absolute HTTPS URL") + return resolved + + +def classify_environment( + values: Iterable[Tuple[str, str]], +) -> Tuple[List[ResolvedCredential], Dict[str, str]]: + """Split provider environment into secret bindings and non-secret configuration.""" + credentials: List[ResolvedCredential] = [] + environment: Dict[str, str] = {} + for name, value in values: + if not name or not value: + raise ValueError( + "model connection bindings require non-empty names and values" + ) + if name in _NON_SECRET_ENV: + environment[name] = value + continue + usage = "local_use" if name in _LOCAL_USE_ENV else "opaque_http" + credentials.append( + ResolvedCredential( + binding={"kind": "environment", "name": name}, + value=value, + usage=usage, + ) + ) + return credentials, environment + + +def build_resolved_connection( + *, + provider: str, + model: str, + deployment: str = "direct", + credential_mode: str, + values: Dict[str, str], + endpoint: Optional[Endpoint] = None, + input_modalities: Optional[List[str]] = None, +) -> ResolvedConnection: + """Build a classified connection and attach the resolver-owned effective route.""" + if deployment in {"vertex", "vertex_ai"} and values.get("GOOGLE_CLOUD_API_KEY"): + raise InvalidConnectionConfigurationError( + "Vertex API-key authentication is not supported by the agent connection contract" + ) + try: + credentials, environment = classify_environment(values.items()) + except ValueError as exc: + # Same contract as the endpoint-resolution failure below: a malformed binding is a + # caller configuration problem (422), never an unhandled 500. + raise InvalidConnectionConfigurationError(str(exc)) from exc + if credential_mode == "env" and not credentials: + raise InvalidConnectionConfigurationError( + "credential_mode 'env' requires at least one usable credential" + ) + try: + route = effective_endpoint( + provider=provider, + deployment=deployment, + endpoint=endpoint, + environment=environment, + ) + except ValueError as exc: + # A runtime-owned login with no resolved credential does not need a credential host. + # Once Agenta supplies any credential, an indeterminate route is unsafe and fails loud. + if any(credential.usage == "opaque_http" for credential in credentials): + raise InvalidConnectionConfigurationError(str(exc)) from exc + route = None + return ResolvedConnection( + provider=provider, + model=model, + deployment=deployment, + credential_mode=credential_mode, + credentials=credentials, + environment=environment, + endpoint=route, + input_modalities=input_modalities, + ) diff --git a/sdks/python/agenta/sdk/agents/connections/errors.py b/sdks/python/agenta/sdk/agents/connections/errors.py index eaac35bb4a..68802e69f5 100644 --- a/sdks/python/agenta/sdk/agents/connections/errors.py +++ b/sdks/python/agenta/sdk/agents/connections/errors.py @@ -37,6 +37,31 @@ class EndpointResolutionError(ConnectionResolutionError): status_code = 422 +class MissingCredentialError(ConnectionResolutionError): + """Raised when an Agenta-managed connection has no usable credential.""" + + # The invoke remap reads `status_code` off the exception; without it this fell through to 500. + status_code = 422 + + def __init__(self, *, provider: str, slug: Optional[str] = None) -> None: + subject = ( + f"connection '{slug}'" if slug else f"provider '{provider}' connection" + ) + super().__init__( + f"{subject} has no usable credential; configure a credential or select " + "self_managed authentication" + ) + self.provider = provider + self.slug = slug + + +class InvalidConnectionConfigurationError(AgentConnectionError): + """Raised when resolved routing and credentials form an unsafe combination.""" + + # The invoke remap reads `status_code` off the exception; without it this fell through to 500. + status_code = 422 + + class ConnectionNotFoundError(ConnectionResolutionError): """Raised when a named connection (``mode == agenta`` + ``slug``) does not exist.""" diff --git a/sdks/python/agenta/sdk/agents/connections/models.py b/sdks/python/agenta/sdk/agents/connections/models.py index d29cc2b821..338c0c9a50 100644 --- a/sdks/python/agenta/sdk/agents/connections/models.py +++ b/sdks/python/agenta/sdk/agents/connections/models.py @@ -1,9 +1,8 @@ """Neutral provider / model / connection contracts for the agent runtime. These models carry *intent* (which model, which provider, where its credential comes from) -and the *resolved* least-privilege output a harness adapter applies. They are deliberately -credential-shaped only at the edges: ``ResolvedConnection.env`` is the one secret-bearing -channel; everything else (``Endpoint``, ``Connection``, ``ModelRef``) names non-secret intent. +and the *resolved* least-privilege output a harness adapter applies. They are deliberately credential-shaped only at the edges: typed credentials live under +``ResolvedConnection``; everything else (``Endpoint``, ``Connection``, ``ModelRef``) names non-secret intent. The design is in ``docs/design/agent-workflows/projects/provider-model-auth/design.md`` (Concerns 1-3). This @@ -17,6 +16,7 @@ from __future__ import annotations from typing import Any, Dict, List, Literal, Optional +from urllib.parse import urlparse from uuid import UUID from pydantic import BaseModel, Field, field_serializer, model_validator @@ -32,6 +32,7 @@ # provider's vars; ``runtime_provided`` injects nothing (the harness owns auth, e.g. an OAuth # login or a self-managed sidecar); ``none`` injects nothing and asserts no credential. CredentialMode = Literal["env", "runtime_provided", "none"] +CredentialUsage = Literal["opaque_http", "local_use"] # Which deployment surface a provider is reached through. ``direct`` is the provider's own # API; custom-provider deployments preserve the vault ``data.kind`` value (for example @@ -77,8 +78,8 @@ class Endpoint(BaseModel): """NON-secret connection config a harness applies alongside its credential. This carries only public, non-secret fields: a custom base URL, an API version, a region, - and public headers. Secret-bearing values (the api key, secret auth headers) never live - here; they ride ``ResolvedConnection.env``, the one secret channel. + and public headers. Secret-bearing values never live here; they ride typed credential bindings under the + resolved connection. """ base_url: Optional[str] = None @@ -105,6 +106,52 @@ def to_wire(self) -> Dict[str, Any]: return wire +class EnvironmentCredentialBinding(BaseModel): + """Bind one resolved credential to the harness environment protocol.""" + + kind: Literal["environment"] = "environment" + name: str + + @model_validator(mode="after") + def _require_name(self) -> "EnvironmentCredentialBinding": + if not self.name.strip(): + raise ValueError("credential environment binding requires a non-empty name") + return self + + def to_wire(self) -> Dict[str, str]: + return {"kind": self.kind, "name": self.name} + + +class ResolvedCredential(BaseModel): + """One secret value, its protocol binding, and how the consumer uses it.""" + + binding: EnvironmentCredentialBinding + value: str = Field(repr=False) + usage: CredentialUsage + + @model_validator(mode="after") + def _require_value(self) -> "ResolvedCredential": + if not self.value: + raise ValueError("resolved credential requires a non-empty value") + return self + + @field_serializer("value", when_used="always") + def _mask_value(self, value: str) -> str: + """Structural guard (F-SDK-DUMP): a dump can never carry the credential value. + + Attribute access stays plain for the legitimate consumers (:meth:`to_wire` and + ``ResolvedConnection.plaintext_environment``), which read ``self.value`` directly. + """ + return "**********" + + def to_wire(self) -> Dict[str, Any]: + return { + "binding": self.binding.to_wire(), + "value": self.value, + "usage": self.usage, + } + + class ModelRef(BaseModel): """Model intent plus the credential connection, carried in the agent config. @@ -160,47 +207,64 @@ def to_model_string(self) -> str: class ResolvedConnection(BaseModel): - """The least-privilege output a :class:`ConnectionResolver` returns for one run. + """Resolved route and credentials for one model consumer. - ``env`` is the ONLY channel that carries secret values: one provider's vars (the api key - and any secret-bearing extras). ``endpoint`` carries only non-secret connection config. - The harness adapter applies ``env`` + ``endpoint`` + ``model`` and never sees a vault, a - connection, or a slug. + Credentials stay nested under the connection they authenticate. ``environment`` carries + non-secret provider configuration such as regions and project ids, rather than disguising + those values as credentials. Credential values are secret-bearing and must not be logged. - Serialization safety: ``env`` is masked from ``repr``/``str`` AND from - ``model_dump()``/``model_dump_json()`` by construction — the values never leave this model. - Read ``env`` directly (attribute access is unmasked) to hand the credential to a harness; - :meth:`to_wire` never emits ``env`` at all. + Serialization safety: each credential ``value`` is masked from ``repr``/``str`` AND from + ``model_dump()``/``model_dump_json()`` by construction — the values never leave this model + on a dump. Attribute access (unmasked) hands the credential to a harness via + :meth:`to_wire` / :meth:`plaintext_environment`. """ provider: str - model: str # possibly rewritten for the deployment (e.g. a bedrock id) + model: str deployment: Deployment = "direct" credential_mode: CredentialMode - env: Dict[str, str] = Field( - default_factory=dict, repr=False - ) # the ONLY secret channel + credentials: List[ResolvedCredential] = Field(default_factory=list, repr=False) + environment: Dict[str, str] = Field(default_factory=dict) endpoint: Optional[Endpoint] = None # NON-secret connection config only input_modalities: Optional[List[str]] = None - @field_serializer("env", when_used="always") - def _mask_env(self, env: Dict[str, str]) -> Dict[str, str]: - """Structural guard: a dump can never carry the credential (keys survive, values do not).""" - return {key: "**********" for key in env} + @model_validator(mode="after") + def _validate_credential_route(self) -> "ResolvedConnection": + names = [item.binding.name for item in self.credentials] + if len(names) != len(set(names)) or any( + name in self.environment for name in names + ): + raise ValueError("model connection environment bindings must be unique") + if self.credential_mode == "env" and not self.credentials: + raise ValueError("credential_mode 'env' requires at least one credential") + if self.credential_mode != "env" and self.credentials: + raise ValueError("resolved credentials require credential_mode 'env'") + if any(item.usage == "opaque_http" for item in self.credentials): + base_url = self.endpoint.base_url if self.endpoint else None + parsed = urlparse(base_url or "") + if parsed.scheme.lower() != "https" or not parsed.hostname: + raise ValueError( + "opaque_http model credentials require an effective HTTPS endpoint" + ) + return self + + def plaintext_environment(self) -> Dict[str, str]: + """Materialize the validated contract at a local execution boundary.""" + values = dict(self.environment) + for credential in self.credentials: + values[credential.binding.name] = credential.value + return values def to_wire(self) -> Dict[str, Any]: - """The NON-secret camelCase fields for the wire. Never emits ``env``. - - ``env`` is the secret channel and rides the existing ``secrets`` wire field during the - transition (Slice 1); only the non-secret descriptor is serialized here so a trace or - an echoed payload never carries credentials. - """ + """Serialize the consumer-owned model connection onto the trusted internal wire.""" wire: Dict[str, Any] = { "provider": self.provider, - "model": self.model, "deployment": self.deployment, "credentialMode": self.credential_mode, + "credentials": [item.to_wire() for item in self.credentials], } + if self.environment: + wire["environment"] = dict(self.environment) if self.endpoint is not None: endpoint_wire = self.endpoint.to_wire() if endpoint_wire: diff --git a/sdks/python/agenta/sdk/agents/connections/resolver.py b/sdks/python/agenta/sdk/agents/connections/resolver.py index 9c519b9ce3..05206c88f7 100644 --- a/sdks/python/agenta/sdk/agents/connections/resolver.py +++ b/sdks/python/agenta/sdk/agents/connections/resolver.py @@ -19,7 +19,8 @@ from ..capabilities import PROVIDER_ENV_VARS from ..model_catalog import model_input_modalities -from .errors import UnsupportedProviderError +from .endpoints import build_resolved_connection +from .errors import MissingCredentialError, UnsupportedProviderError from .models import ( Endpoint, ModelRef, @@ -46,8 +47,8 @@ class EnvConnectionResolver: - ``agenta`` (the default mode, with or without a slug) -> infer the provider (from ``ModelRef.provider``, else error), look up its env var, and: - present -> ``credential_mode = env`` carrying exactly that one var; - - absent -> ``credential_mode = runtime_provided`` with empty ``env`` (absence is - valid; the harness falls back to its own login, matching today's semantics). + - absent -> fail closed. Only an explicit ``self_managed`` connection may let the + harness own authentication. The model passes through unchanged. Offline, no vault, no network. """ @@ -64,11 +65,11 @@ async def resolve( ) -> ResolvedConnection: if model.connection.mode == "self_managed": provider = model.provider or "" - return ResolvedConnection( + return build_resolved_connection( provider=provider, model=model.model, credential_mode="runtime_provided", - env={}, + values={}, input_modalities=_input_modalities( context, provider=provider, model=model.model ), @@ -84,24 +85,18 @@ async def resolve( env_var = _PROVIDER_ENV_VARS.get(provider.lower()) key = self._env.get(env_var) if env_var else None if env_var and key: - return ResolvedConnection( + return build_resolved_connection( provider=provider, model=model.model, credential_mode="env", - env={env_var: key}, + values={env_var: key}, input_modalities=_input_modalities( context, provider=provider, model=model.model ), ) - # Absence is valid: inject nothing and let the harness use its own login/OAuth. - return ResolvedConnection( + raise MissingCredentialError( provider=provider, - model=model.model, - credential_mode="runtime_provided", - env={}, - input_modalities=_input_modalities( - context, provider=provider, model=model.model - ), + slug=model.connection.slug, ) @@ -146,18 +141,34 @@ async def resolve( context: RuntimeAuthContext, ) -> ResolvedConnection: provider = self._provider or model.provider or "" + if model.connection.mode == "self_managed": + return build_resolved_connection( + provider=provider, + model=model.model, + deployment=self._deployment, + credential_mode="runtime_provided", + values={}, + input_modalities=_input_modalities( + context, provider=provider, model=model.model + ), + ) env: Dict[str, str] = {} if self._api_key: env_var = self._env_var or _PROVIDER_ENV_VARS.get(provider.lower()) if env_var: env[env_var] = self._api_key endpoint = Endpoint(base_url=self._base_url) if self._base_url else None - return ResolvedConnection( + if not env: + raise MissingCredentialError( + provider=provider, + slug=model.connection.slug, + ) + return build_resolved_connection( provider=provider, model=model.model, - deployment=self._deployment, # type: ignore[arg-type] - credential_mode="env" if env else "runtime_provided", - env=env, + deployment=self._deployment, + credential_mode="env", + values=env, endpoint=endpoint, input_modalities=_input_modalities( context, provider=provider, model=model.model diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py index b50155a2aa..c1b57b7841 100644 --- a/sdks/python/agenta/sdk/agents/dtos.py +++ b/sdks/python/agenta/sdk/agents/dtos.py @@ -714,17 +714,12 @@ class HarnessAgentTemplate(BaseModel): harness: ClassVar[HarnessKind] agents_md: Optional[str] = None - # ``model`` stays the back-compat plain string the adapter hands to the harness. - # ``model_ref`` carries the structured ref when one is supplied; it is populated only from - # structured input (a dict / a ``ModelRef``), so a plain-string ``model`` leaves it - # ``None`` and the wire is unchanged. See :meth:`wire_model_ref`. + # ``model`` stays the plain string handed to the harness. ``model_ref`` carries author + # intent for resolution; only the resolved connection crosses the runner boundary. model: Optional[str] = None model_ref: Optional[ModelRef] = None - # ``resolved_connection`` carries the least-privilege output of a ``ConnectionResolver`` - # (threaded down from ``SessionConfig``). It is the authoritative source of the non-secret - # provider/model descriptor on the wire when present; unset leaves the wire unchanged (the - # golden contract). Its ``env`` is the secret channel and never reaches the wire here (it - # rides ``secrets``). See :meth:`wire_resolved_connection`. + # ``resolved_connection`` carries the route and typed credential bindings produced by the + # resolver. It serializes as one consumer-owned ``modelConnection`` object. resolved_connection: Optional[ResolvedConnection] = None tool_callback: Optional[ToolCallback] = None mcp_servers: List[ResolvedMCPServer] = Field(default_factory=list) @@ -819,48 +814,46 @@ def wire_harness_mode(self) -> Dict[str, Any]: """The harness-specific ACP session mode override for the ``/run`` payload.""" return {} - def wire_model_ref(self) -> Dict[str, Any]: - """The non-secret provider/connection fields for the ``/run`` payload. + def wire_connection_ref(self) -> Dict[str, Any]: + """The author's connection CHOICE for the ``/run`` payload, not its resolved contents. - 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. + This is the reference the author picked in the config (``self_managed``, or an Agenta + connection named by slug). It stays separate from ``modelConnection`` because the two + answer different questions: this one is "which connection did the author select", which + the runner reads to route a named OpenAI-compatible Pi run through its models.json path + (``pi-model-config.ts``); ``modelConnection`` is "what did that connection resolve to", + which is credential material. + + Empty when ``model_ref`` is unset, and empty for the project default (``agenta`` with no + slug) because that carries no information beyond the model itself, so a default run's + payload stays byte-identical. """ if self.model_ref is None: return {} - 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 + if connection.mode == "agenta" and connection.slug is None: + return {} + wire: Dict[str, Any] = {"mode": connection.mode} + if connection.slug is not None: + wire["slug"] = connection.slug + return {"connection": wire} + + def wire_model_connection(self) -> Dict[str, Any]: + """The resolved model route and credentials, grouped under their consumer. - 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.""" + ``modelCapabilities`` (resolved input modalities) is hoisted to the top level of the + request: the runner's attachment-delivery chain reads ``request.modelCapabilities``, + not the connection object. + """ if self.resolved_connection is None: return {} - return self.resolved_connection.to_wire() + connection_wire = self.resolved_connection.to_wire() + out: Dict[str, Any] = {"model": self.resolved_connection.model} + capabilities = connection_wire.pop("modelCapabilities", None) + if capabilities is not None: + out["modelCapabilities"] = capabilities + out["modelConnection"] = connection_wire + return out class PiAgentTemplate(HarnessAgentTemplate): @@ -879,6 +872,23 @@ class PiAgentTemplate(HarnessAgentTemplate): harness: ClassVar[HarnessKind] = HarnessKind.PI + def wire_model_connection(self) -> Dict[str, Any]: + """Keep Pi's selector provider-qualified so equal model suffixes cannot misroute. + + ``modelConnection.provider`` describes credential ownership, but Pi routes on the + top-level model string. Agenta inherits this behavior; Claude keeps its bare aliases. + """ + wire = super().wire_model_connection() + if not wire or self.resolved_connection is None: + return wire + provider = self.resolved_connection.provider + model = self.resolved_connection.model + prefix = f"{provider}/" if provider else "" + wire["model"] = ( + model if not prefix or model.startswith(prefix) else f"{prefix}{model}" + ) + return wire + tool_specs: List[ToolSpec] = Field( default_factory=list, validation_alias=AliasChoices("tool_specs", "custom_tools"), @@ -1080,8 +1090,8 @@ class AgentaAgentTemplate(PiAgentTemplate): class SessionConfig(BaseModel): """Everything one run needs except where it runs. - ``agent`` is the agent definition. ``secrets`` are provider keys injected as harness - env, never written to the agent filesystem. The ``custom_tools`` / ``tool_callback`` pair + ``agent`` is the agent definition. Model routing and credentials are carried by + ``resolved_connection``. The ``custom_tools`` / ``tool_callback`` pair is the resolved tool delivery (Agenta produces it server-side; empty for a bare standalone run); built-in tools are not part of it, the runner activates them. The agent config's ``sandbox`` field is a @@ -1091,10 +1101,7 @@ class SessionConfig(BaseModel): model_config = ConfigDict(populate_by_name=True) agent: AgentTemplate - secrets: Dict[str, str] = Field(default_factory=dict, repr=False) - # ``resolved_connection`` carries the least-privilege output of a ``ConnectionResolver``. - # ``secrets`` is the compatibility alias for ``resolved_connection.env`` during the - # transition: Slice 1 still ships the credential through ``secrets`` on the wire. + # ``resolved_connection`` is the single source of model routing and credentials. resolved_connection: Optional[ResolvedConnection] = None permission_default: PermissionMode = "allow_reads" trace: Optional[TraceContext] = None diff --git a/sdks/python/agenta/sdk/agents/handler.py b/sdks/python/agenta/sdk/agents/handler.py index 044bb77e4d..40a7993c81 100644 --- a/sdks/python/agenta/sdk/agents/handler.py +++ b/sdks/python/agenta/sdk/agents/handler.py @@ -2,7 +2,7 @@ Owns stream/trim/force; composition (template, tool/MCP/connection resolvers, backend selector) is injectable via `AgentComposition`, defaulting to env-driven SDK behavior. The -default composition also owns capability gating and degradation policy: +default composition also owns fail-closed capability/connection gating: these are protocol-level safety behaviors, not service-specific, so a bare `agent_v0` (no composition override) gets them for free instead of a permissive fallback. """ @@ -22,8 +22,6 @@ harness_allows_provider, ) from agenta.sdk.agents.connections import ( - ConnectionResolutionError, - MissingProviderError, ModelRef, ResolvedConnection, RuntimeAuthContext, @@ -51,6 +49,9 @@ from agenta.sdk.agents.dtos import RunContext, RunContextRun from agenta.sdk.engines.running.errors import ForceNotSupportedV0Error +from agenta.sdk.redaction.context import get_active_redactor, redaction_context +from agenta.sdk.redaction.redactor import Redactor +from agenta.sdk.redaction.seed import seed_from_request from agenta.sdk.models.workflows import ( WorkflowInvokeRequestFlags, WorkflowServiceRequest, @@ -160,50 +161,14 @@ async def _default_resolve_session_connection( *, resolve_connection: ResolveConnectionFn = _default_resolve_connection, ) -> ResolvedConnection: - """Resolve one least-privilege connection for the run, with graceful degradation. + """Resolve and capability-check one least-privilege connection for the run. - Provider + mode are rejected BEFORE the vault resolve (known from the config), the resolved - deployment is rejected AFTER (only known once the vault picks the secret). - - An EXPLICIT named ``agenta`` connection (``slug`` set) fails loud on a resolution failure: the - user named a connection, so a missing/ambiguous one is a real error they must fix. - - A project-default connection (``agenta`` with no slug) or a ``self_managed`` connection is - TOLERANT of a resolution failure: most projects have no configured connection for the default - model and rely on the harness's own login / a self-managed sidecar, so a failed resolve - (including a network/HTTP error) degrades to an empty ``runtime_provided`` plan and the run - still works. A capability reject is NEVER tolerated — it is a misconfiguration the user must - fix, not a missing credential. + Resolution failures are fail-closed. A caller that wants harness-owned authentication must + select ``self_managed`` explicitly; a vault outage, missing key, invalid route, or ambiguous + connection must never become an implicit runtime-provided fallback. """ _check_harness_pre_resolve(model_ref, context.harness) - - connection = model_ref.connection - is_named = connection.mode == "agenta" and bool( - connection.slug and connection.slug.strip() - ) - if is_named: - resolved = await resolve_connection(model=model_ref, context=context) - _check_harness_post_resolve(resolved, context.harness) - return resolved - try: - resolved = await resolve_connection(model=model_ref, context=context) - except MissingProviderError: - # A bare model id with no provider is an underspecified config, not a missing - # credential, so it fails loud even on a default connection. - raise - except ConnectionResolutionError: - log.warning( - "agent: no connection resolved for provider %r (mode=%s); " - "running with no injected credential (harness login / self-managed)", - model_ref.provider, - connection.mode, - ) - return ResolvedConnection( - provider=model_ref.provider or "", - model=model_ref.model, - credential_mode="runtime_provided", - env={}, - ) + resolved = await resolve_connection(model=model_ref, context=context) _check_harness_post_resolve(resolved, context.harness) return resolved @@ -218,15 +183,15 @@ class AgentComposition: ``None``/no-op when a run has no such state, so a bare ``agent_v0`` behaves correctly in any process without composition. - ``resolve_session_connection`` defaults to the SAFE behavior (capability-gated + degrading - connection resolve) rather than a bare fallback, so a composition-free ``agent_v0`` is not - the permissive copy.""" + ``resolve_session_connection`` defaults to the SAFE behavior (capability-gated + + fail-closed connection resolve) rather than a bare fallback, so a composition-free + ``agent_v0`` is not the permissive copy.""" default_template: DefaultTemplateFn = field(default=_default_template) resolve_tools: ResolveToolsFn = field(default=_default_resolve_tools) resolve_mcp_servers: ResolveMCPFn = field(default=_default_resolve_mcp_servers) resolve_connection: ResolveConnectionFn = field(default=_default_resolve_connection) - # capability gating + degradation policy; override to replace, not just add to. + # capability gating + fail-closed resolution policy; override to replace, not just add to. resolve_session_connection: Optional[ResolveSessionConnectionFn] = field( default=None ) @@ -279,12 +244,11 @@ async def _agent( model_ref = _agent_model_ref(agent_template) resolved_connection: Optional[ResolvedConnection] = None - secrets: Dict[str, str] = {} if model_ref is not None: ctx = RuntimeAuthContext( harness=agent_template.harness, backend=agent_template.sandbox ) - # Default is the gated+degrading resolve, bound to comp.resolve_connection + # Default is the gated+fail-closed resolve, bound to comp.resolve_connection # so an override of the plain resolver still flows through the capability check. resolve_session_connection = comp.resolve_session_connection or ( lambda m, c: _default_resolve_session_connection( @@ -292,7 +256,29 @@ async def _agent( ) ) resolved_connection = await resolve_session_connection(model_ref, ctx) - secrets = resolved_connection.env + + # Seed a FRESH per-run redactor immediately after trusted resolution and before + # transport, trace, event, error, or result sinks can observe an echoed credential. + # The redactor is installed into the ambient context for exactly this run's scope — + # the batch call below, or the returned stream's lifetime — and the prior redactor is + # restored on exit (`redaction_context`), so sequential runs sharing one task/context + # never accumulate each other's deny-set values and a finished run's secrets do not + # linger in the ambient contextvar. + redactor = seed_from_request( + extra_values=[ + *( + credential.value + for credential in ( + resolved_connection.credentials if resolved_connection else [] + ) + ), + *( + credential.value + for server in resolved_mcp + for credential in server.credentials + ), + ] + ) # run_kind rides the wire on `request.meta`: a wire-supplied run_kind must not # be silently dropped, so it layers onto whatever run_context composition supplies. @@ -305,7 +291,6 @@ async def _agent( session_config = SessionConfig( agent=agent_template, - secrets=secrets, resolved_connection=resolved_connection, permission_default=agent_template.permission_default, trace=comp.trace_context(), @@ -317,20 +302,38 @@ async def _agent( ) if stream: - return agent_event_stream( - harness, session_config, msgs, record_usage=comp.record_usage + return _stream_in_redaction_scope( + redactor, + agent_event_stream( + harness, session_config, msgs, record_usage=comp.record_usage + ), + ) + with redaction_context(redactor): + return await agent_batch( + harness, + session_config, + msgs, + trim=flags.trim, + record_usage=comp.record_usage, ) - return await agent_batch( - harness, - session_config, - msgs, - trim=flags.trim, - record_usage=comp.record_usage, - ) return _agent +async def _stream_in_redaction_scope(redactor: Redactor, events): + """Iterate `events` with `redactor` installed as the ambient per-run redactor. + + An async generator frame shares its caller's context (PEP 567 gives generators no context + of their own), so the install on first `__anext__` is visible to every sink that runs while + a chunk is produced, and the `finally` inside `redaction_context` restores the caller's + redactor once the stream is exhausted or closed — the same per-run boundary the batch path + gets from wrapping `agent_batch`. + """ + with redaction_context(redactor): + async for event in events: + yield event + + async def agent_event_stream( harness, session_config, msgs, *, record_usage: RecordUsageFn = ambient_record_usage ): @@ -342,7 +345,9 @@ async def agent_event_stream( async for event in run: if event.type == "done": event_stop_reason = (event.data or {}).get("stopReason") - yield {"type": event.type, "data": event.data} + yield get_active_redactor().redact_json( + {"type": event.type, "data": event.data}, sink="agent_event" + ) # The terminal result's stop_reason is authoritative: the runner's `done` event carries # no stopReason for a HITL pause (the engine settles paused-vs-ended after the event # stream closes, onto the terminal result only). When it disagrees with the streamed @@ -379,7 +384,11 @@ async def agent_batch( try: run = await harness.stream(session_config, msgs) async for event in run: - events.append({"type": event.type, "data": event.data}) + events.append( + get_active_redactor().redact_json( + {"type": event.type, "data": event.data}, sink="agent_event" + ) + ) result = run.result() finally: await harness.cleanup() diff --git a/sdks/python/agenta/sdk/agents/interfaces.py b/sdks/python/agenta/sdk/agents/interfaces.py index dce5efa88a..804ed1ed6d 100644 --- a/sdks/python/agenta/sdk/agents/interfaces.py +++ b/sdks/python/agenta/sdk/agents/interfaces.py @@ -190,7 +190,11 @@ async def create_session( sandbox, config, harness=harness, - secrets=session_config.secrets, + secrets=( + session_config.resolved_connection.plaintext_environment() + if session_config.resolved_connection + else {} + ), trace=session_config.trace, run_context=session_config.run_context, session_id=session_config.session_id, diff --git a/sdks/python/agenta/sdk/agents/mcp/__init__.py b/sdks/python/agenta/sdk/agents/mcp/__init__.py index c6c8c5e6b8..d46e032c7e 100644 --- a/sdks/python/agenta/sdk/agents/mcp/__init__.py +++ b/sdks/python/agenta/sdk/agents/mcp/__init__.py @@ -8,12 +8,14 @@ ) from .interfaces import MCPSecretProvider from .models import ( + HeaderCredentialBinding, MCPConnection, MCPHeaderSecretRefs, MCPPolicy, MCPServerConfig, MCPToolPolicy, NoMCPCredentials, + ResolvedMCPCredential, ResolvedMCPServer, ) from .parsing import parse_mcp_server_config, parse_mcp_server_configs @@ -27,6 +29,8 @@ "MCPPolicy", "MCPToolPolicy", "NoMCPCredentials", + "HeaderCredentialBinding", + "ResolvedMCPCredential", "ResolvedMCPServer", "MCPSecretProvider", "MCPResolver", diff --git a/sdks/python/agenta/sdk/agents/mcp/models.py b/sdks/python/agenta/sdk/agents/mcp/models.py index 159abc8752..efd8e93207 100644 --- a/sdks/python/agenta/sdk/agents/mcp/models.py +++ b/sdks/python/agenta/sdk/agents/mcp/models.py @@ -4,7 +4,14 @@ from typing import Annotated, Any, Dict, List, Literal, Optional, Union -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + field_serializer, + field_validator, + model_validator, +) Permission = Literal["allow", "ask", "deny"] @@ -39,6 +46,22 @@ class MCPConnection(BaseModel): headers: Dict[str, str] = Field(default_factory=dict) credentials: MCPCredentials = Field(default_factory=NoMCPCredentials) + @model_validator(mode="after") + def _validate_header_roles(self) -> "MCPConnection": + secret_refs = ( + self.credentials.headers + if isinstance(self.credentials, MCPHeaderSecretRefs) + else {} + ) + for name, value in {**self.headers, **secret_refs}.items(): + if not name.strip() or not value: + raise ValueError("MCP bindings require non-empty names and values") + if {name.lower() for name in self.headers} & { + name.lower() for name in secret_refs + }: + raise ValueError("HTTP MCP public and credential headers must be unique") + return self + class MCPToolPolicy(BaseModel): model_config = ConfigDict(extra="forbid") @@ -79,16 +102,61 @@ def _reject_reserved_name(cls, value: str) -> str: return value +class HeaderCredentialBinding(BaseModel): + kind: Literal["header"] = "header" + name: str = Field(min_length=1) + + +class ResolvedMCPCredential(BaseModel): + binding: HeaderCredentialBinding + value: str = Field(min_length=1, repr=False) + usage: Literal["opaque_http"] = "opaque_http" + + @field_serializer("value", when_used="always") + def _mask_value(self, value: str) -> str: + """Structural guard (F-SDK-DUMP): a dump can never carry the credential value. + + Attribute access stays plain for the legitimate consumer (:meth:`to_wire`), which + reads ``self.value`` directly. Mirrors ``ResolvedCredential`` in + ``connections/models.py``. + """ + return "**********" + + def to_wire(self) -> Dict[str, Any]: + return { + "binding": self.binding.model_dump(), + "value": self.value, + "usage": self.usage, + } + + class ResolvedMCPServer(BaseModel): - """Per-run delivery config. Headers may contain resolved secret values.""" + """Per-run delivery config. + + Public HTTP headers and secret header credentials stay separate by protocol role: + ``headers`` carries only public values; each resolved secret rides ``credentials`` as a + typed header binding whose value must never be logged. + """ model_config = ConfigDict(extra="forbid", frozen=True) name: str url: str - headers: Dict[str, str] = Field(default_factory=dict, repr=False) + headers: Dict[str, str] = Field(default_factory=dict) # public headers only + credentials: List[ResolvedMCPCredential] = Field(default_factory=list, repr=False) policy: MCPPolicy = Field(default_factory=MCPPolicy) + @model_validator(mode="after") + def _validate_header_roles(self) -> "ResolvedMCPServer": + for name, value in self.headers.items(): + if not name.strip() or not value: + raise ValueError("MCP bindings require non-empty names and values") + names = [credential.binding.name.lower() for credential in self.credentials] + public_names = {name.lower() for name in self.headers} + if len(names) != len(set(names)) or any(name in public_names for name in names): + raise ValueError("http MCP header bindings must be unique") + return self + def to_wire(self) -> Dict[str, Any]: connection: Dict[str, Any] = { "type": "http", @@ -96,6 +164,8 @@ def to_wire(self) -> Dict[str, Any]: } if self.headers: connection["headers"] = dict(self.headers) + if self.credentials: + connection["credentials"] = [item.to_wire() for item in self.credentials] wire: Dict[str, Any] = { "name": self.name, diff --git a/sdks/python/agenta/sdk/agents/mcp/resolver.py b/sdks/python/agenta/sdk/agents/mcp/resolver.py index 57c2a67fb1..5058a12d25 100644 --- a/sdks/python/agenta/sdk/agents/mcp/resolver.py +++ b/sdks/python/agenta/sdk/agents/mcp/resolver.py @@ -46,10 +46,11 @@ async def resolve( if isinstance(credentials, MCPHeaderSecretRefs) else {} ) + # An empty resolved value is as unusable as an absent one, so both are missing. missing = [ secret_name for secret_name in secret_refs.values() - if secret_name not in secret_values + if not secret_values.get(secret_name) ] if missing and self._missing_secret_policy == MissingSecretPolicy.ERROR: raise MissingMCPSecretError( @@ -57,11 +58,6 @@ async def resolve( secret_names=missing, ) - headers = dict(server_config.connection.headers) - for header_name, secret_name in secret_refs.items(): - if secret_name in secret_values: - headers[header_name] = secret_values[secret_name] - if server_config.connection.url: try: assert_endpoint_url_allowed(server_config.connection.url) @@ -71,11 +67,24 @@ async def resolve( url=server_config.connection.url, ) from exc + # Public headers and secret header credentials stay separate by protocol role: + # each resolved secret becomes a typed header binding, never a merged header. + credentials = [ + { + "binding": {"kind": "header", "name": header_name}, + "value": secret_values[secret_name], + "usage": "opaque_http", + } + for header_name, secret_name in secret_refs.items() + if secret_values.get(secret_name) + ] + resolved.append( ResolvedMCPServer( name=server_config.name, url=server_config.connection.url, - headers=headers, + headers=dict(server_config.connection.headers), + credentials=credentials, policy=server_config.policy, ) ) diff --git a/sdks/python/agenta/sdk/agents/platform/connections.py b/sdks/python/agenta/sdk/agents/platform/connections.py index b7a95bc7e3..bdf16d50e9 100644 --- a/sdks/python/agenta/sdk/agents/platform/connections.py +++ b/sdks/python/agenta/sdk/agents/platform/connections.py @@ -24,12 +24,14 @@ HARNESS_CONNECTION_CAPABILITIES, PROVIDER_ENV_VARS, ) +from ..connections.endpoints import build_resolved_connection from ..connections import ( AmbiguousConnectionError, ConnectionNotFoundError, ConnectionResolutionError, EndpointResolutionError, Endpoint, + MissingCredentialError, MissingProviderError, ModelRef, ProviderMismatchError, @@ -500,11 +502,11 @@ def _resolve_from_secrets( model = model.model_copy(update={"provider": inferred}) if connection.mode == "self_managed": provider = model.provider or "" - return ResolvedConnection( + return build_resolved_connection( provider=provider, model=model.model, credential_mode="runtime_provided", - env={}, + values={}, # A miss means workspace-only downstream; do not guess. input_modalities=model_input_modalities( harness, model.model, provider=provider or None @@ -531,12 +533,14 @@ def _resolve_from_secrets( raise chosen.endpoint_resolution_error() env = chosen.resolved_env(provider) resolved_model = chosen.selected_model_id(model) - return ResolvedConnection( + if not env: + raise MissingCredentialError(provider=provider, slug=chosen.slug) + return build_resolved_connection( provider=provider, model=resolved_model, deployment=chosen.deployment, - credential_mode="env" if env else "runtime_provided", - env=env, + credential_mode="env", + values=env, endpoint=chosen.endpoint, # A miss means workspace-only downstream; do not guess. input_modalities=model_input_modalities( diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py index ce32192de6..3ece69488b 100644 --- a/sdks/python/agenta/sdk/agents/utils/wire.py +++ b/sdks/python/agenta/sdk/agents/utils/wire.py @@ -86,7 +86,6 @@ def request_to_wire( sandbox: str, config: HarnessAgentTemplate, messages: Sequence[Message], - secrets: Optional[Dict[str, str]] = None, trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, session_id: Optional[str] = None, @@ -104,15 +103,11 @@ def request_to_wire( packages, likewise omitted when there are none (skills ride their own seam, not the tool wire). ``config.wire_sandbox_permission()`` adds the declared sandbox security boundary, omitted when unset (plumbing only; the runner does not enforce it yet). - ``config.wire_model_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_connection_ref()`` adds the author's connection CHOICE (``self_managed``, or an + Agenta connection named by slug), omitted for the project default because it carries nothing + beyond the model. ``config.wire_model_connection()`` adds what that choice RESOLVED to: the + model route and typed credentials as one consumer-owned object. It is omitted when no + connection was resolved and overrides the base model id with the exact resolved model. ``config.wire_harness_files()`` adds the generic ``harnessFiles`` array: files the active harness's config rendered from its own ``permissions`` / ``extras`` slice, to materialize in the session cwd before the session starts (``path`` relative to cwd, ``content`` the file text). Omitted @@ -132,7 +127,6 @@ def request_to_wire( "agentsMd": config.agents_md, "model": config.model, "messages": [message.to_wire() for message in messages], - "secrets": dict(secrets or {}), # The run's tracing inputs ride the wire grouped by role (see the trace/telemetry interface # restructure): `context.propagation` carries the per-call W3C trace-context headers, and # `telemetry` carries the operator-owned exporter config + capture policy. Both come from the @@ -145,8 +139,8 @@ def request_to_wire( **config.wire_mcp(), **config.wire_skills(), **config.wire_sandbox_permission(), - **config.wire_model_ref(), - **config.wire_resolved_connection(), + **config.wire_connection_ref(), + **config.wire_model_connection(), **config.wire_harness_mode(), **config.wire_harness_files(), } @@ -168,6 +162,7 @@ def result_from_wire(data: Dict[str, Any]) -> AgentResult: stable code and a clear message rather than an empty reply. The runner ``error`` is sanitized at this boundary (one clean line, no stack/path leak); the full detail is logged. """ + data = get_active_redactor().redact_json(data, sink="runner_result") if not data.get("ok"): raise AgentRunFailed(sanitize_runner_error(data.get("error"))) diff --git a/sdks/python/agenta/sdk/agents/wire_models.py b/sdks/python/agenta/sdk/agents/wire_models.py index 3c6f8ab27c..1c74d9b4d2 100644 --- a/sdks/python/agenta/sdk/agents/wire_models.py +++ b/sdks/python/agenta/sdk/agents/wire_models.py @@ -77,12 +77,50 @@ class WireEndpoint(_WireModel): class WireConnection(_WireModel): - """The author's credential-connection intent (``{mode, slug?}``).""" + """The author's connection CHOICE: which connection they picked, not what it resolved to. + + Deliberately separate from :class:`WireModelConnection`. This is non-secret routing config + that the runner reads directly: a named Agenta connection's ``slug`` is the provider name a + custom OpenAI-compatible Pi run is registered under in Pi's own ``models.json`` + (``services/runner/src/engines/sandbox_agent/pi-model-config.ts``). The resolved route and + credentials ride ``modelConnection`` instead. + + Emitted only when the choice carries information: ``self_managed``, or an Agenta connection + naming a slug. The project default (``agenta`` with no slug) is omitted. + """ mode: Literal["agenta", "self_managed"] = "agenta" slug: Optional[str] = None +class WireCredentialBinding(_WireModel): + """Protocol location where the model client consumes one credential.""" + + 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 WireModelCapabilities(_WireModel): """Resolved model capabilities supplied to the runner.""" @@ -293,11 +331,35 @@ class WirePermissions(_WireModel): rules: Optional[List[WirePermissionRule]] = None +class WireMcpCredentialBinding(_WireModel): + kind: Literal["header"] + name: str + + +class WireMcpCredential(_WireModel): + binding: WireMcpCredentialBinding + value: str + usage: Literal["opaque_http"] + + +class WireMcpConnection(_WireModel): + """How the runner reaches one external HTTP MCP server. + + Public ``headers`` and secret header ``credentials`` stay separate by protocol role + (mirrors ``ResolvedMCPServer.to_wire``'s ``connection`` object). + """ + + type: Optional[str] = None + url: Optional[str] = None + headers: Optional[Dict[str, str]] = None + credentials: Optional[List[WireMcpCredential]] = None + + class WireMcpServer(_WireModel): - """A resolved external HTTP MCP server, mirrors ``mcp_servers_to_wire``.""" + """A resolved external HTTP MCP server, mirrors ``ResolvedMCPServer.to_wire``.""" name: str - connection: Dict[str, Any] + connection: WireMcpConnection policy: Dict[str, Any] @@ -418,22 +480,21 @@ 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. The author's connection CHOICE and what that choice RESOLVED to are + # two separate fields: `connection` is non-secret routing config the runner reads directly, + # `modelConnection` is the resolved route and its credentials. model: Optional[str] = None - harness_mode: Optional[str] = Field(default=None, alias="harnessMode") - 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" + ) + harness_mode: Optional[str] = Field(default=None, alias="harnessMode") + # Resolved model input modalities. Omitted when the resolver cannot determine them. model_capabilities: Optional[WireModelCapabilities] = Field( default=None, alias="modelCapabilities" ) # Turn. messages: Optional[List[WireChatMessage]] = None - # Secrets injected as harness env (provider keys); never written to the agent filesystem. - secrets: Optional[Dict[str, str]] = None # Tracing inputs, grouped by role (see the trace/telemetry interface restructure): ``context`` # carries the per-call W3C trace-context propagation, ``telemetry`` the operator-owned exporter # config + capture policy. Both come from the single service-side trace capture. diff --git a/sdks/python/agenta/sdk/redaction/context.py b/sdks/python/agenta/sdk/redaction/context.py index 769251dc94..078230f6f2 100644 --- a/sdks/python/agenta/sdk/redaction/context.py +++ b/sdks/python/agenta/sdk/redaction/context.py @@ -21,7 +21,9 @@ def get_active_redactor() -> Redactor: try: return _redactor_context.get() except LookupError: - return Redactor() + redactor = Redactor() + _redactor_context.set(redactor) + return redactor def set_active_redactor(redactor: Redactor) -> Token: diff --git a/sdks/python/agenta/sdk/redaction/seed.py b/sdks/python/agenta/sdk/redaction/seed.py index 0c11c52787..19a1fa0bf1 100644 --- a/sdks/python/agenta/sdk/redaction/seed.py +++ b/sdks/python/agenta/sdk/redaction/seed.py @@ -1,7 +1,7 @@ """Deny-set seeding: known-value redaction only ever registers VALUES, never key names. Sources per request/run: - 1. resolved connection/tool/mcp secrets (``ResolvedConnection.env``, ``ResolvedMCPServer.env``, + 1. resolved connection/tool/mcp secrets (``ResolvedConnection.credentials``, ``ResolvedMCPServer.env``, tool-spec secrets) — the values the platform just resolved for this run; 2. the request/run credential (the caller's Agenta API key); 3. the VALUE of any process env var selected by the name matchers below. diff --git a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py index 33876544d0..d5974be435 100644 --- a/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py +++ b/sdks/python/oss/tests/pytest/integration/agents/_fake_runner_backend.py @@ -54,7 +54,6 @@ def __init__( config: HarnessAgentTemplate, *, harness: HarnessKind, - secrets: Optional[Mapping[str, str]], trace: Optional[TraceContext], run_context: Optional[RunContext], session_id: Optional[str], @@ -62,7 +61,6 @@ def __init__( self._backend = backend self._config = config self._harness = harness - self._secrets = dict(secrets or {}) self._trace = trace self._run_context = run_context self._session_id = session_id @@ -78,7 +76,6 @@ def _wire_payload(self, messages: Sequence[Message]) -> Dict[str, Any]: sandbox="local", config=self._config, messages=messages, - secrets=self._secrets, trace=self._trace, run_context=self._run_context, session_id=self._session_id, @@ -150,6 +147,9 @@ async def create_session( config: HarnessAgentTemplate, *, harness: HarnessKind, + # Accepted for interface parity and ignored, exactly like the production sandbox-agent + # backend: resolved credentials reach the runner inside `config` as the typed + # `modelConnection`, never as a separate plaintext map on the wire. secrets: Optional[Mapping[str, str]] = None, trace: Optional[TraceContext] = None, run_context: Optional[RunContext] = None, @@ -159,7 +159,6 @@ async def create_session( self, config, harness=harness, - secrets=secrets, trace=trace, run_context=run_context, session_id=session_id, diff --git a/sdks/python/oss/tests/pytest/integration/agents/test_custom_connection_replay.py b/sdks/python/oss/tests/pytest/integration/agents/test_custom_connection_replay.py index a7e659fb61..4e0f521702 100644 --- a/sdks/python/oss/tests/pytest/integration/agents/test_custom_connection_replay.py +++ b/sdks/python/oss/tests/pytest/integration/agents/test_custom_connection_replay.py @@ -14,9 +14,9 @@ offline stand-in for the live ``GET /secrets/`` fetch), threads the resulting ``ResolvedConnection`` onto the ``SessionConfig``, and ``request_to_wire`` spreads it onto the ``/run`` payload. The test drives that through the real subprocess transport and asserts the - wire carries ``deployment=custom``, ``provider=openai``, the author's ``connection``, the - endpoint ``baseUrl``, the exact resolved ``model``, and the provider key present in - ``secrets`` by NAME (``OPENAI_API_KEY``), value redacted. + wire carries ``modelConnection`` with ``deployment=custom``, ``provider=openai``, the endpoint + ``baseUrl``, and the provider key as a typed credential bound to ``OPENAI_API_KEY`` (value + redacted), plus the author's ``connection`` and the exact resolved ``model`` at the top level. 2. The RESULT-parsing half. The recorded runner response replays back through ``result_from_wire`` / the transport, proving the SDK folds a real recorded custom-connection @@ -125,13 +125,12 @@ async def test_custom_openai_compatible_connection_replays(tmp_path): assert resolved.endpoint is not None assert resolved.endpoint.base_url == "https://openrouter.ai/api/v1" assert resolved.credential_mode == "env" - assert set(resolved.env) == {"OPENAI_API_KEY"} + assert [credential.binding.name for credential in resolved.credentials] == [ + "OPENAI_API_KEY" + ] - session_config = SessionConfig( - agent=template, - secrets=resolved.env, # Slice 1 ships the credential through `secrets` on the wire - resolved_connection=resolved, - ) + # The resolved connection is the only credential channel; nothing rides beside it. + session_config = SessionConfig(agent=template, resolved_connection=resolved) messages = [ Message(role=m["role"], content=m["content"]) for m in rec["request"]["messages"] @@ -145,16 +144,24 @@ async def test_custom_openai_compatible_connection_replays(tmp_path): # 1) REQUEST-shaping half: the /run wire carries the resolved custom-connection descriptor. sent = json.loads(capture_path.read_text(encoding="utf-8")) - assert sent["deployment"] == "custom" - assert sent["provider"] == "openai" assert sent["connection"] == {"mode": "agenta", "slug": "replay-compat"} - assert sent["endpoint"] == {"baseUrl": "https://openrouter.ai/api/v1"} assert sent["model"] == "openai/gpt-oss-20b:free" - assert sent["credentialMode"] == "env" - # The provider key rides `secrets` by NAME; the value is the redacted placeholder, and no - # real key ever reaches the wire (the fixture carries only `sk-test`). - assert "OPENAI_API_KEY" in sent["secrets"] - assert sent["secrets"]["OPENAI_API_KEY"] == "sk-test" + connection = sent["modelConnection"] + assert connection["deployment"] == "custom" + assert connection["provider"] == "openai" + assert connection["endpoint"] == {"baseUrl": "https://openrouter.ai/api/v1"} + assert connection["credentialMode"] == "env" + # The provider key rides one typed credential naming its own binding; the value is the + # redacted placeholder, and no real key ever reaches the wire (the fixture carries only + # `sk-test`). `usage: opaque_http` marks it as a key the remote provider reads over HTTPS, + # which is what makes it substitutable by a Daytona Secret on a remote sandbox. + assert connection["credentials"] == [ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-test", + "usage": "opaque_http", + } + ] # 2) RESULT-parsing half: the recorded runner response folds back cleanly, no live LLM. assert result.output == rec["result"]["output"] == "REPLAY-COMPAT-OK" diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py index 0ff60ae8a6..cce1413d9c 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_dtos_model_ref.py @@ -1,9 +1,9 @@ """``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. The +author's connection CHOICE crosses the runner boundary as a bare ``{mode, slug}`` reference, +because the runner routes on it; what that choice RESOLVED to (route plus credentials) crosses +only as ``modelConnection``. No credential and no flat provider field ride the wire. """ from __future__ import annotations @@ -58,64 +58,47 @@ def test_explicit_model_ref_is_respected(): assert config.model_ref.provider == "openai" -# ------------------------------------------------------------- wire_model_ref / wire +# ------------------------------------------------------ resolved model connection wire -def test_wire_model_ref_empty_for_string_only_config(): - config = PiAgentTemplate(model="openai-codex/gpt-5.5") - assert config.wire_model_ref() == {} - - -def test_wire_model_ref_emits_provider_and_connection_for_structured(): - config = PiAgentTemplate( - model={ +def test_wire_model_connection_empty_before_resolution(): + for model in ( + "openai-codex/gpt-5.5", + {"provider": "openai", "model": "gpt-5.5"}, + { "provider": "openai", "model": "gpt-5.5", "connection": {"mode": "agenta", "slug": "openai-prod"}, - } - ) - assert config.wire_model_ref() == { - "provider": "openai", - "connection": {"mode": "agenta", "slug": "openai-prod"}, - } - - -def test_wire_model_ref_omits_default_connection(): - config = PiAgentTemplate( - model={"provider": "openai", "model": "gpt-5.5"}, - ) - # Default connection carries no non-default info, so only the provider rides the wire. - assert config.wire_model_ref() == {"provider": "openai"} - - -def test_wire_model_ref_emits_self_managed_connection_without_slug(): - config = PiAgentTemplate( - model={ + }, + { "provider": "openai", "model": "gpt-5.5", "connection": {"mode": "self_managed"}, - } - ) - assert config.wire_model_ref() == { - "provider": "openai", - "connection": {"mode": "self_managed"}, - } + }, + ): + config = PiAgentTemplate(model=model) + assert config.wire_model_connection() == {} -def test_string_only_config_wire_has_no_new_keys(): - # The whole point of Slice 1: a string-only config's payload gains no new keys. +def test_string_only_config_wire_has_no_model_connection(): payload = request_to_wire( harness=HarnessKind.PI, sandbox="local", config=PiAgentTemplate(model="openai-codex/gpt-5.5"), messages=[Message(role="user", content="hi")], ) - assert "provider" not in payload - assert "connection" not in payload + assert "modelConnection" not in payload assert payload["model"] == "openai-codex/gpt-5.5" -def test_structured_config_wire_carries_provider_and_connection(): +def test_named_connection_choice_crosses_the_boundary_without_credentials(): + """The named connection rides the wire; nothing resolved or secret does. + + The runner needs the author's choice to route a named OpenAI-compatible Pi run through its + models.json path, so ``connection`` is part of the contract. The flat ``provider`` and + ``secrets`` fields are retired: a provider is only meaningful once resolved, and credentials + only ever travel inside ``modelConnection``. + """ payload = request_to_wire( harness=HarnessKind.PI, sandbox="local", @@ -129,8 +112,41 @@ 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", "secrets"): + assert removed not in payload + + +def test_project_default_connection_is_omitted_from_the_wire(): + """The project default (``agenta``, no slug) says nothing beyond the model, so it is omitted. + + This keeps a plain run's payload byte-identical to a config that never named a connection. + """ + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate(model={"provider": "openai", "model": "gpt-5.5"}), + messages=[Message(role="user", content="hi")], + ) + assert "connection" not in payload + + +def test_self_managed_connection_choice_crosses_the_boundary(): + """``self_managed`` is a real choice (the harness owns its own login), so it rides the wire.""" + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate( + model={ + "provider": "openai-codex", + "model": "gpt-5.5", + "connection": {"mode": "self_managed"}, + } + ), + messages=[Message(role="user", content="hi")], + ) + assert payload["connection"] == {"mode": "self_managed"} def test_default_connection_equality(): diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py index c3ebc8c423..2a389ac8c6 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_models.py @@ -3,7 +3,7 @@ Locks the three model-string shapes the design promises (``"openai/gpt-5.5"``, ``"gpt-5.5"``, a full object with a connection), the first-slash split (so a custom ``my-gw/llama-3`` parses correctly and a provider slug is never re-split), the ``Connection`` validity rules, and the -secret hygiene of ``ResolvedConnection.to_wire()`` (it never emits ``env``). +secret hygiene of ``ResolvedConnection`` (typed credentials, masked dumps). """ from __future__ import annotations @@ -17,6 +17,8 @@ ModelRef, ResolvedConnection, ) +from agenta.sdk.agents.connections.endpoints import build_resolved_connection +from agenta.sdk.agents.connections.errors import InvalidConnectionConfigurationError # ----------------------------------------------------------------- ModelRef.coerce @@ -134,22 +136,31 @@ def test_no_default_mode(): # --------------------------------------------------- ResolvedConnection / Endpoint shape -def test_resolved_connection_to_wire_excludes_env(): +def test_resolved_connection_to_wire_nests_typed_credentials(): resolved = ResolvedConnection( provider="openai", model="gpt-5.5", credential_mode="env", - env={"OPENAI_API_KEY": "sk-secret"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-secret", + "usage": "opaque_http", + } + ], endpoint=Endpoint(base_url="https://gw.example/v1"), ) - wire = resolved.to_wire() - assert "env" not in wire - assert "sk-secret" not in repr(wire) - assert wire == { + assert resolved.to_wire() == { "provider": "openai", - "model": "gpt-5.5", "deployment": "direct", "credentialMode": "env", + "credentials": [ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-secret", + "usage": "opaque_http", + } + ], "endpoint": {"baseUrl": "https://gw.example/v1"}, } @@ -176,35 +187,89 @@ def test_resolved_connection_to_wire_omits_endpoint_when_absent(): wire = resolved.to_wire() assert "endpoint" not in wire assert "modelCapabilities" not in wire + assert wire["credentials"] == [] assert wire["credentialMode"] == "runtime_provided" -def test_resolved_connection_env_is_hidden_from_repr(): +def test_local_use_credentials_do_not_require_an_http_endpoint(): + resolved = build_resolved_connection( + provider="bedrock", + model="anthropic.claude-x", + deployment="bedrock", + credential_mode="env", + values={"AWS_PROFILE": "profile"}, + ) + assert resolved.endpoint is None + assert resolved.credential_mode == "env" + assert [credential.usage for credential in resolved.credentials] == ["local_use"] + + +@pytest.mark.parametrize("values", [{"": "value"}, {"OPENAI_API_KEY": ""}]) +def test_build_resolved_connection_maps_malformed_bindings_to_configuration_error( + values, +): + # A malformed binding is a caller configuration problem: it must surface as the typed + # 422 error (like the endpoint-resolution failures), never as a bare ValueError (500). + with pytest.raises( + InvalidConnectionConfigurationError, + match="non-empty names and values", + ): + build_resolved_connection( + provider="openai", + model="gpt-5.5", + credential_mode="env", + values=values, + ) + + +def test_resolved_connection_credential_is_hidden_from_repr(): resolved = ResolvedConnection( provider="openai", model="gpt-5.5", credential_mode="env", - env={"OPENAI_API_KEY": "do-not-print"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "do-not-print", + "usage": "opaque_http", + } + ], + endpoint=Endpoint(base_url="https://api.openai.com/v1"), ) assert "do-not-print" not in repr(resolved) -def test_resolved_connection_env_is_masked_from_model_dump(): +def _env_credentials(env: dict[str, str]) -> list[dict]: + return [ + { + "binding": {"kind": "environment", "name": name}, + "value": value, + "usage": "opaque_http", + } + for name, value in env.items() + ] + + +def test_resolved_connection_credential_is_masked_from_model_dump(): # F-SDK-DUMP: repr hiding is not enough — a dump must not carry the credential either. resolved = ResolvedConnection( provider="openai", model="gpt-5.5", credential_mode="env", - env={"OPENAI_API_KEY": "do-not-dump"}, + credentials=_env_credentials({"OPENAI_API_KEY": "do-not-dump"}), + endpoint=Endpoint(base_url="https://api.openai.com/v1"), ) assert "do-not-dump" not in str(resolved.model_dump()) assert "do-not-dump" not in resolved.model_dump_json() - assert resolved.model_dump()["env"] == {"OPENAI_API_KEY": "**********"} + assert resolved.model_dump()["credentials"][0]["value"] == "**********" # The credential is still readable through attribute access (the harness path). - assert resolved.env["OPENAI_API_KEY"] == "do-not-dump" + assert resolved.credentials[0].value == "do-not-dump" + # ... and through the wire/local-materialization consumers. + assert resolved.to_wire()["credentials"][0]["value"] == "do-not-dump" + assert resolved.plaintext_environment() == {"OPENAI_API_KEY": "do-not-dump"} -def test_session_config_dump_does_not_leak_resolved_connection_env(): +def test_session_config_dump_does_not_leak_resolved_connection_credential(): # The nested case: a SessionConfig dump must not surface the connection's credential. from agenta.sdk.agents.dtos import AgentTemplate, SessionConfig @@ -214,7 +279,104 @@ def test_session_config_dump_does_not_leak_resolved_connection_env(): provider="openai", model="gpt-5.5", credential_mode="env", - env={"OPENAI_API_KEY": "do-not-dump"}, + credentials=_env_credentials({"OPENAI_API_KEY": "do-not-dump"}), + endpoint=Endpoint(base_url="https://api.openai.com/v1"), ), ) assert "do-not-dump" not in config.model_dump_json() + + +@pytest.mark.parametrize( + "credentials, endpoint, mode", + [ + ( + [ + { + "binding": {"kind": "environment", "name": ""}, + "value": "key", + "usage": "opaque_http", + } + ], + Endpoint(base_url="https://api.example"), + "env", + ), + ( + [ + { + "binding": {"kind": "environment", "name": "KEY"}, + "value": "", + "usage": "opaque_http", + } + ], + Endpoint(base_url="https://api.example"), + "env", + ), + ( + [ + { + "binding": {"kind": "environment", "name": "KEY"}, + "value": "key", + "usage": "opaque_http", + } + ], + None, + "env", + ), + ( + [ + { + "binding": {"kind": "environment", "name": "KEY"}, + "value": "key", + "usage": "opaque_http", + } + ], + Endpoint(base_url="http://api.example"), + "env", + ), + ([], Endpoint(base_url="https://api.example"), "env"), + ( + [ + { + "binding": {"kind": "environment", "name": "KEY"}, + "value": "key", + "usage": "local_use", + } + ], + Endpoint(base_url="https://api.example"), + "runtime_provided", + ), + ], +) +def test_resolved_connection_rejects_invalid_credential_combinations( + credentials, endpoint, mode +): + with pytest.raises(ValidationError): + ResolvedConnection( + provider="test", + model="m", + credential_mode=mode, + credentials=credentials, + endpoint=endpoint, + ) + + +def test_plaintext_environment_materializes_only_at_local_boundary(): + resolved = ResolvedConnection( + provider="anthropic", + model="claude", + deployment="bedrock", + credential_mode="env", + environment={"AWS_REGION": "us-east-1"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "AWS_ACCESS_KEY_ID"}, + "value": "AKIA", + "usage": "local_use", + } + ], + endpoint=Endpoint(base_url="https://bedrock-runtime.us-east-1.amazonaws.com"), + ) + assert resolved.plaintext_environment() == { + "AWS_REGION": "us-east-1", + "AWS_ACCESS_KEY_ID": "AKIA", + } diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py index 75a89c0dd2..4f98f805cf 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py +++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_resolver.py @@ -1,8 +1,7 @@ """The offline SDK-default resolvers: ``EnvConnectionResolver`` / ``StaticConnectionResolver``. -Locks the least-privilege contract: the env resolver returns exactly the one provider var when -present, ``runtime_provided`` (empty env) when absent or self-managed, and the static resolver -builds a resolved connection from a user-supplied credential. +Locks the least-privilege contract: managed resolvers return exactly the requested credential or +fail closed. Only an explicit self-managed connection yields ``runtime_provided`` with no env. """ from __future__ import annotations @@ -12,12 +11,18 @@ from agenta.sdk.agents.connections import ( Connection, EnvConnectionResolver, + MissingCredentialError, ModelRef, RuntimeAuthContext, StaticConnectionResolver, UnsupportedProviderError, ) + +def _credential_environment(resolved) -> dict[str, str]: + return {item.binding.name: item.value for item in resolved.credentials} + + _CTX = RuntimeAuthContext(harness="pi_core") @@ -34,9 +39,11 @@ async def test_env_resolver_returns_only_the_requested_provider_var(): ) assert resolved.credential_mode == "env" # Least privilege: exactly the one var, never the other provider's key. - assert resolved.env == {"OPENAI_API_KEY": "sk-openai"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-openai"} assert resolved.model == "gpt-5.5" assert resolved.provider == "openai" + assert resolved.endpoint.base_url == "https://api.openai.com/v1" + assert [item.usage for item in resolved.credentials] == ["opaque_http"] assert resolved.input_modalities == ["text", "image"] @@ -60,19 +67,16 @@ async def test_env_resolver_reads_the_live_process_env(monkeypatch): context=_CTX, ) assert resolved.credential_mode == "env" - assert resolved.env == {"OPENAI_API_KEY": "sk-from-env"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-from-env"} -async def test_env_resolver_absent_key_is_runtime_provided(): +async def test_env_resolver_absent_key_fails_closed(): resolver = EnvConnectionResolver(env={}) - resolved = await resolver.resolve( - model=ModelRef(provider="openai", model="gpt-5.5"), - context=_CTX, - ) - # Absence is valid: inject nothing, harness falls back to its own login. - assert resolved.credential_mode == "runtime_provided" - assert resolved.env == {} - assert resolved.model == "gpt-5.5" + with pytest.raises(MissingCredentialError, match="self_managed"): + await resolver.resolve( + model=ModelRef(provider="openai", model="gpt-5.5"), + context=_CTX, + ) async def test_env_resolver_self_managed_is_runtime_provided(): @@ -87,7 +91,7 @@ async def test_env_resolver_self_managed_is_runtime_provided(): ) # Self-managed injects nothing even when a key is in the env. assert resolved.credential_mode == "runtime_provided" - assert resolved.env == {} + assert _credential_environment(resolved) == {} async def test_env_resolver_errors_without_a_provider(): @@ -106,7 +110,7 @@ async def test_static_resolver_builds_from_an_api_key(): context=_CTX, ) assert resolved.credential_mode == "env" - assert resolved.env == {"OPENAI_API_KEY": "sk-static"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-static"} assert resolved.provider == "openai" assert resolved.model == "gpt-5.5" @@ -115,26 +119,39 @@ async def test_static_resolver_carries_a_base_url_into_the_endpoint(): resolver = StaticConnectionResolver( provider="openai", api_key="sk-static", - base_url="https://gw.example/v1", + base_url="https://gw.example:8443/v1", ) resolved = await resolver.resolve( model=ModelRef(provider="openai", model="gpt-5.5"), context=_CTX, ) assert resolved.endpoint is not None - assert resolved.endpoint.base_url == "https://gw.example/v1" + assert resolved.endpoint.base_url == "https://gw.example:8443/v1" # The base URL is non-secret and must not leak into env. - assert resolved.env == {"OPENAI_API_KEY": "sk-static"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-static"} -async def test_static_resolver_without_a_key_is_runtime_provided(): +async def test_static_resolver_without_a_key_fails_closed(): + resolver = StaticConnectionResolver(provider="openai") + with pytest.raises(MissingCredentialError, match="self_managed"): + await resolver.resolve( + model=ModelRef(provider="openai", model="gpt-5.5"), + context=_CTX, + ) + + +async def test_static_resolver_self_managed_without_a_key_is_runtime_provided(): resolver = StaticConnectionResolver(provider="openai") resolved = await resolver.resolve( - model=ModelRef(provider="openai", model="gpt-5.5"), + model=ModelRef( + provider="openai", + model="gpt-5.5", + connection=Connection(mode="self_managed"), + ), context=_CTX, ) assert resolved.credential_mode == "runtime_provided" - assert resolved.env == {} + assert _credential_environment(resolved) == {} async def test_static_resolver_from_dict(): @@ -145,5 +162,5 @@ async def test_static_resolver_from_dict(): model=ModelRef(provider="anthropic", model="claude-opus-4-8"), context=_CTX, ) - assert resolved.env == {"ANTHROPIC_API_KEY": "sk-ant"} + assert _credential_environment(resolved) == {"ANTHROPIC_API_KEY": "sk-ant"} assert resolved.provider == "anthropic" diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json index a47eb0e97a..8fe424302d 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.attachment.json @@ -3,7 +3,7 @@ "sandbox": "local", "sessionId": "sess-attachment", "agentsMd": "Use the attached file.", - "model": "claude-sonnet-4-6", + "model": "anthropic/claude-sonnet-4-6", "messages": [ { "role": "user", @@ -22,7 +22,6 @@ ] } ], - "secrets": {}, "context": null, "telemetry": null, "tools": ["read", "bash", "edit", "write", "grep", "find", "ls"], @@ -31,13 +30,16 @@ "permissions": { "default": "allow_reads" }, - "provider": "anthropic", - "deployment": "direct", - "credentialMode": "runtime_provided", "modelCapabilities": { "inputModalities": [ "text", "image" ] + }, + "modelConnection": { + "provider": "anthropic", + "deployment": "direct", + "credentialMode": "runtime_provided", + "credentials": [] } } diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json index 48ac69c51a..b52afc65a7 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.claude.json @@ -5,25 +5,29 @@ "agentsMd": "You are a helpful assistant.", "model": "claude-sonnet-4-6", "messages": [ - {"role": "user", "content": "hi"} + { + "role": "user", + "content": "hi" + } ], - "secrets": {"ANTHROPIC_API_KEY": "sk-ant"}, "context": null, "telemetry": null, - "runContext": { - "run": {"kind": "test"} - }, "tools": [], "customTools": [ { "name": "get_user", "description": "Get a user", - "inputSchema": {"type": "object", "properties": {}}, - "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", + "inputSchema": { + "type": "object", + "properties": {} + }, + "readOnly": true, "kind": "callback", - "contextBindings": {"target.workflow_variant_id": "$ctx.workflow.variant.id"}, - "timeoutMs": 120000, - "readOnly": true + "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", + "contextBindings": { + "target.workflow_variant_id": "$ctx.workflow.variant.id" + }, + "timeoutMs": 120000 } ], "toolCallback": { @@ -33,27 +37,63 @@ "permissions": { "default": "deny", "rules": [ - {"pattern": "WebFetch", "permission": "deny"}, - {"pattern": "Read", "permission": "allow"}, - {"pattern": "Bash(npm run:*)", "permission": "allow"} + { + "pattern": "WebFetch", + "permission": "deny" + }, + { + "pattern": "Read", + "permission": "allow" + }, + { + "pattern": "Bash(npm run:*)", + "permission": "allow" + } ] }, - "harnessFiles": [ - { - "path": ".claude/settings.json", - "content": "{\n \"permissions\": {\n \"defaultMode\": \"acceptEdits\",\n \"allow\": [\n \"Read\",\n \"Bash(npm run:*)\"\n ],\n \"deny\": [\n \"WebFetch\",\n \"mcp__agenta-tools__get_user\"\n ]\n }\n}" - } - ], "skills": [ { "name": "release-notes", "description": "Draft release notes from a changelog.", "body": "Read the changelog, then write release notes.", "files": [ - {"path": "scripts/draft.py", "content": "print('draft')", "executable": true} + { + "path": "scripts/draft.py", + "content": "print('draft')", + "executable": true + } ], "disableModelInvocation": true, "allowExecutableFiles": true } - ] + ], + "modelConnection": { + "provider": "anthropic", + "deployment": "direct", + "credentialMode": "env", + "credentials": [ + { + "binding": { + "kind": "environment", + "name": "ANTHROPIC_API_KEY" + }, + "value": "sk-ant", + "usage": "opaque_http" + } + ], + "endpoint": { + "baseUrl": "https://api.anthropic.com" + } + }, + "harnessFiles": [ + { + "path": ".claude/settings.json", + "content": "{\n \"permissions\": {\n \"defaultMode\": \"acceptEdits\",\n \"allow\": [\n \"Read\",\n \"Bash(npm run:*)\"\n ],\n \"deny\": [\n \"WebFetch\",\n \"mcp__agenta-tools__get_user\"\n ]\n }\n}" + } + ], + "runContext": { + "run": { + "kind": "test" + } + } } diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.codex.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.codex.json index 6293810fa4..67d0f0e9b8 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.codex.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.codex.json @@ -10,9 +10,6 @@ "content": "hi" } ], - "secrets": { - "OPENAI_API_KEY": "sk-openai" - }, "context": null, "telemetry": null, "tools": [], @@ -21,6 +18,24 @@ "permissions": { "default": "allow_reads" }, + "modelConnection": { + "provider": "openai", + "deployment": "direct", + "credentialMode": "env", + "credentials": [ + { + "binding": { + "kind": "environment", + "name": "OPENAI_API_KEY" + }, + "value": "sk-openai", + "usage": "opaque_http" + } + ], + "endpoint": { + "baseUrl": "https://api.openai.com/v1" + } + }, "harnessFiles": [ { "path": ".codex/config.toml", diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json index 39fe25b522..1363f84b52 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json +++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_request.pi_core.json @@ -5,9 +5,11 @@ "agentsMd": "You are a helpful assistant.", "model": "openai-codex/gpt-5.5", "messages": [ - {"role": "user", "content": "hi"} + { + "role": "user", + "content": "hi" + } ], - "secrets": {"OPENAI_API_KEY": "sk-test"}, "context": { "propagation": { "traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", @@ -29,40 +31,45 @@ } } }, - "runContext": { - "run": {"kind": "test"}, - "workflow": { - "artifact": {"id": "wf_abc"}, - "variant": {"id": "var_abc", "slug": "weather-agent"}, - "revision": {"id": "rev_abc123", "version": "3"}, - "is_draft": false - }, - "trace": { - "trace_id": "0af7651916cd43dd8448eb211c80319c", - "span_id": "b7ad6b7169203331" - } - }, "tools": ["read", "bash", "edit", "write", "grep", "find", "ls"], "customTools": [ { "name": "get_user", "description": "Get a user", - "inputSchema": {"type": "object", "properties": {}}, - "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", + "inputSchema": { + "type": "object", + "properties": {} + }, + "readOnly": true, "kind": "callback", - "contextBindings": {"target.workflow_variant_id": "$ctx.workflow.variant.id"}, - "timeoutMs": 120000, - "readOnly": true + "callRef": "tools__composio__github__GET_THE_AUTHENTICATED_USER__github-tvn", + "contextBindings": { + "target.workflow_variant_id": "$ctx.workflow.variant.id" + }, + "timeoutMs": 120000 }, { "name": "get_weather", "description": "Look up weather for a city", - "inputSchema": {"type": "object", "properties": {"city": {"type": "string"}}}, + "inputSchema": { + "type": "object", + "properties": { + "city": { + "type": "string" + } + } + }, "kind": "callback", "call": { "method": "POST", "path": "/api/workflows/invoke", - "body": {"references": {"workflow_revision": {"id": "rev_abc123"}}}, + "body": { + "references": { + "workflow_revision": { + "id": "rev_abc123" + } + } + }, "args_into": "data.inputs" } } @@ -71,7 +78,9 @@ "endpoint": "https://api.example/tools/call", "authorization": "Access tok-123" }, - "permissions": {"default": "allow_reads"}, + "permissions": { + "default": "allow_reads" + }, "systemPrompt": "You are Pi.", "appendSystemPrompt": "Be terse.", "skills": [ @@ -80,14 +89,62 @@ "description": "Draft release notes from a changelog.", "body": "Read the changelog, then write release notes.", "files": [ - {"path": "scripts/draft.py", "content": "print('draft')", "executable": true} + { + "path": "scripts/draft.py", + "content": "print('draft')", + "executable": true + } ], "disableModelInvocation": true, "allowExecutableFiles": true } ], "sandboxPermission": { - "network": {"mode": "off", "allowlist": []}, + "network": { + "mode": "off", + "allowlist": [] + }, "enforcement": "strict" + }, + "modelConnection": { + "provider": "openai-codex", + "deployment": "direct", + "credentialMode": "env", + "credentials": [ + { + "binding": { + "kind": "environment", + "name": "OPENAI_API_KEY" + }, + "value": "sk-test", + "usage": "opaque_http" + } + ], + "endpoint": { + "baseUrl": "https://api.openai.com/v1" + } + }, + "runContext": { + "run": { + "kind": "test" + }, + "workflow": { + "artifact": { + "id": "wf_abc" + }, + "variant": { + "id": "var_abc", + "slug": "weather-agent" + }, + "revision": { + "id": "rev_abc123", + "version": "3" + }, + "is_draft": false + }, + "trace": { + "trace_id": "0af7651916cd43dd8448eb211c80319c", + "span_id": "b7ad6b7169203331" + } } } diff --git a/sdks/python/oss/tests/pytest/unit/agents/mcp/test_resolver.py b/sdks/python/oss/tests/pytest/unit/agents/mcp/test_resolver.py index bbf44c4034..b715da4b63 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/mcp/test_resolver.py +++ b/sdks/python/oss/tests/pytest/unit/agents/mcp/test_resolver.py @@ -59,7 +59,18 @@ def test_server_name_must_be_a_runtime_safe_identifier(name): server(name=name) -async def test_resolves_public_and_secret_headers(): +def test_public_and_secret_header_names_must_be_unique(): + # One header name cannot be both a public value and a secret credential. + with pytest.raises(ValidationError, match="must be unique"): + MCPConnection( + type="http", + url=PUBLIC_MCP_URL, + headers={"Authorization": "public"}, + credentials=MCPHeaderSecretRefs(headers={"authorization": "token_ref"}), + ) + + +async def test_resolves_public_headers_and_typed_secret_credentials(): resolved = await MCPResolver( secret_provider=DictSecretProvider({"memory_token": "secret-value"}) ).resolve( @@ -76,17 +87,30 @@ async def test_resolves_public_and_secret_headers(): ) ] ) + # Public headers and secret credentials stay separate by protocol role: the resolved + # secret rides a typed header binding, never a merged header value. assert resolved[0].to_wire()["connection"] == { "type": "http", "url": PUBLIC_MCP_URL, - "headers": { - "X-Workspace": "demo", - "Authorization": "secret-value", - }, + "headers": {"X-Workspace": "demo"}, + "credentials": [ + { + "binding": {"kind": "header", "name": "Authorization"}, + "value": "secret-value", + "usage": "opaque_http", + } + ], } + assert "secret-value" not in repr(resolved[0]) + # Structural dump guard (F-SDK-DUMP), mirroring ResolvedCredential: a model_dump can never + # carry the credential value — only to_wire/attribute access hands it to the runner wire. + assert "secret-value" not in str(resolved[0].model_dump()) + assert "secret-value" not in resolved[0].model_dump_json() + dumped = resolved[0].model_dump() + assert dumped["credentials"][0]["value"] == "**********" -async def test_missing_mcp_secret_is_explicit(): +async def test_missing_http_mcp_secret_is_explicit(): with pytest.raises(MissingMCPSecretError): await MCPResolver(secret_provider=DictSecretProvider({})).resolve( [ @@ -103,6 +127,23 @@ async def test_missing_mcp_secret_is_explicit(): ) +async def test_empty_secret_value_is_treated_as_missing(): + with pytest.raises(MissingMCPSecretError): + await MCPResolver(secret_provider=DictSecretProvider({"token": ""})).resolve( + [ + server( + connection=MCPConnection( + type="http", + url=PUBLIC_MCP_URL, + credentials=MCPHeaderSecretRefs( + headers={"Authorization": "token"} + ), + ) + ) + ] + ) + + async def test_policy_rides_the_wire(): resolved = await MCPResolver(secret_provider=DictSecretProvider({})).resolve( [ @@ -167,3 +208,4 @@ async def test_omit_missing_secret_keeps_public_headers_only(): ] ) assert resolved[0].to_wire()["connection"]["headers"] == {"X-Workspace": "demo"} + assert "credentials" not in resolved[0].to_wire()["connection"] diff --git a/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py b/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py index 60cdb2a18d..0ddf231b6c 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py +++ b/sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py @@ -8,6 +8,8 @@ AmbiguousConnectionError, ConnectionNotFoundError, ConnectionResolutionError, + InvalidConnectionConfigurationError, + MissingCredentialError, MissingProviderError, ModelRef, ProviderMismatchError, @@ -17,6 +19,10 @@ from agenta.sdk.agents.platform import connections +def _credential_environment(resolved) -> dict[str, str]: + return {item.binding.name: item.value for item in resolved.credentials} + + def _model( slug: str | None = "openai", provider: str = "openai", model: str = "gpt-5.5" ) -> ModelRef: @@ -84,7 +90,7 @@ async def test_resolve_fetches_secrets_and_selects_one_key(fake_http, connection assert resolved.model == "gpt-5.5" assert resolved.deployment == "direct" assert resolved.credential_mode == "env" - assert resolved.env == {"OPENAI_API_KEY": "sk-prod"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-prod"} assert resolved.input_modalities == ["text", "image"] assert capture["method"] == "GET" assert capture["url"] == "https://api.x/api/secrets/" @@ -100,7 +106,7 @@ async def test_self_managed_short_circuits_without_api_base(fake_http): context=_context(), ) assert resolved.credential_mode == "runtime_provided" - assert resolved.env == {} + assert _credential_environment(resolved) == {} assert resolved.input_modalities == ["text", "image"] @@ -109,7 +115,15 @@ async def test_default_connection_requires_unique_provider_match(fake_http, conn resolved = await VaultConnectionResolver(connection).resolve( model=_model(slug=None), context=_context() ) - assert resolved.env == {"OPENAI_API_KEY": "sk-default"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-default"} + + +async def test_managed_connection_with_empty_key_fails_closed(fake_http, connection): + fake_http(connections, payload=[_provider_key("default", "openai", "")]) + with pytest.raises(MissingCredentialError, match="self_managed"): + await VaultConnectionResolver(connection).resolve( + model=_model(slug=None), context=_context() + ) async def test_default_connection_ambiguous(fake_http, connection): @@ -148,7 +162,7 @@ async def test_bare_catalog_model_infers_provider(fake_http, connection): model=ModelRef.coerce("gpt-4o-mini"), context=_context() ) assert resolved.provider == "openai" - assert resolved.env == {"OPENAI_API_KEY": "sk-prod"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-prod"} async def test_missing_provider_hint_is_harness_correct_for_claude( @@ -184,7 +198,9 @@ async def test_bare_claude_alias_resolves_to_anthropic(fake_http, connection): ) assert resolved.provider == "anthropic", alias assert resolved.model == alias, alias - assert resolved.env == {"ANTHROPIC_API_KEY": "sk-ant"}, alias + assert _credential_environment(resolved) == {"ANTHROPIC_API_KEY": "sk-ant"}, ( + alias + ) assert resolved.input_modalities == ["text", "image"], alias @@ -308,15 +324,48 @@ async def test_custom_provider_snake_case_extras_normalize_for_bedrock( assert resolved.provider == "anthropic" assert resolved.model == "anthropic.claude-3-5-sonnet" assert resolved.deployment == "bedrock" - assert resolved.env == { - "AWS_REGION": "us-east-1", + assert _credential_environment(resolved) == { "AWS_ACCESS_KEY_ID": "AKIA", "AWS_SECRET_ACCESS_KEY": "secret", "AWS_SESSION_TOKEN": "token", } + assert resolved.environment == {"AWS_REGION": "us-east-1"} + assert {item.usage for item in resolved.credentials} == {"local_use"} assert resolved.endpoint.region == "us-east-1" +async def test_bedrock_bearer_is_opaque_http_with_regional_endpoint( + fake_http, connection +): + fake_http( + connections, + payload=[ + _custom_provider( + "my-bedrock", + "bedrock", + extras={ + "aws_region_name": "eu-west-1", + "aws_bearer_token_bedrock": "bearer-token", + }, + models=["anthropic.claude-3-5-sonnet"], + ) + ], + ) + resolved = await VaultConnectionResolver(connection).resolve( + model=_model( + "my-bedrock", provider="anthropic", model="anthropic.claude-3-5-sonnet" + ), + context=RuntimeAuthContext(harness="claude"), + ) + assert resolved.endpoint.base_url == ( + "https://bedrock-runtime.eu-west-1.amazonaws.com" + ) + assert _credential_environment(resolved) == { + "AWS_BEARER_TOKEN_BEDROCK": "bearer-token" + } + assert [item.usage for item in resolved.credentials] == ["opaque_http"] + + async def test_custom_provider_vertex_snake_case_extras(fake_http, connection): fake_http( connections, @@ -338,11 +387,38 @@ async def test_custom_provider_vertex_snake_case_extras(fake_http, connection): context=RuntimeAuthContext(harness="claude"), ) assert resolved.deployment == "vertex_ai" - assert resolved.env == { + assert _credential_environment(resolved) == { + "GOOGLE_APPLICATION_CREDENTIALS": "/adc.json", + } + assert resolved.environment == { "GOOGLE_CLOUD_PROJECT": "proj", "GOOGLE_CLOUD_LOCATION": "us-central1", - "GOOGLE_APPLICATION_CREDENTIALS": "/adc.json", } + assert [item.usage for item in resolved.credentials] == ["local_use"] + + +async def test_vertex_api_key_mode_is_rejected_as_out_of_scope(fake_http, connection): + fake_http( + connections, + payload=[ + _custom_provider( + "vertex-key", + "vertex_ai", + extras={ + "vertex_ai_location": "us-central1", + "GOOGLE_CLOUD_API_KEY": "vertex-key-value", + }, + models=["gemini-model"], + ) + ], + ) + with pytest.raises( + InvalidConnectionConfigurationError, match="Vertex API-key authentication" + ): + await VaultConnectionResolver(connection).resolve( + model=_model("vertex-key", provider="gemini", model="gemini-model"), + context=RuntimeAuthContext(harness="pi_core"), + ) async def test_custom_gateway_api_key_from_extras_and_endpoint(fake_http, connection): @@ -364,7 +440,7 @@ async def test_custom_gateway_api_key_from_extras_and_endpoint(fake_http, connec context=RuntimeAuthContext(harness="claude"), ) assert resolved.deployment == "custom" - assert resolved.env == {"ANTHROPIC_API_KEY": "sk-gw"} + assert _credential_environment(resolved) == {"ANTHROPIC_API_KEY": "sk-gw"} assert resolved.endpoint.base_url == "https://93.184.216.34/v1" @@ -479,7 +555,7 @@ async def test_openai_compatible_custom_normalizes_to_openai(fake_http, connecti assert resolved.model == model_id assert resolved.endpoint.base_url == endpoint assert resolved.credential_mode == "env" - assert resolved.env == {"OPENAI_API_KEY": "sk-oai-compatible"} + assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-oai-compatible"} async def test_openai_compatible_custom_missing_url_fails_loud(fake_http, connection): @@ -516,7 +592,16 @@ async def test_full_custom_model_key_selects_and_strips_to_backend_model( fake_http( connections, payload=[ - _custom_provider("my-bedrock", "bedrock", models=["anthropic.claude-x"]) + _custom_provider( + "my-bedrock", + "bedrock", + extras={ + "aws_access_key_id": "AKIA", + "aws_secret_access_key": "secret", + "aws_region_name": "us-east-1", + }, + models=["anthropic.claude-x"], + ) ], ) resolved = await VaultConnectionResolver(connection).resolve( diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py index 0a48ef377b..ab956aca25 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_agent_composition_seam.py @@ -104,7 +104,7 @@ async def create_session( def _no_connection_result() -> ResolvedConnection: return ResolvedConnection( - provider="openai", model="m", credential_mode="runtime_provided", env={} + provider="openai", model="m", credential_mode="runtime_provided" ) @@ -169,7 +169,7 @@ async def test_absent_run_kind_leaves_composition_run_context_untouched(): # --------------------------------------------------------------------------- # -# Drift 1 + 2: capability gating and degradation policy are the SEAM DEFAULT now +# Drift 1 + 2: capability and fail-closed resolution policy are the SEAM DEFAULT now # (previously a bare fallback in handler.py with neither). # --------------------------------------------------------------------------- # async def test_default_composition_rejects_unsupported_provider_pre_resolve(): @@ -205,7 +205,13 @@ async def _resolve(*, model, context): model="anthropic.claude-x", deployment="bedrock", credential_mode="env", - env={"AWS_ACCESS_KEY_ID": "AKIA"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "AWS_ACCESS_KEY_ID"}, + "value": "AKIA", + "usage": "local_use", + } + ], ) comp = AgentComposition( @@ -238,7 +244,13 @@ async def _resolve(*, model, context): model="qwen2.5-coder:7b", deployment="custom", credential_mode="env", - env={"OPENAI_API_KEY": "sk-oai"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-oai", + "usage": "opaque_http", + } + ], endpoint={"base_url": "https://93.184.216.34/v1"}, ) @@ -276,7 +288,13 @@ async def _resolve(*, model, context): model="some-model", deployment="custom", credential_mode="env", - env={"ANTHROPIC_API_KEY": "sk-ant"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "ANTHROPIC_API_KEY"}, + "value": "sk-ant", + "usage": "opaque_http", + } + ], endpoint={"base_url": "https://93.184.216.34/v1"}, ) @@ -301,10 +319,9 @@ async def _resolve(*, model, context): ) -async def test_default_composition_degrades_default_connection_failure(): - """An unconfigured default-mode connection degrades to runtime_provided, no raise -- - even with NO composition override (the SDK default now has the degradation policy - the old bare fallback lacked).""" +async def test_default_composition_fails_closed_on_connection_resolution_failure(): + """A connection resolution failure fails closed, even with NO composition override + (the SDK default never degrades to an implicit runtime-provided fallback).""" backend = _FakeBackend(output="echo") async def _resolve(*, model, context): @@ -316,13 +333,14 @@ async def _resolve(*, model, context): ) handler = make_agent_handler(comp) - result = await handler( - request=_request(), - messages=[{"role": "user", "content": "hi"}], - parameters=_params("pi_core", model={"provider": "openai", "model": "gpt-5.5"}), - ) - - assert result == {"messages": [{"role": "assistant", "content": "echo"}]} + with pytest.raises(ConnectionResolutionError, match="network unreachable"): + await handler( + request=_request(), + messages=[{"role": "user", "content": "hi"}], + parameters=_params( + "pi_core", model={"provider": "openai", "model": "gpt-5.5"} + ), + ) async def test_composition_override_replaces_default_gating(): diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py index ae6ccddb41..3c1e600efc 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_harness_adapters.py @@ -65,13 +65,14 @@ def test_pi_keeps_native_tools(make_env): assert result.model == "m" -def test_pi_threads_model_ref_so_connection_reaches_wire(make_env): - """Regression: a named custom connection's ``{mode, slug}`` must reach the ``/run`` wire. +def test_pi_threads_model_ref_so_connection_reaches_resolver(make_env): + """Regression: a named custom connection's ``{mode, slug}`` must survive the adapter. ``_to_harness_config`` builds the wire-producing harness template. If it drops - ``model_ref`` (passing only the plain ``model`` string), ``wire_model_ref`` returns ``{}`` - and the runner never sees the connection slug, so it cannot build its ``models.json`` plan - for an OpenAI-compatible custom connection (the run then falls back to a default provider). + ``model_ref`` (passing only the plain ``model`` string), the connection resolver never + sees the slug, so it cannot select the OpenAI-compatible custom connection (the run then + falls back to a default provider). Author intent itself no longer rides the ``/run`` + wire — only the resolved ``modelConnection`` (route + typed credentials) does. """ harness = PiHarness(make_env(supported=[HarnessKind.PI])) agent = AgentTemplate( @@ -86,14 +87,11 @@ def test_pi_threads_model_ref_so_connection_reaches_wire(make_env): assert result.model_ref is not None assert result.model_ref.connection.slug == "my-compat" - # The connection intent reaches the /run wire so the runner can register the provider. - assert result.wire_model_ref().get("connection") == { - "mode": "agenta", - "slug": "my-compat", - } + # Unresolved author intent never rides the wire; only a resolved connection would. + assert result.wire_model_connection() == {} -def test_agenta_threads_model_ref_so_connection_reaches_wire(make_env): +def test_agenta_threads_model_ref_so_connection_reaches_resolver(make_env): """Same guarantee as Pi for the ``pi_agenta`` harness (it also runs Pi).""" harness = AgentaHarness(make_env(supported=[HarnessKind.AGENTA])) agent = AgentTemplate( @@ -107,10 +105,8 @@ def test_agenta_threads_model_ref_so_connection_reaches_wire(make_env): result = harness._to_harness_config(_session_config(agent=agent)) assert result.model_ref is not None - assert result.wire_model_ref().get("connection") == { - "mode": "agenta", - "slug": "my-compat", - } + assert result.model_ref.connection.slug == "my-compat" + assert result.wire_model_connection() == {} def test_pi_reads_its_harness_extras_slice(make_env): diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py b/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py new file mode 100644 index 0000000000..de4dbbc6c5 --- /dev/null +++ b/sdks/python/oss/tests/pytest/unit/agents/test_redaction_scope.py @@ -0,0 +1,235 @@ +"""Per-run redaction scoping in the agent handler. + +The handler seeds each run's deny-set from that run's resolved credentials. The seed must go +into a FRESH `Redactor` installed for exactly the run's scope (batch call / stream lifetime) +and restored on exit — never mutated into a long-lived ambient redactor. Otherwise sequential +runs sharing one task/context accumulate each other's secret values: the second run's sinks +would hold (and its lifetime would extend) the first run's live credentials. + +These tests drive `make_agent_handler` with the same fakes shape as +`test_agent_composition_seam.py` and observe the ambient redactor from inside the run (the +backend's `create_session` runs within the scope every sink shares). +""" + +from __future__ import annotations + +import asyncio +from typing import Any, AsyncIterator, Dict, List, Optional + +from agenta.sdk.agents import AgentResult, HarnessKind +from agenta.sdk.agents.connections import ResolvedConnection +from agenta.sdk.agents.handler import AgentComposition, make_agent_handler +from agenta.sdk.agents.interfaces import Backend, Sandbox, Session +from agenta.sdk.agents.streaming import AgentStream +from agenta.sdk.models.workflows import WorkflowServiceRequest +from agenta.sdk.redaction.context import get_active_redactor +from agenta.sdk.redaction.redactor import Redactor + +SECRET_A = "sk-run-a-fake-secret-aaaa1111aaaa1111" +SECRET_B = "sk-run-b-fake-secret-bbbb2222bbbb2222" + + +# --------------------------------------------------------------------------- # +# Fakes (mirrors test_agent_composition_seam.py's shape) +# --------------------------------------------------------------------------- # +class _FakeSandbox(Sandbox): + async def add_files(self, files) -> None: + return None + + async def destroy(self) -> None: + return None + + +class _FakeSession(Session): + def __init__(self, result: AgentResult) -> None: + self._result = result + + @property + def id(self) -> Optional[str]: + return self._result.session_id + + async def prompt(self, messages, *, on_event=None) -> AgentResult: + return self._result + + def stream(self, messages) -> AgentStream: + result = self._result + + async def _records() -> AsyncIterator[Dict[str, Any]]: + yield { + "kind": "event", + "event": {"type": "message", "text": result.output}, + } + yield { + "kind": "result", + "result": { + "ok": True, + "output": result.output, + "usage": result.usage, + "sessionId": result.session_id, + }, + } + + return AgentStream(_records()) + + async def destroy(self) -> None: + return None + + +class _CapturingBackend(Backend): + """Records the AMBIENT redactor observed inside each run (at session creation time).""" + + supported_harnesses = frozenset({HarnessKind.PI, HarnessKind.CLAUDE}) + + def __init__(self, *, output: str = "ok") -> None: + self._output = output + self.captured_redactors: List[Redactor] = [] + + async def create_sandbox(self) -> _FakeSandbox: + return _FakeSandbox() + + async def create_session( + self, + sandbox, + config, + *, + harness, + secrets=None, + trace=None, + run_context=None, + session_id=None, + ) -> _FakeSession: + self.captured_redactors.append(get_active_redactor()) + return _FakeSession(AgentResult(output=self._output, events=[], usage={})) + + +def _make_handler(secret: str, backend: _CapturingBackend): + async def _resolve(*, model, context): + return ResolvedConnection( + provider="openai", + model="qwen2.5-coder:7b", + deployment="custom", + credential_mode="env", + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": secret, + "usage": "opaque_http", + } + ], + endpoint={"base_url": "https://93.184.216.34/v1"}, + ) + + comp = AgentComposition( + select_backend=lambda template: backend, + resolve_connection=_resolve, + ) + return make_agent_handler(comp) + + +def _params() -> Dict[str, Any]: + return { + "agent": { + "harness": {"kind": "pi_core"}, + "llm": {"provider": "openai", "model": "qwen2.5-coder:7b"}, + } + } + + +def _messages() -> List[Dict[str, str]]: + return [{"role": "user", "content": "hi"}] + + +def _knows(redactor: Redactor, secret: str) -> bool: + """True when `secret` is in the redactor's deny-set (it gets scrubbed).""" + return secret not in (redactor.redact_string(f"x {secret}", sink="test") or "") + + +# --------------------------------------------------------------------------- # +# Sequential runs in ONE task/context: no accumulation, ambient restored +# --------------------------------------------------------------------------- # +async def test_sequential_batch_runs_do_not_accumulate_deny_set(): + backend = _CapturingBackend() + baseline = get_active_redactor() # the ambient redactor of this task, pre-run + + await _make_handler(SECRET_A, backend)( + request=WorkflowServiceRequest(), + messages=_messages(), + parameters=_params(), + ) + first = backend.captured_redactors[0] + assert _knows(first, SECRET_A), "run 1's redactor is seeded with run 1's secret" + # The run's scope closed: the ambient redactor is the pre-run one again, unseeded. + assert get_active_redactor() is baseline + assert not _knows(baseline, SECRET_A) + + await _make_handler(SECRET_B, backend)( + request=WorkflowServiceRequest(), + messages=_messages(), + parameters=_params(), + ) + second = backend.captured_redactors[1] + assert second is not first, "each run gets a FRESH redactor" + assert _knows(second, SECRET_B) + # The core isolation property: run 2's deny-set does NOT hold run 1's secret. + assert not _knows(second, SECRET_A) + assert get_active_redactor() is baseline + + +async def test_sequential_streaming_runs_scope_and_restore(): + backend = _CapturingBackend(output=f"leak {SECRET_A}") + baseline = get_active_redactor() + + stream = await _make_handler(SECRET_A, backend)( + request=WorkflowServiceRequest(flags={"stream": True}), + messages=_messages(), + parameters=_params(), + ) + events = [event async for event in stream] + # The seeded scope was active during iteration: the echoed secret is scrubbed from the + # live event wire. + assert events, "the stream produced events" + assert SECRET_A not in str(events) + assert "[ag:redacted" in str(events) + first = backend.captured_redactors[0] + assert _knows(first, SECRET_A) + # Exhausting the stream closed the scope: the ambient redactor is restored, unseeded. + assert get_active_redactor() is baseline + assert not _knows(baseline, SECRET_A) + + stream_b = await _make_handler(SECRET_B, backend)( + request=WorkflowServiceRequest(flags={"stream": True}), + messages=_messages(), + parameters=_params(), + ) + async for _ in stream_b: + pass + second = backend.captured_redactors[1] + assert _knows(second, SECRET_B) + assert not _knows(second, SECRET_A), "no accumulation across streamed runs" + assert get_active_redactor() is baseline + + +# --------------------------------------------------------------------------- # +# Concurrent runs: each task's scope holds only its own secret +# --------------------------------------------------------------------------- # +async def test_concurrent_runs_have_isolated_deny_sets(): + backend_a = _CapturingBackend() + backend_b = _CapturingBackend() + + await asyncio.gather( + _make_handler(SECRET_A, backend_a)( + request=WorkflowServiceRequest(), + messages=_messages(), + parameters=_params(), + ), + _make_handler(SECRET_B, backend_b)( + request=WorkflowServiceRequest(), + messages=_messages(), + parameters=_params(), + ), + ) + + run_a = backend_a.captured_redactors[0] + run_b = backend_b.captured_redactors[0] + assert _knows(run_a, SECRET_A) and not _knows(run_a, SECRET_B) + assert _knows(run_b, SECRET_B) and not _knows(run_b, SECRET_A) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py index 339f42d076..a95d50fadb 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_contract.py @@ -19,6 +19,9 @@ import pytest +from agenta.sdk.redaction.context import redaction_context +from agenta.sdk.redaction.redactor import Redactor + from agenta.sdk.agents import ( AgentaAgentTemplate, AgentTemplate, @@ -48,27 +51,31 @@ result_from_wire, sanitize_runner_error, ) +from agenta.sdk.agents.wire_models import WireRunRequest from agenta.sdk.agents.pi_builtins import PI_BUILTIN_TOOL_NAMES from agenta.sdk.utils.types import build_agent_v0_default -# The full set of top-level keys ``request_to_wire`` may emit. The TS ``AgentRunRequest`` -# interface must declare a superset of these. Adding a key here without adding it to -# protocol.ts is exactly the drift this set exists to catch. +# The full set of top-level keys ``request_to_wire`` may emit. THREE things must agree on it: +# this set, the ``WireRunRequest`` schema, and the TS ``AgentRunRequest`` interface. Adding a key +# to the producer without adding it here, or here without adding it to protocol.ts, is exactly +# the drift this set exists to catch. +# +# The schema half is checked structurally by ``test_known_request_keys_match_the_wire_schema`` +# below, because a payload-validation test cannot catch it: ``_WireModel`` sets +# ``extra="allow"``, so a payload carrying a field the schema forgot still validates cleanly and +# the field silently becomes an extra. A generated client built from that schema would then drop +# it. That is how ``connection`` went missing once already. KNOWN_REQUEST_KEYS = { "harness", "sandbox", "sessionId", "agentsMd", "model", + "connection", "harnessMode", "modelCapabilities", - "provider", - "connection", - "deployment", - "endpoint", - "credentialMode", + "modelConnection", "messages", - "secrets", "context", "telemetry", "runContext", @@ -132,6 +139,20 @@ def _pi_payload(): config = PiAgentTemplate( agents_md="You are a helpful assistant.", model="openai-codex/gpt-5.5", + resolved_connection=ResolvedConnection( + provider="openai-codex", + model="gpt-5.5", + deployment="direct", + credential_mode="env", + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-test", + "usage": "opaque_http", + } + ], + endpoint=Endpoint(base_url="https://api.openai.com/v1"), + ), custom_tools=[dict(_CUSTOM_TOOL), dict(_DIRECT_CALL_TOOL)], tool_callback=_CALLBACK, skills=[dict(_SKILL)], @@ -144,7 +165,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", @@ -177,6 +197,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", @@ -192,7 +229,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, @@ -203,13 +239,26 @@ def _codex_payload(): config = CodexAgentTemplate( agents_md="You are a helpful assistant.", model="gpt-5.6-luna", + resolved_connection=ResolvedConnection( + provider="openai", + model="gpt-5.6-luna", + deployment="direct", + credential_mode="env", + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-openai", + "usage": "opaque_http", + } + ], + endpoint=Endpoint(base_url="https://api.openai.com/v1"), + ), ) return request_to_wire( harness=HarnessKind.CODEX, sandbox="local", config=config, messages=[Message(role="user", content="hi")], - secrets={"OPENAI_API_KEY": "sk-openai"}, trace=None, run_context=RunContext(run=RunContextRun(kind="test")), session_id=None, @@ -552,9 +601,9 @@ def test_request_to_wire_codex_matches_golden(golden): assert "permissionPolicy" not in payload assert "systemPrompt" not in payload # Codex exposes no prompt overrides assert "appendSystemPrompt" not in payload - # A managed codex run (this default, unresolved => managed) renders config.toml carrying the - # file-free auth provider block (env_key OPENAI_API_KEY), even with no authored options. The - # secret never appears in the file; it rides `secrets` (D-002 final ruling). + # A managed codex run (credential_mode "env") renders config.toml carrying the file-free auth + # provider block (env_key OPENAI_API_KEY), even with no authored options. The secret never + # appears in the file; it rides `modelConnection.credentials` (D-002 final ruling). assert payload["harnessFiles"] == [ { "path": ".codex/config.toml", @@ -566,7 +615,13 @@ def test_request_to_wire_codex_matches_golden(golden): ), } ] - assert payload["secrets"] == {"OPENAI_API_KEY": "sk-openai"} + assert payload["modelConnection"]["credentials"] == [ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-openai", + "usage": "opaque_http", + } + ] assert payload["context"] is None assert payload["telemetry"] is None assert "trace" not in payload @@ -615,7 +670,6 @@ def test_request_to_wire_codex_managed_is_file_free_provider_block(): sandbox="local", config=config, messages=[Message(role="user", content="hi")], - secrets={"OPENAI_API_KEY": "sk-openai"}, ) assert payload["harnessFiles"] == [ { @@ -628,7 +682,7 @@ def test_request_to_wire_codex_managed_is_file_free_provider_block(): ), } ] - # The secret rides the `secrets` wire field, never the file. + # No credential value ever appears in the file. assert "sk-openai" not in payload["harnessFiles"][0]["content"] @@ -650,7 +704,6 @@ def test_request_to_wire_codex_subscription_renders_no_provider_block(): provider="openai", model="gpt-5.6-luna", credential_mode="runtime_provided", - env={}, ), ) payload = request_to_wire( @@ -721,19 +774,72 @@ 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_known_request_keys_match_the_wire_schema(): + """``WireRunRequest`` must declare exactly the keys the producer may emit. + + The subset guard above cannot catch a field the SCHEMA forgot, for two reasons. It only sees + the keys the three sample payloads happen to carry, and ``_WireModel`` sets ``extra="allow"``, + so even a payload that does carry the field validates cleanly with the field demoted to an + extra. The schema is what generated clients are built from, so a field missing here is a + field those clients drop. + + Equality, not a subset, in both directions: a key the schema declares and the producer never + emits is dead contract surface that readers will assume is real. + """ + declared = { + field.alias or name for name, field in WireRunRequest.model_fields.items() + } + assert declared == KNOWN_REQUEST_KEYS + + +def test_named_connection_choice_is_a_declared_schema_field(): + """A named Agenta connection reaches the runner as a first-class field, not as an extra. + + The runner registers a custom OpenAI-compatible Pi run in Pi's ``models.json`` under a + provider named after this slug (``pi-model-config.ts``), so a client that dropped the field + would silently misroute those runs to the generic provider path. + """ + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=PiAgentTemplate( + model={ + "provider": "openai", + "model": "gpt-5.5", + "connection": {"mode": "agenta", "slug": "openrouter-prod"}, + } + ), + messages=[Message(role="user", content="hi")], + ) + assert payload["connection"] == {"mode": "agenta", "slug": "openrouter-prod"} + assert set(payload) <= KNOWN_REQUEST_KEYS + + parsed = WireRunRequest.model_validate(payload) + assert parsed.connection is not None + assert parsed.connection.slug == "openrouter-prod" + # The point of the assertion: `connection` is a MODELLED field, so it survives a schema + # round-trip. An extra would be dropped by `model_dump` without `serialize_as_any`. + assert parsed.model_dump(by_alias=True, exclude_none=True)["connection"] == { + "mode": "agenta", + "slug": "openrouter-prod", + } + + +def test_request_to_wire_carries_consumer_owned_model_connection(): config = PiAgentTemplate( - model="openai/gpt-5.5", # 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"), ), ) @@ -742,21 +848,77 @@ def test_request_to_wire_carries_resolved_connection_non_secret_descriptor(): sandbox="local", config=config, messages=[Message(role="user", content="hi")], - secrets={"OPENAI_API_KEY": "sk-secret"}, # the secret rides here, by design ) assert set(payload) <= KNOWN_REQUEST_KEYS - assert payload["provider"] == "openai" - assert payload["credentialMode"] == "env" - assert payload["deployment"] == "custom" - assert payload["endpoint"] == {"baseUrl": "https://gw.example/v1"} - # Exactly one `model` key, and it is the resolved exact model (last spread wins). - assert payload["model"] == "gpt-5.5-2026" - # The secret only rides `secrets`; `env` is never serialized onto the wire. - assert payload["secrets"] == {"OPENAI_API_KEY": "sk-secret"} - assert "env" not in payload - assert ( - "sk-secret" not in {k: v for k, v in payload.items() if k != "secrets"}.values() + assert payload["model"] == "openai/gpt-5.5-2026" + assert payload["modelConnection"] == { + "provider": "openai", + "deployment": "custom", + "credentialMode": "env", + "credentials": [ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-secret", + "usage": "opaque_http", + } + ], + "endpoint": {"baseUrl": "https://gw.example/v1"}, + } + for removed in ( + "secrets", + "provider", + "connection", + "deployment", + "endpoint", + "credentialMode", + ): + assert removed not in payload + + +@pytest.mark.parametrize( + ("provider", "model", "expected"), + [ + ("openai", "shared-model", "openai/shared-model"), + ("openrouter", "shared-model", "openrouter/shared-model"), + ("openrouter", "meta-llama/llama-3", "openrouter/meta-llama/llama-3"), + ], +) +def test_pi_wire_model_preserves_resolved_provider(provider, model, expected): + config = PiAgentTemplate( + model=model, + resolved_connection=ResolvedConnection( + provider=provider, + model=model, + deployment="direct", + credential_mode="runtime_provided", + ), + ) + payload = request_to_wire( + harness=HarnessKind.PI, + sandbox="local", + config=config, + messages=[Message(role="user", content="hi")], ) + assert payload["model"] == expected + + +def test_claude_wire_model_keeps_bare_alias(): + config = ClaudeAgentTemplate( + model="sonnet", + resolved_connection=ResolvedConnection( + provider="anthropic", + model="sonnet", + deployment="direct", + credential_mode="runtime_provided", + ), + ) + payload = request_to_wire( + harness=HarnessKind.CLAUDE, + sandbox="local", + config=config, + messages=[Message(role="user", content="hi")], + ) + assert payload["model"] == "sonnet" def test_request_to_wire_omits_resolved_connection_when_none(): @@ -769,11 +931,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" @@ -1044,3 +1203,23 @@ def test_permission_policy_absent_from_serialized_session_config(): claude_payload = _claude_payload() assert "permissionPolicy" not in json.dumps(pi_payload) assert "permissionPolicy" not in json.dumps(claude_payload) + + +def test_result_from_wire_redacts_seeded_credential_from_output_events_and_errors(): + marker = "sk-live-marker-12345678" + redactor = Redactor().with_known_secrets([marker]) + with redaction_context(redactor): + result = result_from_wire( + { + "ok": True, + "output": f"echo {marker}", + "messages": [{"role": "assistant", "content": marker}], + "events": [{"type": "message", "content": marker}], + } + ) + assert marker not in result.output + assert marker not in repr(result.messages) + assert marker not in repr(result.events) + with pytest.raises(RuntimeError) as exc: + result_from_wire({"ok": False, "error": f"provider rejected {marker}"}) + assert marker not in str(exc.value) diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py b/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py index 0142713d5c..4c7eeb9d31 100644 --- a/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py +++ b/sdks/python/oss/tests/pytest/unit/agents/test_wire_models.py @@ -24,8 +24,10 @@ import jsonschema import pytest +from pydantic import ValidationError from agenta.sdk.agents.wire_models import ( + WireMcpServer, WireRunRequest, WireRunResult, run_contract_schemas, @@ -121,3 +123,33 @@ def test_minimal_result_validates(): payload = {"ok": True} jsonschema.validate(payload, CATALOG_TYPES["run_result"]) assert WireRunResult.model_validate(payload).ok is True + + +def test_mcp_wire_schema_separates_public_headers_and_credentials(): + # Public headers and secret header credentials stay separate by protocol role inside the + # server's `connection` object. + server = WireMcpServer.model_validate( + { + "name": "linear", + "connection": { + "type": "http", + "url": "https://mcp.linear.app/sse", + "headers": {"X-Client": "agenta"}, + "credentials": [ + { + "binding": {"kind": "header", "name": "Authorization"}, + "value": "secret-marker", + "usage": "opaque_http", + } + ], + }, + "policy": {"tools": {"mode": "all"}}, + } + ) + assert server.connection.credentials + assert server.connection.credentials[0].binding.name == "Authorization" + # The retired flat stdio shape (mixed secret env) is not part of the schema anymore. + with pytest.raises(ValidationError): + WireMcpServer.model_validate( + {"name": "legacy", "transport": "stdio", "env": {"TOKEN": "secret"}} + ) diff --git a/services/oss/tests/pytest/unit/agent/test_invoke_handler.py b/services/oss/tests/pytest/unit/agent/test_invoke_handler.py index d141914687..0ffae99ada 100644 --- a/services/oss/tests/pytest/unit/agent/test_invoke_handler.py +++ b/services/oss/tests/pytest/unit/agent/test_invoke_handler.py @@ -33,6 +33,17 @@ from oss.src.agent import app +def _first_allowed_provider(harness): + """The first provider ``harness`` can consume, per the SDK's own capability table. + + Used by the no-credential stub so one stub satisfies the post-resolve capability gate for + every harness this file runs, without the test restating the capability matrix. + """ + from agenta.sdk.agents.capabilities import HARNESS_CONNECTION_CAPABILITIES + + return HARNESS_CONNECTION_CAPABILITIES[harness].providers[0] + + def _request(*, stream=None, session_id=None): """Build the request `_agent` reads stream/session_id off of. @@ -63,15 +74,18 @@ async def _no_mcp(mcp_servers, **_kw): return [] async def _no_connection(*, model, context): - # No connection is configured for the default model, so the resolve fails and the handler - # degrades to a no-credential ``runtime_provided`` plan (harness login / self-managed). - # This is the realistic "no connection" simulation: it exercises the degraded path that - # every harness tolerates, so the response-body / lifecycle / cross-harness tests run - # clean regardless of harness. A stubbed *successful* resolve would instead pin a single - # provider and be rejected by the post-resolve capability gate on a mismatched harness - # (e.g. ``openai`` on ``claude``), which is not what these tests are exercising. - raise ConnectionResolutionError( - "no connection configured for the default model" + # The run has no key of its own: the harness authenticates with its own login, so the + # resolve succeeds with a no-credential ``runtime_provided`` plan. Resolution FAILURES + # are fail-closed now, so a raising stub would abort every test in this file instead of + # exercising the response-body / lifecycle / cross-harness paths they are about. + # + # The provider is read from the harness's own capability table rather than hardcoded, + # because the post-resolve gate rejects a provider the harness cannot consume (e.g. + # ``openai`` on ``claude``) and this stub serves every harness the file exercises. + return ResolvedConnection( + provider=_first_allowed_provider(context.harness), + model=model.model, + credential_mode="runtime_provided", ) monkeypatch.setattr(app, "resolve_tools", _tools) @@ -420,8 +434,9 @@ async def _no_mcp(mcp_servers, **_kw): async def test_named_connection_env_reaches_session(monkeypatch, fake_backend): """A structured ModelRef with a named connection resolves one key onto the session. - The resolved ``env`` reaches ``SessionConfig.secrets`` (the wire's credential channel) and - the ``ResolvedConnection`` is set on the session. The resolver is called with a ``ModelRef`` + The resolved credential reaches the session on ``SessionConfig.resolved_connection`` (the + single credential channel) and is materialized as plaintext only at the local backend + boundary. The resolver is called with a ``ModelRef`` carrying the config's connection and a ``RuntimeAuthContext`` for the run. """ backend = fake_backend(result=AgentResult(output="echo", usage={"total": 1})) @@ -434,7 +449,14 @@ async def _resolve(*, model, context): provider="openai", model="gpt-5.5", credential_mode="env", - env={"OPENAI_API_KEY": "sk-x"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "OPENAI_API_KEY"}, + "value": "sk-x", + "usage": "opaque_http", + } + ], + endpoint={"base_url": "https://api.openai.com/v1"}, ) built = _patch_resolution(monkeypatch, backend, resolve=_resolve) @@ -451,11 +473,13 @@ async def _resolve(*, model, context): assert captured["context"].harness == "pi_core" assert captured["context"].project_id is None - # the resolved key reached the backend boundary as the session's secrets + # the resolved key reached the local backend boundary as plaintext environment assert backend.created_secrets == [{"OPENAI_API_KEY": "sk-x"}] session_cfg = built[0] - assert session_cfg.secrets == {"OPENAI_API_KEY": "sk-x"} assert session_cfg.resolved_connection is not None + assert session_cfg.resolved_connection.plaintext_environment() == { + "OPENAI_API_KEY": "sk-x" + } assert session_cfg.resolved_connection.provider == "openai" @@ -471,8 +495,7 @@ async def _resolve(*, model, context): return ResolvedConnection( provider="anthropic", model="claude-x", - credential_mode="env", - env={}, + credential_mode="runtime_provided", ) _patch_resolution(monkeypatch, backend, resolve=_resolve) @@ -498,28 +521,26 @@ async def _resolve(*, model, context): await _invoke("pi_core", model=_STRUCTURED_MODEL) -async def test_default_connection_resolution_failure_degrades( +async def test_default_connection_resolution_failure_fails_closed( monkeypatch, fake_backend ): - """An unconfigured default-mode run degrades gracefully: no raise, empty secrets. + """A default-mode resolution failure propagates instead of degrading. - This is the common playground case (a default model on every run, no configured - connection). A resolution failure must NOT crash the run; the harness uses its own login, - exactly as the old whole-vault dump returned ``{}`` and the run proceeded. + A vault outage or a missing key used to degrade into an empty ``runtime_provided`` plan, so + the run continued with no credential and failed later as a confusing provider auth error. It + now fails closed: a caller that genuinely wants harness-owned authentication must say so with + a ``self_managed`` connection (see ``test_self_managed_connection_reaches_session``), which + stays an explicit choice rather than an implicit fallback. """ backend = fake_backend(result=AgentResult(output="echo", usage={"total": 1})) async def _resolve(*, model, context): raise ConnectionResolutionError("connection resolution request failed") - built = _patch_resolution(monkeypatch, backend, resolve=_resolve) + _patch_resolution(monkeypatch, backend, resolve=_resolve) - body = await _invoke("pi_core", model={"provider": "openai", "model": "gpt-5.5"}) - - assert body == {"messages": [{"role": "assistant", "content": "echo"}]} - assert backend.created_secrets == [{}] - assert built[0].secrets == {} - assert built[0].resolved_connection.credential_mode == "runtime_provided" + with pytest.raises(ConnectionResolutionError): + await _invoke("pi_core", model={"provider": "openai", "model": "gpt-5.5"}) async def test_default_connection_missing_provider_fails_loud( @@ -573,7 +594,14 @@ async def _resolve(*, model, context): model="anthropic.claude-x", deployment="bedrock", credential_mode="env", - env={"AWS_ACCESS_KEY_ID": "AKIA", "AWS_REGION": "us-east-1"}, + environment={"AWS_REGION": "us-east-1"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "AWS_ACCESS_KEY_ID"}, + "value": "AKIA", + "usage": "local_use", + } + ], ) built = _patch_resolution(monkeypatch, backend, resolve=_resolve) @@ -584,7 +612,12 @@ async def _resolve(*, model, context): assert body == {"messages": [{"role": "assistant", "content": "echo"}]} assert built[0].resolved_connection.deployment == "bedrock" - assert built[0].secrets == {"AWS_ACCESS_KEY_ID": "AKIA", "AWS_REGION": "us-east-1"} + # Public config (the region) and the local-use credential materialize together only at the + # local execution boundary; on the wire they stay separate fields. + assert built[0].resolved_connection.plaintext_environment() == { + "AWS_ACCESS_KEY_ID": "AKIA", + "AWS_REGION": "us-east-1", + } async def test_pi_bedrock_rejected_post_resolve(monkeypatch, fake_backend): @@ -599,7 +632,13 @@ async def _resolve(*, model, context): model="anthropic.claude-x", deployment="bedrock", credential_mode="env", - env={"AWS_ACCESS_KEY_ID": "AKIA"}, + credentials=[ + { + "binding": {"kind": "environment", "name": "AWS_ACCESS_KEY_ID"}, + "value": "AKIA", + "usage": "local_use", + } + ], ) _patch_resolution(monkeypatch, backend, resolve=_resolve) @@ -726,7 +765,6 @@ async def _resolve(*, model, context): provider="openai-codex", model=model.model, credential_mode="runtime_provided", - env={}, ) built = _patch_resolution(monkeypatch, backend, resolve=_resolve) @@ -746,5 +784,5 @@ async def _resolve(*, model, context): assert captured["model"].connection.mode == "self_managed" # No key injected: the harness uses its own subscription login. assert backend.created_secrets == [{}] - assert built[0].secrets == {} + assert built[0].resolved_connection.plaintext_environment() == {} assert built[0].resolved_connection.credential_mode == "runtime_provided" diff --git a/services/runner/README.md b/services/runner/README.md index 4c116a3053..b7672687c6 100644 --- a/services/runner/README.md +++ b/services/runner/README.md @@ -105,9 +105,48 @@ pnpm run build:extension ## Auth -Provider keys arrive as `request.secrets` (resolved from the project vault) or fall back to -the harness's own login: Pi reads `~/.pi/agent/auth.json` (`pnpm exec pi` then `/login`), -Claude Code reads `~/.claude`. Set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` to override. +Credentials arrive grouped by the consumer that owns them, not as one flat map. The model's +key and route ride `request.modelConnection`; each MCP server's key rides its own +`mcpServers[].connection.credentials`. That grouping is what lets the runner know which host a +given key is allowed to reach, which the section below depends on. A request that still sends +the retired flat fields (`secrets`, `provider`, `deployment`, `credentialMode`, `endpoint`) is +rejected outright rather than run without the credential it meant to supply. + +A run can also carry no key and fall back to the harness's own login: Pi reads +`~/.pi/agent/auth.json` (`pnpm exec pi` then `/login`), Claude Code reads `~/.claude`, Codex +reads `CODEX_HOME`. Set `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` to override locally. + +### Hiding keys from the sandbox + +By default a key reaches the sandbox as an ordinary environment variable or HTTP header, so the +agent running there can read it. That matters because an agent writes and runs its own code: a +prompt injection that convinces it to print its environment prints the key. + +Set `AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local` and, on Daytona, the runner instead +stores each key as a Daytona Secret restricted to the one hostname that key authenticates +against, and puts a placeholder in the sandbox. Daytona substitutes the real value into outbound +requests to that host, so the model call and the MCP call still work while the agent only ever +holds a placeholder. A request to any other host carries the placeholder, which is what makes +exfiltration fail. + +Three things to know: + +- The runner's Daytona API key needs permission to manage Secrets. A key that can create + sandboxes does not automatically have it. Without it, every run carrying a model or MCP key + fails at sandbox creation; the runner never quietly falls back to plaintext. The error names + the variable and the permission (`DAYTONA_SECRETS_PERMISSION_MESSAGE` in `daytona-secrets.ts`). +- `process_local` names the guarantee: the runner tracks the Secret records it created in its own + memory and deletes them when the sandbox goes away. Restart the runner while sandboxes are live + and those records are orphaned until someone removes them. +- Keys a provider SDK signs with locally instead of sending, which today means the AWS keys behind + Bedrock, cannot be hidden this way. There is no outbound request to substitute them into, so the + sandbox holds the real value. They are marked `usage: "local_use"` and the set of names allowed + to claim that is a short explicit allowlist in `daytona-secret-plan.ts`. + +The local sandbox is unaffected: the harness runs inside the runner container, so its keys never +leave the deployment. See +[the configuration reference](../../docs/docs/self-host/reference/01-configuration.mdx) for the +operator-facing version. ## config/ diff --git a/services/runner/src/engines/sandbox_agent/attachments.ts b/services/runner/src/engines/sandbox_agent/attachments.ts index ac9f75de4b..59398b0cd8 100644 --- a/services/runner/src/engines/sandbox_agent/attachments.ts +++ b/services/runner/src/engines/sandbox_agent/attachments.ts @@ -23,7 +23,7 @@ import { type FetchedAttachment, } from "../../sessions/attachments.ts"; import { attachmentDeliveryUnsupportedMessage } from "./capabilities.ts"; -import type { RunPlan } from "./run-plan.ts"; +import type { RunPlan, RunPlanWorkspace } from "./run-plan.ts"; import { COLD_FRAME_USER_LABEL } from "./transcript.ts"; export type AttachmentDeliveryOutcome = @@ -83,8 +83,10 @@ export interface AttachmentSandbox { }) => Promise<{ exitCode?: number } | undefined>; } -type MaterializePlan = Pick; -type DeliveryPlan = Pick; +type MaterializePlan = Pick & { + workspace: Pick; +}; +type DeliveryPlan = MaterializePlan & Pick; type Auth = () => string; type Log = (message: string) => void; @@ -398,7 +400,7 @@ export async function materializeWorkingCopy( ref: AttachmentRef, bytes: Uint8Array, ): Promise<"written" | "exists"> { - const path = attachmentWorkingPath(plan.cwd, ref); + const path = attachmentWorkingPath(plan.workspace.cwd, ref); return plan.isDaytona ? daytonaMaterialize(sandbox, path, bytes) : localMaterialize(path, bytes); @@ -637,7 +639,7 @@ async function workingCopyExists( plan: MaterializePlan, ref: AttachmentRef, ): Promise { - const path = attachmentWorkingPath(plan.cwd, ref); + const path = attachmentWorkingPath(plan.workspace.cwd, ref); if (plan.isDaytona) { if (typeof sandbox.statFs !== "function") return false; await rejectDaytonaSymlinks(sandbox, [ @@ -839,7 +841,7 @@ export async function resolveCurrentTurnAttachments(input: { const authoritative = verifiedRef(ref, fetched); let path: AttachmentPath; try { - path = attachmentWorkingPath(input.plan.cwd, authoritative); + path = attachmentWorkingPath(input.plan.workspace.cwd, authoritative); await materializeWorkingCopy( input.sandbox, input.plan, diff --git a/services/runner/src/engines/sandbox_agent/codex-assets.ts b/services/runner/src/engines/sandbox_agent/codex-assets.ts index 73c6cc41b8..7990df80d0 100644 --- a/services/runner/src/engines/sandbox_agent/codex-assets.ts +++ b/services/runner/src/engines/sandbox_agent/codex-assets.ts @@ -24,7 +24,11 @@ import { existsSync, mkdirSync, symlinkSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; -import type { RunPlan } from "./run-plan.ts"; +import type { + RunPlan, + RunPlanCredentials, + RunPlanWorkspace, +} from "./run-plan.ts"; type Log = (message: string) => void; @@ -44,6 +48,17 @@ export function codexHomeDir(cwd: string): string { * constant across a session's turns and is NOT a config-fingerprint input, preserving warm daemon * reuse. */ +/** The slice that decides which Codex auth mode a run is in. */ +type CodexModePlan = Pick & { + credentials: Pick; +}; + +/** The mode slice plus where the run's Codex home is rooted. */ +type CodexHomePlan = CodexModePlan & + Pick & { + workspace: Pick; + }; + export function codexSqliteHomeDir(cwd: string): string { return join(tmpdir(), "agenta", "codex-sqlite", basename(cwd)); } @@ -54,18 +69,20 @@ export function codexSqliteHomeDir(cwd: string): string { * own mounted OAuth login instead, so it is excluded here. */ export function isManagedCodexRun( - plan: Pick, + plan: CodexModePlan, ): boolean { return ( - plan.acpAgent === "codex" && plan.credentialMode !== "runtime_provided" + plan.acpAgent === "codex" && + plan.credentials.credentialMode !== "runtime_provided" ); } export function isSubscriptionCodexRun( - plan: Pick, + plan: CodexModePlan, ): boolean { return ( - plan.acpAgent === "codex" && plan.credentialMode === "runtime_provided" + plan.acpAgent === "codex" && + plan.credentials.credentialMode === "runtime_provided" ); } @@ -86,7 +103,7 @@ function codexSubscriptionMountDir(): string | undefined { * for best-effort teardown cleanup. Subscription additionally pins the credential store to `file`. */ export function configureCodexHome( - plan: Pick, + plan: CodexHomePlan, env: Record, ): string | undefined { // Local codex only (managed or subscription). Daytona and non-codex runs are no-ops. @@ -94,10 +111,10 @@ export function configureCodexHome( // Runner-owned per-session home in both modes. For subscription this overrides the operator's // mount path that buildDaemonEnv inherited into env.CODEX_HOME, so only the auth.json we symlink // in (see symlinkCodexSubscriptionAuthFile) is visible — not the operator's config/plugins/apps. - env.CODEX_HOME = codexHomeDir(plan.cwd); + env.CODEX_HOME = codexHomeDir(plan.workspace.cwd); // Both modes redirect SQLite off the home so neither the geesefs cwd nor the operator mount // accumulates per-run WAL SQLite. - const sqliteHome = codexSqliteHomeDir(plan.cwd); + const sqliteHome = codexSqliteHomeDir(plan.workspace.cwd); mkdirSync(sqliteHome, { recursive: true }); env.CODEX_SQLITE_HOME = sqliteHome; // Subscription: pin the credential store to `file` so a keyring/auto mode (from any config layer) @@ -133,12 +150,12 @@ export function codexDaytonaSqliteHomeDir(cwd: string): string { * (run-plan.ts); local runs and non-codex runs are no-ops. */ export function configureDaytonaCodexEnv( - plan: Pick, + plan: CodexHomePlan, daytonaEnv: Record, ): void { if (!plan.isDaytona || !isManagedCodexRun(plan)) return; - daytonaEnv.CODEX_HOME = codexHomeDir(plan.cwd); - daytonaEnv.CODEX_SQLITE_HOME = codexDaytonaSqliteHomeDir(plan.cwd); + daytonaEnv.CODEX_HOME = codexHomeDir(plan.workspace.cwd); + daytonaEnv.CODEX_SQLITE_HOME = codexDaytonaSqliteHomeDir(plan.workspace.cwd); } /** @@ -151,7 +168,7 @@ export function configureDaytonaCodexEnv( * file-free and never reach here. */ export function symlinkCodexSubscriptionAuthFile( - plan: Pick, + plan: CodexHomePlan, log: Log = () => {}, ): void { if (!isSubscriptionCodexRun(plan) || plan.isDaytona) return; @@ -162,7 +179,7 @@ export function symlinkCodexSubscriptionAuthFile( return; } - const home = codexHomeDir(plan.cwd); + const home = codexHomeDir(plan.workspace.cwd); mkdirSync(home, { recursive: true, mode: 0o700 }); const linkPath = join(home, "auth.json"); if (existsSync(linkPath)) return; diff --git a/services/runner/src/engines/sandbox_agent/daemon.ts b/services/runner/src/engines/sandbox_agent/daemon.ts index bde21fd5af..7a2e7871dd 100644 --- a/services/runner/src/engines/sandbox_agent/daemon.ts +++ b/services/runner/src/engines/sandbox_agent/daemon.ts @@ -201,16 +201,16 @@ export function inheritableProviderEnvVars( export interface BuildDaemonEnvOptions { /** - * Clear-then-apply (Security rule 5): on a MANAGED run (`credentialMode === "env"`) the - * resolved `secrets` are the sole authority, so the daemon must NOT inherit the sidecar's own - * provider keys (the caller applies only `plan.secrets`). When true, no `KNOWN_PROVIDER_ENV_VARS` - * are copied. When false (a `runtime_provided` / `none` run), the daemon keeps the inherited - * provider/auth keys so the harness's own login still works. + * Clear-then-apply (Security rule 5): on a managed (`credentialMode "env"`) or credential-less + * (`"none"`) run the resolved model environment is the sole authority, so the daemon must NOT + * inherit the sidecar's own provider keys (the caller applies only `plan.modelEnvironment`). + * When true, no `KNOWN_PROVIDER_ENV_VARS` are copied. When false (a `runtime_provided` run), + * the daemon keeps the inherited provider/auth keys so the harness's own login still works. */ clearProviderEnv?: boolean; - /** The run's resolved provider family (`request.provider`); narrows the inherited key set. */ + /** The run's resolved provider family (`request.modelConnection.provider`); narrows the inherited key set. */ provider?: string; - /** The run's resolved deployment surface (`request.deployment`); adds its cloud cred group. */ + /** The run's resolved deployment surface (`request.modelConnection.deployment`); adds its cloud cred group. */ deployment?: string; /** * Escape hatch: inherit EVERY `KNOWN_PROVIDER_ENV_VARS` entry instead of just the declared @@ -235,12 +235,14 @@ export function inheritAllProviderKeys( * launch variables and (for non-managed runs) known provider auth, not the full sidecar * environment. * - * Clear-then-apply (Security rule 5 in the provider-model-auth design): on a managed run - * (`clearProviderEnv`) this copies NONE of `KNOWN_PROVIDER_ENV_VARS`, so the only provider env - * the daemon ever sees is what the caller applies from `plan.secrets`. An inherited - * `ANTHROPIC_API_KEY` can therefore not leak into a resolved OpenAI run. For a `runtime_provided` - * / `none` run the harness uses its own login, so the inherited keys are kept — but only the ones - * the run's DECLARED provider/deployment needs (RUN-SEC-1), not every configured provider key. + * Clear-then-apply (Security rule 5 in the provider-model-auth design): on a managed (`"env"`) + * or credential-less (`"none"`) run (`clearProviderEnv`) this copies NONE of + * `KNOWN_PROVIDER_ENV_VARS`, so the only provider env the daemon ever sees is what the caller + * applies from `plan.modelEnvironment`. An inherited `ANTHROPIC_API_KEY` can therefore not leak + * into a resolved OpenAI run, and a `"none"` run (which asserts no credential) inherits nothing. + * For a `runtime_provided` run the harness uses its own login, so the inherited keys are kept — + * but only the ones the run's DECLARED provider/deployment needs (RUN-SEC-1), not every + * configured provider key. */ export function buildDaemonEnv( _harness: string, @@ -278,8 +280,8 @@ export function buildDaemonEnv( for (const key of KNOWN_SANDBOX_ENV_VARS) env[key] = ""; // Managed run: clear (inherit no provider keys); the caller applies only the resolved - // `plan.secrets`. Non-managed run: keep only the DECLARED provider's own keys so its login - // works without handing the harness every other provider's credential. + // `plan.modelEnvironment`. Non-managed run: keep only the DECLARED provider's own keys so its + // login works without handing the harness every other provider's credential. if (!clearProviderEnv) { const inheritable = inheritAllProviderEnv ? KNOWN_PROVIDER_ENV_VARS diff --git a/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts b/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts new file mode 100644 index 0000000000..c771e09f29 --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts @@ -0,0 +1,323 @@ +import { isIP } from "node:net"; + +import type { McpServerConfig, ModelConnection } from "../../protocol.ts"; + +/** + * One credential that QUALIFIES to be hidden behind a Daytona Secret, before any Secret record + * exists. + * + * "Candidate" rather than "secret" because this object is the output of a pure decision and the + * input to a side effect. Building it decides, from the request alone, that a value can be + * hidden: nothing is created, nothing is called, and the same request always produces the same + * list. The provider then takes each candidate and actually creates the Daytona Secret record, + * which can fail, needs cleanup, and turns the candidate into a real `dtn_secret_` + * placeholder. Keeping the two apart is what makes the decision unit-testable without a Daytona + * account, and what lets the flag-off path build the same plan and simply not act on it. + */ +export interface DaytonaSecretCandidate { + /** + * Position in the plan, assigned in build order. It is the stable name each MCP candidate gets + * in the sandbox (`AGENTA_MCP_SECRET_`), so the same request always produces the same + * variable names and a warm sandbox can be compared against a new request field by field. + */ + ordinal: number; + /** + * WHO reads this credential. Two consumers exist today because two things in a run hold + * credentials: the model, and each HTTP MCP server (named, because a run can attach several + * and each has its own key). This is the field that was missing from the old flat `secrets` + * map, and without it the runner could not tell which host a given key was allowed to reach, + * which is what a Daytona Secret restriction requires. + */ + consumer: { kind: "model" } | { kind: "http_mcp"; server: string }; + /** + * WHERE the value lands in the sandbox. `environment` for a model key (harnesses read provider + * keys from environment variables); `header` for an MCP credential (an HTTP MCP server reads + * its key from a request header). These are the only two delivery points that exist in the + * run today, and they follow from the two consumers above rather than being an open set. + */ + binding: { kind: "environment" | "header"; name: string }; + /** + * The single DNS hostname this credential may be substituted into, e.g. `api.openai.com`. + * + * It cannot be a wildcard and cannot be omitted, and that is the point of the whole feature. + * Daytona substitutes the real value into an outbound request only when the request goes to + * this exact host; anywhere else the agent sends the placeholder instead. A wildcard, or an + * unknown host, would mean the agent could exfiltrate the real key by making one request to a + * server it controls, which is the attack this exists to prevent. + * + * It is always knowable in practice because a credential only qualifies as `opaque_http` when + * we already know the endpoint it authenticates against: the model connection carries + * `endpoint.baseUrl`, and an MCP server carries its own `url`. A credential whose destination + * we do not know is not hideable at all, and `buildDaytonaSecretPlan` rejects the run rather + * than guessing (see the `opaque model credentials require endpoint.baseUrl` failure). + */ + allowedHost: string; + value: string; +} + +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); + // A model credential and a direct environment binding land in ONE create request as + // `secrets` and `envVars` respectively; a shared name would leave precedence to Daytona. + // (MCP candidates attach under generated AGENTA_MCP_SECRET_ names, so only the model + // consumer can collide.) + if ( + candidate.consumer.kind === "model" && + directBindings.has(candidate.binding.name.toLowerCase()) + ) { + fail( + `credential binding '${candidate.binding.name}' collides with a direct environment binding`, + ); + } + const consumerKey = + candidate.consumer.kind === "model" ? "model" : candidate.consumer.server; + const key = `${candidate.consumer.kind}:${consumerKey}:${candidate.binding.kind}:${candidate.binding.name.toLowerCase()}`; + 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; + // Two passes: settle every direct (local_use) binding first, so the collision check in + // `add` sees the complete direct set regardless of credential order on the wire. + for (const credential of connection.credentials ?? []) { + if (credential.usage !== "local_use") continue; + assertLocalUseBinding(credential.binding.name); + const normalized = credential.binding.name.toLowerCase(); + if (directBindings.has(normalized)) { + fail( + `duplicate direct environment binding '${credential.binding.name}'`, + ); + } + directBindings.add(normalized); + environment[credential.binding.name] = credential.value; + } + for (const credential of connection.credentials ?? []) { + if (credential.usage === "local_use") continue; + if (!host) { + fail( + "opaque model credentials require endpoint.baseUrl for exact-host restriction", + ); + } + add({ + consumer: { kind: "model" }, + binding: credential.binding, + allowedHost: host, + value: credential.value, + }); + } + } + + for (const server of input.mcpServers ?? []) { + const headers = server.connection?.headers ?? {}; + const credentials = server.connection?.credentials ?? []; + const hasHeaders = + Object.keys(headers).length > 0 || credentials.length > 0; + if (hasHeaders && !server.connection?.url) { + fail(`headers on MCP server '${server.name}' require a URL`); + } + const host = hasHeaders + ? exactHttpsHost(server.connection!.url) + : undefined; + for (const [name, value] of Object.entries(headers)) { + if (!name.trim() || !value) { + fail( + `HTTP MCP header on server '${server.name}' requires a non-empty name and value`, + ); + } + add({ + consumer: { kind: "http_mcp", server: server.name }, + binding: { kind: "header", name }, + allowedHost: host!, + value, + }); + } + for (const credential of credentials) { + if (credential.usage !== "opaque_http") { + fail("HTTP MCP credentials must use opaque_http"); + } + add({ + consumer: { kind: "http_mcp", server: server.name }, + binding: credential.binding, + allowedHost: host!, + value: credential.value, + }); + } + } + + return { candidates, environment }; +} + +export function daytonaOpaqueSecretsEnabled( + value: string | undefined = process.env.AGENTA_RUNNER_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_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local to enable process-local Secret cleanup; " + + "plaintext fallback is not allowed.", + ); + } +} diff --git a/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts b/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts new file mode 100644 index 0000000000..08e64d2a5d --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/daytona-secret-provider.ts @@ -0,0 +1,353 @@ +import type { McpServerConfig } from "../../protocol.ts"; +import { DaytonaReconnectTerminalError } from "./daytona-provider.ts"; +import type { DaytonaSecretPlan } from "./daytona-secret-plan.ts"; +import { + allocateDaytonaSecrets, + deleteDaytonaSecrets, + isDaytonaNotFound, + type DaytonaSecretAllocation, + type DaytonaSecretApi, +} from "./daytona-secrets.ts"; + +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; +} + +async function destroySandboxIdempotently( + provider: DaytonaProviderLike, + sandboxId: string, +): Promise { + try { + await provider.destroy(sandboxId); + } catch (error) { + if (!isDaytonaNotFound(error)) throw error; + } +} + +/** Serialize lifecycle side effects for one sandbox allocation without poisoning later calls. */ +function serialize( + entry: RegistryEntry, + operation: () => Promise, +): Promise { + const result = entry.operation.catch(() => {}).then(operation); + entry.operation = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +function withMcpPlaceholders( + servers: McpServerConfig[] | undefined, + allocation: DaytonaSecretAllocation | undefined, +): McpServerConfig[] | undefined { + if (!allocation || !servers) return servers; + return servers.map((server) => { + const placeholders = allocation.mcpHeaderPlaceholders[server.name]; + const headers = server.connection?.headers; + const credentials = server.connection?.credentials; + if ( + Object.keys(headers ?? {}).length === 0 && + (credentials?.length ?? 0) === 0 + ) { + return server; + } + if (!placeholders) { + throw new Error( + `Daytona Secret allocation is missing MCP placeholders for '${server.name}'.`, + ); + } + return { + ...server, + connection: { + ...server.connection, + headers: headers + ? Object.fromEntries( + Object.keys(headers).map((name) => { + const placeholder = placeholders[name]; + if (!placeholder) { + throw new Error( + `Daytona Secret allocation is missing MCP placeholder '${name}'.`, + ); + } + return [name, placeholder]; + }), + ) + : undefined, + credentials: credentials?.map((credential) => { + const placeholder = placeholders[credential.binding.name]; + if (!placeholder) { + throw new Error( + `Daytona Secret allocation is missing MCP placeholder '${credential.binding.name}'.`, + ); + } + return { ...credential, value: placeholder }; + }), + }, + }; + }); +} + +/** + * Wrap Daytona provisioning with process-local Secret allocation. + * + * A parked sandbox and its allocation live in the registry together. Secret ownership is + * process-local BY DESIGN: the registry dies with the runner process, so a hard crash can + * orphan a Daytona Secret (until Daytona's auto-delete backstop reaps the sandbox). This is an + * accepted limit of the process_local mode, not a bug to patch here — durable reconciliation + * is an explicit follow-up (PR B of the Daytona-Secrets design; see PR #5278). + */ +export function daytonaWithProcessLocalSecrets( + buildProvider: (attachments: Record) => T, + plan: DaytonaSecretPlan, + api: DaytonaSecretApi, + dependencies: ProcessLocalSecretDependencies, +): T & ProcessLocalDaytonaSecretProvider { + const registry = dependencies.registry ?? processLocalRegistry; + const schedule = dependencies.setCleanupTimer ?? setTimeout; + const cancel = dependencies.clearCleanupTimer ?? clearTimeout; + const log = dependencies.log ?? (() => {}); + const createFingerprint = + dependencies.createFingerprint ?? JSON.stringify(plan); + let provider: T | undefined; + let currentAllocation: DaytonaSecretAllocation | undefined; + + const providerFor = (attachments: Record): T => { + provider ??= buildProvider(attachments); + return provider; + }; + + const cleanupAfterSandbox = async ( + sandboxId: string, + entry: RegistryEntry, + activeProvider: T, + ): Promise => { + // A Secret remains mounted until Daytona confirms the sandbox is absent. Never reverse this + // order, including timer cleanup and create compensation after an id was returned. + await destroySandboxIdempotently(activeProvider, sandboxId); + await deleteDaytonaSecrets(entry.allocation, api); + if (registry.get(sandboxId) === entry) registry.delete(sandboxId); + if (currentAllocation === entry.allocation) currentAllocation = undefined; + }; + + const facade: ProcessLocalDaytonaSecretProvider = { + name: "daytona", + async create(...args: unknown[]): Promise { + const allocation = await allocateDaytonaSecrets(plan, api); + try { + provider = buildProvider(allocation.attachments); + } catch (cause) { + // buildProvider is synchronous and failed before any remote create call, so absence is + // proven and compensation may safely remove the newly allocated Secrets. + try { + await deleteDaytonaSecrets(allocation, api); + } catch (cleanupError) { + throw new AggregateError( + [cause, cleanupError], + "Daytona provider construction failed and Secret cleanup was incomplete.", + ); + } + throw cause; + } + try { + const sandboxId = await provider.create(...args); + const entry: RegistryEntry = { + allocation, + plan, + createFingerprint, + generation: 0, + operation: Promise.resolve(), + }; + registry.set(sandboxId, entry); + currentAllocation = allocation; + return sandboxId; + } catch (cause) { + // The vendored provider creates the remote sandbox before it starts the daemon and only + // returns the id after both succeed. A rejection therefore cannot prove remote absence. + // Retain Secrets rather than deleting records that a partially-created sandbox may mount. + if (allocation.created.length > 0) { + log( + "Daytona create failed before remote absence could be confirmed; retaining " + + `${allocation.created.length} Secret allocation(s) for safety.`, + ); + } + throw cause; + } + }, + async reconnect(sandboxId: string): Promise { + const entry = registry.get(sandboxId); + const activeProvider = providerFor({}); + if (!entry) { + // The runner restarted or lost ownership. It cannot prove which Secrets back the parked + // sandbox, so delete the sandbox and force the caller onto a fresh create. + await destroySandboxIdempotently(activeProvider, sandboxId); + throw new DaytonaReconnectTerminalError( + sandboxId, + "missing-process-local-secret-allocation", + ); + } + if (entry.cleanupTimer) { + cancel(entry.cleanupTimer); + entry.cleanupTimer = undefined; + } + // Invalidate a timer callback that fired but has not entered its serialized operation yet. + // If cleanup already owns the operation, reconnect waits and observes the deleted entry. + entry.generation += 1; + await serialize(entry, async () => { + if (registry.get(sandboxId) !== entry) { + throw new DaytonaReconnectTerminalError( + sandboxId, + "missing-process-local-secret-allocation", + ); + } + if (!plansMatch(entry, createFingerprint)) { + await cleanupAfterSandbox(sandboxId, entry, activeProvider); + throw new DaytonaReconnectTerminalError( + sandboxId, + "process-local-secret-allocation-mismatch", + ); + } + currentAllocation = entry.allocation; + try { + await activeProvider.reconnect?.(sandboxId); + } catch (cause) { + try { + await cleanupAfterSandbox(sandboxId, entry, activeProvider); + } catch (cleanupError) { + throw new AggregateError( + [cause, cleanupError], + "Daytona reconnect failed and process-local cleanup was incomplete.", + ); + } + throw cause; + } + }); + }, + async pause(sandboxId: string): Promise { + const activeProvider = providerFor({}); + const entry = registry.get(sandboxId); + if (!entry) { + await activeProvider.pause?.(sandboxId); + return; + } + if (entry.cleanupTimer) cancel(entry.cleanupTimer); + entry.cleanupTimer = undefined; + entry.generation += 1; + await serialize(entry, async () => { + if (registry.get(sandboxId) !== entry) return; + await activeProvider.pause?.(sandboxId); + const scheduledGeneration = entry.generation; + entry.cleanupTimer = schedule(() => { + void serialize(entry, async () => { + if ( + registry.get(sandboxId) !== entry || + entry.generation !== scheduledGeneration + ) { + return; + } + entry.cleanupTimer = undefined; + await cleanupAfterSandbox(sandboxId, entry, activeProvider); + }).catch((error) => { + log( + `process-local Daytona Secret cleanup failed sandbox=${sandboxId}: ${String( + error instanceof Error ? error.message : error, + ).slice(0, 200)}`, + ); + }); + }, dependencies.cleanupDelayMilliseconds); + entry.cleanupTimer.unref?.(); + }); + }, + async destroy(sandboxId: string): Promise { + const activeProvider = providerFor({}); + const entry = registry.get(sandboxId); + if (!entry) { + await destroySandboxIdempotently(activeProvider, sandboxId); + return; + } + if (entry.cleanupTimer) { + cancel(entry.cleanupTimer); + entry.cleanupTimer = undefined; + } + entry.generation += 1; + await serialize(entry, async () => { + if (registry.get(sandboxId) !== entry) return; + await cleanupAfterSandbox(sandboxId, entry, activeProvider); + }); + }, + materializeMcpServers(servers) { + if ( + !currentAllocation && + plan.candidates.some( + (candidate) => candidate.consumer.kind === "http_mcp", + ) + ) { + throw new Error( + "Daytona MCP credentials cannot be materialized without the process-local Secret allocation.", + ); + } + return withMcpPlaceholders(servers, currentAllocation); + }, + }; + + return new Proxy(facade as T & ProcessLocalDaytonaSecretProvider, { + get(target, property, receiver) { + if (Reflect.has(target, property)) { + return Reflect.get(target, property, receiver); + } + const activeProvider = providerFor({}); + const value = Reflect.get(activeProvider, property); + return typeof value === "function" ? value.bind(activeProvider) : value; + }, + }); +} + +export function materializeDaytonaMcpServers( + provider: unknown, + servers: McpServerConfig[] | undefined, +): McpServerConfig[] | undefined { + if ( + typeof provider === "object" && + provider !== null && + "materializeMcpServers" in provider && + typeof provider.materializeMcpServers === "function" + ) { + return provider.materializeMcpServers(servers); + } + return servers; +} diff --git a/services/runner/src/engines/sandbox_agent/daytona-secrets.ts b/services/runner/src/engines/sandbox_agent/daytona-secrets.ts new file mode 100644 index 0000000000..c8d9460c7b --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/daytona-secrets.ts @@ -0,0 +1,199 @@ +import { randomBytes } from "node:crypto"; + +import { DaytonaNotFoundError } from "@daytonaio/sdk"; + +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[]; +} + +/** + * True when a Daytona failure means "the resource is already gone": the SDK's typed + * not-found error, or any 404-shaped error object. The one absence predicate shared by + * Secret cleanup here and the sandbox lifecycle wrapper (`daytona-secret-provider.ts`). + */ +export function isDaytonaNotFound(error: unknown): boolean { + return ( + error instanceof DaytonaNotFoundError || + (typeof error === "object" && + error !== null && + "statusCode" in error && + error.statusCode === 404) + ); +} + +/** + * True when a Daytona failure means "this API key is not allowed to do that". + * + * Worth recognizing on its own because it has exactly one cause in practice and a completely + * different fix from every other failure here. A Daytona API key is minted with a set of + * permissions, and a key that can create sandboxes does not necessarily have the separate + * permission to manage Secrets. When it does not, every run with a hideable credential fails at + * sandbox creation, and the raw provider message says nothing about the flag that caused it. + */ +export function isDaytonaPermissionDenied(error: unknown): boolean { + if (typeof error !== "object" || error === null) return false; + const status = "statusCode" in error ? error.statusCode : undefined; + if (status === 401 || status === 403) return true; + const message = "message" in error ? String(error.message) : ""; + return /\b(403|401)\b|forbidden|not authorized|unauthorized|permission/i.test( + message, + ); +} + +/** The message an operator can act on, instead of a bare provider status code. */ +export const DAYTONA_SECRETS_PERMISSION_MESSAGE = + "Daytona refused to manage Secrets with this API key. " + + "AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local stores each model and MCP key as a " + + "Daytona Secret, which needs an API key that is allowed to manage Secrets, not only to " + + "create sandboxes. Grant that permission to the key in AGENTA_RUNNER_DAYTONA_API_KEY, or " + + "unset AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS to pass credentials as plain environment " + + "variables again."; + +async function deleteIdempotently( + api: DaytonaSecretApi, + id: string, +): Promise { + try { + await api.delete(id); + } catch (error) { + if (!isDaytonaNotFound(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); + let rawSecret: DaytonaSecretRecord; + try { + rawSecret = await api.create({ + name, + value: candidate.value, + description: "Agenta process-local sandbox credential", + hosts: [candidate.allowedHost], + }); + } catch (error) { + // Re-raise a permission refusal as an actionable message. Everything else keeps its + // original error, and either way the catch below compensates for what was created. + if (isDaytonaPermissionDenied(error)) { + throw new Error(DAYTONA_SECRETS_PERMISSION_MESSAGE, { cause: error }); + } + throw error; + } + // Track the provider record before validating returned metadata. If the provider returns a + // malformed placeholder or host list, compensation must still delete the record it made. + if (rawSecret.id) created.push(rawSecret); + const secret = assertCreatedSecret(rawSecret, name, candidate); + if (candidate.consumer.kind === "model") { + attachments[candidate.binding.name] = secret.name; + } else { + attachments[`AGENTA_MCP_SECRET_${candidate.ordinal}`] = secret.name; + (mcpHeaderPlaceholders[candidate.consumer.server] ??= {})[ + candidate.binding.name + ] = secret.placeholder; + } + } + return { attachments, mcpHeaderPlaceholders, created }; + } catch (cause) { + const cleanupFailures: unknown[] = []; + for (const secret of [...created].reverse()) { + try { + await deleteIdempotently(api, secret.id); + } catch (error) { + cleanupFailures.push(error); + } + } + if (cleanupFailures.length > 0) { + throw new AggregateError( + [cause, ...cleanupFailures], + "Daytona Secret allocation failed and compensation was incomplete.", + ); + } + throw cause; + } +} + +/** Delete one allocation in reverse creation order. Missing provider records are success. */ +export async function deleteDaytonaSecrets( + allocation: DaytonaSecretAllocation, + api: DaytonaSecretApi, +): Promise { + const failures: unknown[] = []; + for (const secret of [...allocation.created].reverse()) { + try { + await deleteIdempotently(api, secret.id); + } catch (error) { + failures.push(error); + } + } + if (failures.length > 0) { + throw new AggregateError( + failures, + "Daytona Secret cleanup was incomplete.", + ); + } +} diff --git a/services/runner/src/engines/sandbox_agent/daytona.ts b/services/runner/src/engines/sandbox_agent/daytona.ts index 1746236914..d4610b0433 100644 --- a/services/runner/src/engines/sandbox_agent/daytona.ts +++ b/services/runner/src/engines/sandbox_agent/daytona.ts @@ -11,7 +11,11 @@ import { serializePiModelsJson, type PiModelConfigPlan, } from "./pi-model-config.ts"; -import { type RunPlan } from "./run-plan.ts"; +import { + type RunPlan, + type RunPlanPrompt, + type RunPlanWorkspace, +} from "./run-plan.ts"; type Log = (message: string) => void; @@ -34,7 +38,7 @@ export const DAYTONA_PI_COMMAND = `${DAYTONA_PI_INSTALL_DIR}/node_modules/.bin/p */ export function daytonaEnvVars( piExtEnv: Record, - secrets: Record, + environment: Record, ): Record { return { PI_CODING_AGENT_DIR: DAYTONA_PI_DIR, @@ -42,8 +46,9 @@ export function daytonaEnvVars( // snapshot bakes Pi there; a custom image gets the pinned install before the session. PI_ACP_PI_COMMAND: DAYTONA_PI_COMMAND, ...piExtEnv, - // Provider API keys from the vault: the in-sandbox harness authenticates with these. - ...secrets, + // Non-secret config and explicitly local-use values. Opaque HTTP credentials attach through + // Daytona's `secrets` create field and never enter this plaintext environment map. + ...environment, }; } @@ -189,14 +194,13 @@ export async function removePiModelsConfigFromSandbox( export interface PrepareDaytonaPiAssetsInput { sandbox: any; - plan: Pick< - RunPlan, - | "isPi" - | "skillDirs" - | "hasSystemPrompt" - | "systemPrompt" - | "appendSystemPrompt" - >; + plan: Pick & { + workspace: Pick; + prompt: Pick< + RunPlanPrompt, + "hasSystemPrompt" | "systemPrompt" | "appendSystemPrompt" + >; + }; /** * A managed OpenAI-compatible custom run's Pi provider config. When set, its `models.json` is * uploaded before the ACP session starts; when absent, any stale `models.json` on a reused @@ -242,15 +246,20 @@ export async function prepareDaytonaPiAssets({ } else { await removePiModelsConfigFromSandbox(sandbox, DAYTONA_PI_DIR, log); } - if (plan.skillDirs.length > 0) { - await uploadSkillsToSandbox(sandbox, DAYTONA_PI_DIR, plan.skillDirs, log); + if (plan.workspace.skillDirs.length > 0) { + await uploadSkillsToSandbox( + sandbox, + DAYTONA_PI_DIR, + plan.workspace.skillDirs, + log, + ); } - if (plan.hasSystemPrompt) { + if (plan.prompt.hasSystemPrompt) { await uploadSystemPromptToSandbox( sandbox, DAYTONA_PI_DIR, - plan.systemPrompt, - plan.appendSystemPrompt, + plan.prompt.systemPrompt, + plan.prompt.appendSystemPrompt, log, ); } diff --git a/services/runner/src/engines/sandbox_agent/environment-setup.ts b/services/runner/src/engines/sandbox_agent/environment-setup.ts index b1b431b45f..765f545f06 100644 --- a/services/runner/src/engines/sandbox_agent/environment-setup.ts +++ b/services/runner/src/engines/sandbox_agent/environment-setup.ts @@ -18,6 +18,7 @@ import { import { buildDaemonEnv, resolveDaemonBinary } from "./daemon.ts"; import { conciseError } from "./errors.ts"; import { signSessionMountCredentials, type MountCredentials } from "./mount.ts"; +import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../extensions/model-provider-override.ts"; import { buildPiExtensionEnv, configurePiSessionWorkspace, @@ -160,19 +161,27 @@ export async function prepareEnvironmentSetup( if (!planResult.ok) return { ok: false as const, error: planResult.error }; const plan = planResult.plan; const piSkillSnapshot = resolvePiSkillSnapshot(plan); - const agentMountDir = agentMountCreds ? agentMountPath(plan.cwd) : undefined; + const agentMountDir = agentMountCreds + ? agentMountPath(plan.workspace.cwd) + : undefined; // Clear-then-apply (Security rule 5): on a managed run (credentialMode "env") the daemon - // inherits NONE of the sidecar's own provider keys, so only the resolved `plan.secrets` are - // present and an inherited key for another provider cannot leak. For runtime_provided/none/ - // un-migrated runs the harness uses its own login, so the inherited keys stay. - const clearProviderEnv = plan.credentialMode === "env"; + // inherits NONE of the sidecar's own provider keys, so only the resolved + // `plan.credentials.modelEnvironment` is present and an inherited key for another + // provider cannot leak. + // "none" asserts NO credential (connections/models.py), so it clears too — otherwise the + // daemon would inherit the declared provider's keys (e.g. OPENAI_API_KEY) from the sidecar. + // Only runtime_provided keeps the inherited keys: the harness uses its own login there. + const clearProviderEnv = + plan.credentials.credentialMode === "env" || + plan.credentials.credentialMode === "none"; const env = (deps.buildDaemonEnv ?? buildDaemonEnv)(plan.acpAgent, { clearProviderEnv, - provider: request.provider, - deployment: request.deployment, + provider: request.modelConnection?.provider, + deployment: request.modelConnection?.deployment, }); - Object.assign(env, plan.secrets); // apply only the resolved provider keys + // apply only the resolved provider keys + Object.assign(env, plan.credentials.modelEnvironment); applyClaudeConnectionEnv(env, request, plan.acpAgent, logger); const piSessionDir = configurePiSessionWorkspace(plan, env); configurePiSkillSnapshot(piSkillSnapshot, env); @@ -183,7 +192,9 @@ export async function prepareEnvironmentSetup( // local Pi's OTLP bearer rides a runner-written 0600 file, never a plain env var — // Daytona never receives telemetry env here at all (`!plan.isDaytona` gates it off above). const otlpAuthFilePath = - plan.isPi && !plan.isDaytona ? `${plan.relayDir}.otlp-auth` : undefined; + plan.isPi && !plan.isDaytona + ? `${plan.workspace.relayDir}.otlp-auth` + : undefined; const otlpAuthorization = request.telemetry?.exporters?.otlp?.headers?.authorization; if (otlpAuthFilePath && otlpAuthorization) { @@ -191,14 +202,14 @@ export async function prepareEnvironmentSetup( } const piExtEnv = plan.isPi ? buildPiExtensionEnv(request, !plan.isDaytona, { - relayDir: plan.relayDir, - usageOutPath: plan.usageOutPath, + relayDir: plan.workspace.relayDir, + usageOutPath: plan.workspace.usageOutPath, otlpAuthFilePath, - builtinGatingActive: plan.builtinGatingActive, + builtinGatingActive: plan.tools.builtinGatingActive, // The materialized skill names (author + forced `_agenta.*`) so Pi's own agent span // records which skills loaded; local Pi self-instruments, so the runner's sandbox-agent // otel has no span to stamp here. - skills: plan.skillDirs.map((s) => s.name), + skills: plan.workspace.skillDirs.map((s) => s.name), }) : {}; // Daytona's provider is built from `piExtEnv` rather than the local daemon env. Keep the @@ -214,11 +225,11 @@ export async function prepareEnvironmentSetup( configureDaytonaCodexEnv(plan, piExtEnv); Object.assign(env, piExtEnv); // local daemon inherits it; daytona gets it via envVars logger( - `tools=${plan.toolSpecs.length} executableTools=${plan.executableToolSpecs.length} ` + + `tools=${plan.tools.toolSpecs.length} executableTools=${plan.tools.executableToolSpecs.length} ` + `piPublicTools=${piExtEnv.AGENTA_AGENT_TOOLS_PUBLIC_SPECS ? "yes" : "no"}`, ); if (!plan.isPi && plan.isDaytona) { - const clientTools = plan.toolSpecs + const clientTools = plan.tools.toolSpecs .filter((spec) => spec.kind === "client") .map((spec) => spec.name); if (clientTools.length > 0) { @@ -238,7 +249,20 @@ export async function prepareEnvironmentSetup( let piModelConfigError: Error | undefined; if (plan.isPi) { try { - piModelConfig = buildPiModelConfigPlan(request, plan.secrets); + // The presence check consults the FULL materialized model environment: on a Daytona + // Secrets run the opaque key left `plan.credentials.modelEnvironment` for the + // secret plan, but the sandbox still receives its binding (as a Daytona Secret + // attachment). + const fullModelEnvironment: Record = { + ...plan.credentials.modelEnvironment, + }; + const secretCandidates = plan.credentials.daytonaSecretPlan?.candidates; + for (const candidate of secretCandidates ?? []) { + if (candidate.consumer.kind === "model") { + fullModelEnvironment[candidate.binding.name] = candidate.value; + } + } + piModelConfig = buildPiModelConfigPlan(request, fullModelEnvironment); } catch (err) { piModelConfigError = err as Error; } @@ -280,7 +304,16 @@ export async function prepareEnvironmentSetup( const localBuiltinGatingUnenforceable = plan.isPi && !plan.isDaytona && - plan.builtinGatingActive && + plan.tools.builtinGatingActive && + !localPiAssets.extensionInstalled; + // Fail closed: a Pi run whose provider routing rides the extension's model endpoint override + // (`model-provider-override.ts`, set in `buildPiExtensionEnv`) cannot run without the + // extension — the harness would silently call the provider's default endpoint. Recorded here + // and thrown inside the engine try, like the two gates above. + const localModelOverrideUnenforceable = + plan.isPi && + !plan.isDaytona && + piExtEnv[PI_MODEL_PROVIDER_OVERRIDE_ENV] !== undefined && !localPiAssets.extensionInstalled; // A local Claude subscription run reads and writes the operator's read-write mounted login @@ -291,15 +324,20 @@ export async function prepareEnvironmentSetup( // lifecycle, exactly like a normal local install (interface.md section 6). buildRunPlan already // rejected a runtime_provided Claude run with no configured CLAUDE_CONFIG_DIR. - logger(`harness=${plan.harness} sandbox=${plan.sandboxId} cwd=${plan.cwd}`); + logger( + `harness=${plan.harness} sandbox=${plan.sandboxId} cwd=${plan.workspace.cwd}`, + ); // The resolved model ref as it reaches the runner (key NAMES only, never values) — the one // line that answers "what model/provider/deployment/credential did this run actually use". logger( - `resolved model=${request.model ?? ""} provider=${request.provider ?? ""} ` + - `deployment=${request.deployment ?? ""} ` + + `resolved model=${request.model ?? ""} provider=${request.modelConnection?.provider ?? ""} ` + + `deployment=${request.modelConnection?.deployment ?? ""} ` + `connection=${request.connection ? `${request.connection.mode}:${request.connection.slug ?? "-"}` : ""} ` + - `secretKeys=[${Object.keys(request.secrets ?? {}).join(",")}]`, + `credentialMode=${request.modelConnection?.credentialMode ?? ""} ` + + `credentialBindings=[${(request.modelConnection?.credentials ?? []) + .map((credential) => credential.binding.name) + .join(",")}]`, ); // The shared client-tool relay reference (the deferred ref baked into the MCP server reads it; @@ -364,7 +402,7 @@ export async function prepareEnvironmentSetup( ? undefined : { cleanup: async () => - rmSync(plan.cwd, { recursive: true, force: true }), + rmSync(plan.workspace.cwd, { recursive: true, force: true }), }, runtimeRemount: undefined, closeToolMcp: undefined, @@ -396,6 +434,7 @@ export async function prepareEnvironmentSetup( localBuiltinGatingUnenforceable, logger, localModelConfigUnwritable, + localModelOverrideUnenforceable, mcpAbort, piExtEnv, piModelConfig, diff --git a/services/runner/src/engines/sandbox_agent/environment.ts b/services/runner/src/engines/sandbox_agent/environment.ts index b6ac90279a..954ba90029 100644 --- a/services/runner/src/engines/sandbox_agent/environment.ts +++ b/services/runner/src/engines/sandbox_agent/environment.ts @@ -57,7 +57,12 @@ import { } from "./daytona.ts"; import { applyCodexMode, resolveCodexMode } from "./codex-mode.ts"; import { conciseError } from "./errors.ts"; -import { buildSessionMcpServers } from "./mcp.ts"; +import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../extensions/model-provider-override.ts"; +import { materializeDaytonaMcpServers } from "./daytona-secret-provider.ts"; +import { + buildSessionMcpServers, + validateUserMcpServers, +} from "./mcp.ts"; import { applyModel } from "./model.ts"; import { discoverTunnelEndpoint, @@ -72,6 +77,7 @@ import { } from "./mount.ts"; import { PI_MODEL_CONFIG_WRITE_FAILED_MESSAGE, + PI_MODEL_OVERRIDE_EXTENSION_UNAVAILABLE_MESSAGE, PI_PERMISSION_EXTENSION_UNAVAILABLE_MESSAGE, prepareLocalPiAssets, uploadSystemPromptToSandbox, @@ -261,6 +267,7 @@ export async function acquireEnvironment( environment, localBuiltinGatingUnenforceable, localModelConfigUnwritable, + localModelOverrideUnenforceable, logger, mcpAbort, piExtEnv, @@ -343,7 +350,7 @@ export async function acquireEnvironment( } if (!environment.durableCwdSafeToDelete) { logger( - `durable cwd unmount not confirmed, skipping workspace cleanup cwd=${plan.cwd}`, + `durable cwd unmount not confirmed, skipping workspace cleanup cwd=${plan.workspace.cwd}`, ); } else { await environment.workspace?.cleanup().catch(() => {}); @@ -368,7 +375,7 @@ export async function acquireEnvironment( if (environment.codexSqliteHome) rmSync(environment.codexSqliteHome, { recursive: true, force: true }); // Remove the per-run skills temp root the materializer created (success or error). - plan.skillsCleanup(); + plan.workspace.skillsCleanup(); }; let agentMountGuidanceActive = false; @@ -387,17 +394,17 @@ export async function acquireEnvironment( } if (!plan.isPi) return; - plan.appendSystemPrompt = combineAppendSystemPrompt( - plan.appendSystemPrompt, + plan.prompt.appendSystemPrompt = combineAppendSystemPrompt( + plan.prompt.appendSystemPrompt, AGENT_MOUNT_SYSTEM_PROMPT_SEGMENT, ); - plan.hasSystemPrompt = true; + plan.prompt.hasSystemPrompt = true; if (plan.isDaytona) { await uploadSystemPromptToSandbox( environment.sandbox, DAYTONA_PI_DIR, - plan.systemPrompt, - plan.appendSystemPrompt, + plan.prompt.systemPrompt, + plan.prompt.appendSystemPrompt, logger, ); return; @@ -405,8 +412,8 @@ export async function acquireEnvironment( if (environment.runAgentDir) { writeSystemPromptLocal( environment.runAgentDir, - plan.systemPrompt, - plan.appendSystemPrompt, + plan.prompt.systemPrompt, + plan.prompt.appendSystemPrompt, logger, ); return; @@ -429,18 +436,18 @@ export async function acquireEnvironment( const mountLocalDurableCwd = async (reason: string): Promise => { if (!environment.mountCreds || plan.isDaytona) return false; logger( - `local durable cwd mount (${reason}) session=${sessionForMount} cwd=${plan.cwd}`, + `local durable cwd mount (${reason}) session=${sessionForMount} cwd=${plan.workspace.cwd}`, ); environment.durableCwdSafeToDelete = false; const mounted = await (deps.mountStorage ?? mountStorage)( - plan.cwd, + plan.workspace.cwd, environment.mountCreds, { log: logger, }, ); if (mounted) { - environment.mountedCwd = plan.cwd; + environment.mountedCwd = plan.workspace.cwd; environment.installedMountExpiries.cwd = mountExpiryMs( environment.mountCreds.expiresAt, ); @@ -452,7 +459,7 @@ export async function acquireEnvironment( }; const mountLocalAgentCwd = async (): Promise => { if (!environment.agentMountCreds || plan.isDaytona) return false; - const mountPath = agentMountPath(plan.cwd); + const mountPath = agentMountPath(plan.workspace.cwd); if (environment.agentMountedPath === mountPath) return true; try { mkdirSync(mountPath, { recursive: true }); @@ -473,7 +480,7 @@ export async function acquireEnvironment( environment.agentMountCreds.expiresAt, ); await seedAgentReadme(mountPath, { log: logger }); - await linkAgentFiles(plan.cwd, mountPath, { log: logger }); + await linkAgentFiles(plan.workspace.cwd, mountPath, { log: logger }); await activateAgentMountGuidance(); return true; } catch (err) { @@ -491,7 +498,7 @@ export async function acquireEnvironment( LOCAL_DURABLE_CWD_ENOTCONN_REMOUNT_LIMIT ) { logger( - `local agent mount ENOTCONN remount limit reached artifact=${artifactId} path=${agentMountPath(plan.cwd)}`, + `local agent mount ENOTCONN remount limit reached artifact=${artifactId} path=${agentMountPath(plan.workspace.cwd)}`, ); return false; } @@ -523,13 +530,13 @@ export async function acquireEnvironment( LOCAL_DURABLE_CWD_ENOTCONN_REMOUNT_LIMIT ) { logger( - `local durable cwd ENOTCONN remount limit reached session=${sessionForMount} cwd=${plan.cwd}`, + `local durable cwd ENOTCONN remount limit reached session=${sessionForMount} cwd=${plan.workspace.cwd}`, ); return false; } localDurableCwdEnotconnRemounts += 1; logger( - `local durable cwd ENOTCONN session=${sessionForMount} cwd=${plan.cwd}; re-signing and remounting`, + `local durable cwd ENOTCONN session=${sessionForMount} cwd=${plan.workspace.cwd}; re-signing and remounting`, ); const fresh = await signMount(sessionForMount, { apiBase: apiBase(), @@ -558,7 +565,7 @@ export async function acquireEnvironment( ) return; logger( - `local durable mount ENOTCONN observed in ACP event session=${sessionForMount} cwd=${plan.cwd}; re-signing and remounting`, + `local durable mount ENOTCONN observed in ACP event session=${sessionForMount} cwd=${plan.workspace.cwd}; re-signing and remounting`, ); environment.runtimeRemount = (async () => { const cwdOk = cwdEligible ? await reSignAndRemountLocalCwd() : true; @@ -590,6 +597,15 @@ export async function acquireEnvironment( if (localModelConfigUnwritable) { throw new Error(PI_MODEL_CONFIG_WRITE_FAILED_MESSAGE); } + // Fail closed: a Pi run that routes its provider through the extension's model endpoint + // override cannot run without the extension — the model would silently hit the default + // endpoint with the wrong credentials. + if (localModelOverrideUnenforceable) { + throw new Error(PI_MODEL_OVERRIDE_EXTENSION_UNAVAILABLE_MESSAGE); + } + // Structural + SSRF validation of user MCP servers BEFORE any sandbox (or Daytona Secret) is + // created, so an invalid credentialed server never triggers remote side effects. + await validateUserMcpServers(request.mcpServers); // Persist events in-process so a follow-up turn can resume by session id. const persist = deps.createPersist?.() ?? new InMemorySessionPersistDriver(); @@ -611,8 +627,9 @@ export async function acquireEnvironment( env, binaryPath, piExtEnv, - plan.secrets, + plan.credentials.modelEnvironment, plan.sandboxPermission, + plan.credentials.daytonaSecretPlan, ); const startOptions = { sandbox: sandboxProvider, @@ -691,24 +708,37 @@ export async function acquireEnvironment( deps.prepareDaytonaPiAssets ?? prepareDaytonaPiAssets )({ sandbox: environment.sandbox, - plan: { ...plan, skillDirs: [] }, + plan: { ...plan, workspace: { ...plan.workspace, skillDirs: [] } }, piModelConfig, log: logger, }); // Fail closed (Decision 2): same guarantee as the local path. A genuine upload failure on the // Daytona sandbox stops the run rather than running Pi's built-in tools unprotected. - if (plan.isPi && plan.builtinGatingActive && !daytonaExtensionInstalled) { + if ( + plan.isPi && + plan.tools.builtinGatingActive && + !daytonaExtensionInstalled + ) { throw new Error(PI_PERMISSION_EXTENSION_UNAVAILABLE_MESSAGE); } - if (!plan.isPi && plan.toolSpecs.length > 0) { + // Fail closed: the Pi model endpoint override rides the extension; without it the model + // would silently hit the default endpoint with the wrong credentials. + if ( + plan.isPi && + piExtEnv[PI_MODEL_PROVIDER_OVERRIDE_ENV] !== undefined && + !daytonaExtensionInstalled + ) { + throw new Error(PI_MODEL_OVERRIDE_EXTENSION_UNAVAILABLE_MESSAGE); + } + if (!plan.isPi && plan.tools.toolSpecs.length > 0) { // Advertise the FULL tool set to the shim, client tools included: a parked client tool // resolves through the relay's paused answer (see startToolRelay / tool-mcp-stdio.ts). internalToolMcp = await ( deps.uploadToolMcpAssets ?? uploadToolMcpAssets )( environment.sandbox, - plan.toolMcpDir, - advertisedToolSpecs(plan.toolSpecs), + plan.workspace.toolMcpDir, + advertisedToolSpecs(plan.tools.toolSpecs), logger, ); } @@ -736,7 +766,7 @@ export async function acquireEnvironment( canMount && (await (deps.mountStorageRemote ?? mountStorageRemote)( environment.sandbox, - plan.cwd, + plan.workspace.cwd, environment.mountCreds, { endpoint, @@ -807,9 +837,12 @@ export async function acquireEnvironment( await seedAgentReadmeRemote(environment.sandbox, mountPath, { log: logger, }); - await linkAgentFilesRemote(environment.sandbox, plan.cwd, mountPath, { - log: logger, - }); + await linkAgentFilesRemote( + environment.sandbox, + plan.workspace.cwd, + mountPath, + { log: logger }, + ); await activateAgentMountGuidance(); logger(`remote agent mount active for artifact=${artifactId}`); } @@ -903,7 +936,7 @@ export async function acquireEnvironment( harness: plan.harness, isPi: plan.isPi, probed, - toolSpecs: plan.toolSpecs, + toolSpecs: plan.tools.toolSpecs, log: logger, }); @@ -912,9 +945,14 @@ export async function acquireEnvironment( capabilities, harness: plan.harness, isDaytona: plan.isDaytona, - toolSpecs: plan.toolSpecs, - userMcpServers: request.mcpServers, - relayDir: plan.relayDir, + toolSpecs: plan.tools.toolSpecs, + // On a Daytona Secrets run the provider swaps each MCP credential value for its Daytona + // Secret placeholder, so no plaintext secret rides the sandbox-bound session config. + userMcpServers: materializeDaytonaMcpServers( + sandboxProvider, + request.mcpServers, + ), + relayDir: plan.workspace.relayDir, clientToolRelay: deferredClientToolRelay, executableToolGate: !plan.isPi && !plan.isDaytona ? deferredExecutableToolGate : undefined, @@ -951,7 +989,7 @@ export async function acquireEnvironment( ? { ...(claudeSystemPromptMeta ?? {}), ...(claudeThinking ?? {}) } : undefined; const sessionInit = { - cwd: plan.cwd, + cwd: plan.workspace.cwd, mcpServers: sessionMcp.servers, ...(claudeMeta ? { _meta: claudeMeta } : {}), }; @@ -1027,7 +1065,7 @@ export async function acquireEnvironment( environment.session = await environment.sandbox.createSession({ ...(localSessionId ? { id: localSessionId } : {}), agent: plan.acpAgent, - cwd: plan.cwd, + cwd: plan.workspace.cwd, sessionInit, }); } finally { @@ -1085,7 +1123,11 @@ export async function acquireEnvironment( timingLog("acquire_total", acquireStartedAt); return { ok: true, env: environment }; } catch (err) { - const error = conciseError(err, plan.harness, request.provider); + const error = conciseError( + err, + plan.harness, + request.modelConnection?.provider, + ); // Mirror today's shared teardown: no otel exists yet during acquire, so there is no partial // trace to flush — just run the incrementally-registered finalizers and surface the error. await environment.destroy({ reason: "failed-turn" }); diff --git a/services/runner/src/engines/sandbox_agent/errors.ts b/services/runner/src/engines/sandbox_agent/errors.ts index ff3f1ca160..ea1e55def4 100644 --- a/services/runner/src/engines/sandbox_agent/errors.ts +++ b/services/runner/src/engines/sandbox_agent/errors.ts @@ -17,8 +17,8 @@ const PROVIDER_KEY_LABELS: Record = { * harness name (`pi_core`/`claude`) is not the provider, so deriving the hint from it mislabels * every cross-provider run (e.g. Pi + Anthropic wrongly read "check the project's OpenAI key"). * - * `provider` is the resolved provider the runner already knows (`request.provider`, from the - * resolved connection). When it is absent (un-migrated caller) fall back to the harness default + * `provider` is the resolved provider the runner already knows (`request.modelConnection.provider`, from the + * resolved connection). When it is absent, fall back to the harness default * — Claude is always Anthropic; every other harness defaults to OpenAI, matching the old * behavior for that path only. */ diff --git a/services/runner/src/engines/sandbox_agent/mcp.ts b/services/runner/src/engines/sandbox_agent/mcp.ts index 674786e612..27273fe227 100644 --- a/services/runner/src/engines/sandbox_agent/mcp.ts +++ b/services/runner/src/engines/sandbox_agent/mcp.ts @@ -198,7 +198,58 @@ export async function validateUserMcpUrl( return undefined; } -/** Convert external HTTP MCP servers into Claude ACP session entries. */ +/** + * Structural validation of user-declared HTTP MCP servers: public headers and typed secret + * header credentials must be well-formed (non-empty names and values, `header` bindings with + * `opaque_http` usage, no duplicate header name across the public and secret sets), and the + * URL must pass the SSRF guard. Called early in acquire — BEFORE any sandbox or Daytona Secret + * is created — and again per server at ACP materialization (`toAcpMcpServers`). + */ +export async function validateUserMcpServers( + servers: McpServerConfig[] | undefined, +): Promise { + for (const server of servers ?? []) { + const url = server.connection?.url; + if (!url) throw new Error("http MCP server requires url"); + const names = new Set(); + for (const [name, value] of Object.entries( + server.connection.headers ?? {}, + )) { + if (!name.trim() || !value) { + throw new Error("HTTP MCP headers require non-empty names and values"); + } + names.add(name.toLowerCase()); + } + for (const credential of server.connection.credentials ?? []) { + const name = credential?.binding?.name; + if ( + credential?.binding?.kind !== "header" || + !name?.trim() || + !credential.value || + credential.usage !== "opaque_http" + ) { + throw new Error( + "HTTP MCP credential binding, value, or usage is invalid", + ); + } + const normalized = name.toLowerCase(); + if (names.has(normalized)) { + throw new Error(`duplicate HTTP MCP header binding '${name}'`); + } + names.add(normalized); + } + const urlError = await validateUserMcpUrl(url); + if (urlError) throw new Error(urlError); + } +} + +/** + * Convert external HTTP MCP servers into Claude ACP session entries. Public headers and typed + * secret header credentials stay separate until this final ACP materialization boundary, where + * each credential is emitted as a request header. On a Daytona run the caller has already + * swapped credential values for Daytona Secret placeholders (`materializeDaytonaMcpServers`), + * so no plaintext secret reaches the sandbox creation request there. + */ export async function toAcpMcpServers( servers: McpServerConfig[] | undefined, log: Log = () => {}, @@ -210,15 +261,21 @@ export async function toAcpMcpServers( log(`skipping HTTP MCP server '${s?.name ?? "?"}' (no URL)`); continue; } - const urlError = await validateUserMcpUrl(url); - if (urlError) throw new Error(urlError); + await validateUserMcpServers([s]); out.push({ type: "http", name: s.name, url, - headers: Object.entries(s.connection.headers ?? {}).map( - ([name, value]) => ({ name, value }), - ), + headers: [ + ...Object.entries(s.connection.headers ?? {}).map(([name, value]) => ({ + name, + value, + })), + ...(s.connection.credentials ?? []).map((credential) => ({ + name: credential.binding.name, + value: credential.value, + })), + ], }); } return out; diff --git a/services/runner/src/engines/sandbox_agent/pi-assets.ts b/services/runner/src/engines/sandbox_agent/pi-assets.ts index 07cec962f0..03d06fa9f9 100644 --- a/services/runner/src/engines/sandbox_agent/pi-assets.ts +++ b/services/runner/src/engines/sandbox_agent/pi-assets.ts @@ -16,15 +16,25 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import type { AgentRunRequest, ResolvedToolSpec } from "../../protocol.ts"; +import { + encodePiModelProviderOverride, + PI_MODEL_PROVIDER_OVERRIDE_ENV, +} from "../../extensions/model-provider-override.ts"; import { advertisedToolSpecs } from "../../tools/public-spec.ts"; import type { MaterializedSkill } from "../skills.ts"; import { PKG_ROOT } from "./daemon.ts"; import { + isPiModelConfigApplicable, PI_MODELS_JSON_FILENAME, serializePiModelsJson, type PiModelConfigPlan, } from "./pi-model-config.ts"; -import type { RunPlan } from "./run-plan.ts"; +import type { + RunPlan, + RunPlanCredentials, + RunPlanPrompt, + RunPlanWorkspace, +} from "./run-plan.ts"; type Log = (message: string) => void; @@ -40,11 +50,13 @@ export function piSessionWorkspaceDir(cwd: string): string { /** Point Pi at the durable conversation-scoped transcript directory. */ export function configurePiSessionWorkspace( - plan: Pick, + plan: Pick & { + workspace: Pick; + }, env: Record, ): string | undefined { if (!plan.isPi) return undefined; - const sessionDir = piSessionWorkspaceDir(plan.cwd); + const sessionDir = piSessionWorkspaceDir(plan.workspace.cwd); env.PI_CODING_AGENT_SESSION_DIR = sessionDir; return sessionDir; } @@ -100,11 +112,13 @@ function hashPart( /** Resolve the immutable project-local snapshot selected for this Pi run. */ export function resolvePiSkillSnapshot( - plan: Pick, + plan: Pick & { + workspace: Pick; + }, ): PiSkillSnapshot | undefined { - if (!plan.isPi || plan.skillDirs.length === 0) return undefined; + if (!plan.isPi || plan.workspace.skillDirs.length === 0) return undefined; - const skills = [...plan.skillDirs].sort((a, b) => + const skills = [...plan.workspace.skillDirs].sort((a, b) => a.name.localeCompare(b.name), ); const hash = createHash("sha256"); @@ -125,7 +139,7 @@ export function resolvePiSkillSnapshot( })}\n`; return { digest, - dir: join(plan.cwd, "agents", "skills", digest), + dir: join(plan.workspace.cwd, "agents", "skills", digest), marker, skills, }; @@ -286,6 +300,18 @@ export const PI_MODEL_CONFIG_WRITE_FAILED_MESSAGE = "custom provider could not be registered. The run was stopped rather than fall back to a " + "default provider. Ask your deployment operator to make the runner's Pi agent directory writable."; +/** + * Thrown (via the engine's named-message pattern) when the run routes its model provider through + * the extension's endpoint override (`model-provider-override.ts`) but the Agenta extension could + * not be installed. Fail closed: without the extension the harness would silently call the + * provider's DEFAULT endpoint with credentials resolved for the custom one. Single line so + * `conciseError` surfaces it verbatim. + */ +export const PI_MODEL_OVERRIDE_EXTENSION_UNAVAILABLE_MESSAGE = + "The agent could not apply its custom model endpoint: the Agenta extension failed to install, " + + "so the provider override could not be registered. The run was stopped rather than call the " + + "default endpoint. Ask your deployment operator to rebuild and republish the runner image."; + /** * Write the Pi `models.json` into a local (throwaway) agent dir with mode `0600` via an atomic * temp-file-plus-rename. THROWS on failure so the caller can make materialization terminal — a @@ -349,6 +375,18 @@ export function buildPiExtensionEnv( if (telemetry && opts.skills && opts.skills.length > 0) env.AGENTA_AGENT_SKILLS_LOADED = JSON.stringify(opts.skills); + // Point Pi's built-in provider at the resolved custom base URL via the Agenta extension + // (`model-provider-override.ts`). Skipped when the managed OpenAI-compatible custom path + // already routes this run through its own `models.json` provider (`pi-model-config.ts`) — + // two competing registrations for the same run would race for the provider. + const modelBaseUrl = request.modelConnection?.endpoint?.baseUrl; + if (modelBaseUrl !== undefined && !isPiModelConfigApplicable(request)) { + env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = encodePiModelProviderOverride({ + provider: request.modelConnection?.provider, + baseUrl: modelBaseUrl, + }); + } + const specs = advertisedToolSpecs( (request.customTools as ResolvedToolSpec[]) ?? [], ); @@ -542,17 +580,14 @@ export function prepareLocalAgentDir( } export interface PrepareLocalPiAssetsInput { - plan: Pick< - RunPlan, - | "isPi" - | "isDaytona" - | "credentialMode" - | "skillDirs" - | "hasSystemPrompt" - | "systemPrompt" - | "appendSystemPrompt" - | "sourcePiAgentDir" - >; + plan: Pick & { + credentials: Pick; + workspace: Pick; + prompt: Pick< + RunPlanPrompt, + "hasSystemPrompt" | "systemPrompt" | "appendSystemPrompt" + >; + }; env: Record; /** * A managed OpenAI-compatible custom run's Pi provider config. When set, the isolated per-run @@ -621,14 +656,14 @@ export function prepareLocalPiAssets({ // buildRunPlan already rejected a local runtime_provided run with no configured // PI_CODING_AGENT_DIR, so `sourcePiAgentDir` here IS the operator's mount. A model-config plan // never reaches here (it requires credentialMode "env"), so there is no models.json to write. - if (plan.credentialMode === "runtime_provided") { - const agentDir = plan.sourcePiAgentDir; + if (plan.credentials.credentialMode === "runtime_provided") { + const agentDir = plan.workspace.sourcePiAgentDir; const extensionInstalled = installPiExtensionLocal(agentDir, log); - if (plan.hasSystemPrompt) { + if (plan.prompt.hasSystemPrompt) { writeSystemPromptLocal( agentDir, - plan.systemPrompt, - plan.appendSystemPrompt, + plan.prompt.systemPrompt, + plan.prompt.appendSystemPrompt, log, ); } @@ -642,15 +677,15 @@ export function prepareLocalPiAssets({ // managed OpenAI-compatible custom run (a model-config plan is present) additionally gets a // models.json and does NOT receive the operator's personal auth.json. const { dir: runAgentDir, extensionInstalled } = prepareLocalAgentDir( - plan.sourcePiAgentDir, + plan.workspace.sourcePiAgentDir, log, { seedCredentials: !piModelConfig }, ); - if (plan.hasSystemPrompt) { + if (plan.prompt.hasSystemPrompt) { writeSystemPromptLocal( runAgentDir, - plan.systemPrompt, - plan.appendSystemPrompt, + plan.prompt.systemPrompt, + plan.prompt.appendSystemPrompt, log, ); } diff --git a/services/runner/src/engines/sandbox_agent/pi-model-config.ts b/services/runner/src/engines/sandbox_agent/pi-model-config.ts index 61f42b58b8..bc9781a4c4 100644 --- a/services/runner/src/engines/sandbox_agent/pi-model-config.ts +++ b/services/runner/src/engines/sandbox_agent/pi-model-config.ts @@ -63,21 +63,39 @@ function isPiHarness(harness: string | undefined): boolean { } /** - * Build the Pi model-config plan from the neutral run request and the resolved secrets, or return - * `undefined` when the request is not a managed OpenAI-compatible custom Pi run (current behavior). - * - * Applicability (which KIND of run this is) — ALL must hold, else no plan: + * Applicability (which KIND of run this is) — ALL must hold, else this builder does not apply: * - the harness is Pi; - * - the provider family is "openai"; - * - the deployment is "custom"; + * - the resolved provider family is "openai"; + * - the resolved deployment is "custom"; * - the connection is a named Agenta connection (`mode === "agenta"`). * + * Exported so `buildPiExtensionEnv` can skip the generic Pi provider-override env for runs this + * models.json path already routes (two competing registrations would race for the provider). + */ +export function isPiModelConfigApplicable(request: AgentRunRequest): boolean { + return ( + isPiHarness(request.harness) && + request.modelConnection?.provider === "openai" && + request.modelConnection?.deployment === "custom" && + request.connection?.mode === "agenta" + ); +} + +/** + * Build the Pi model-config plan from the neutral run request and the materialized model + * environment, or return `undefined` when the request is not a managed OpenAI-compatible custom + * Pi run (current behavior). + * + * Applicability is `isPiModelConfigApplicable` above. + * * Completeness (the applicable run has everything it needs) — once applicable, ALL must hold or * the request is INCOMPLETE and throws `PiModelConfigError`: * - a non-empty connection slug; * - an endpoint base URL; * - credential mode "env"; - * - `OPENAI_API_KEY` present in the resolved secrets; + * - `OPENAI_API_KEY` present in the materialized model environment (`secrets` — on a Daytona + * Secrets run this includes the opaque credential BINDINGS, whose in-sandbox value is the + * Daytona placeholder); * - a model id. * * The plan holds only the env var NAME; the raw key never enters it. @@ -86,25 +104,19 @@ export function buildPiModelConfigPlan( request: AgentRunRequest, secrets: Record, ): PiModelConfigPlan | undefined { - const applicable = - isPiHarness(request.harness) && - request.provider === "openai" && - request.deployment === "custom" && - request.connection?.mode === "agenta"; - if (!applicable) return undefined; + if (!isPiModelConfigApplicable(request)) return undefined; + const credentialMode = request.modelConnection?.credentialMode; const slug = request.connection?.slug?.trim(); - const baseUrl = request.endpoint?.baseUrl?.trim(); + const baseUrl = request.modelConnection?.endpoint?.baseUrl?.trim(); const model = request.model?.trim(); const hasKey = !!secrets[OPENAI_API_KEY_ENV]?.trim(); const missing: string[] = []; if (!slug) missing.push("a connection slug"); if (!baseUrl) missing.push("an endpoint base URL"); - if (request.credentialMode !== "env") - missing.push( - `credential mode "env" (got "${request.credentialMode ?? "none"}")`, - ); + if (credentialMode !== "env") + missing.push(`credential mode "env" (got "${credentialMode ?? "none"}")`); if (!hasKey) missing.push(`${OPENAI_API_KEY_ENV} in the resolved secrets`); if (!model) missing.push("a model id"); @@ -116,13 +128,24 @@ export function buildPiModelConfigPlan( ); } + // The wire `model` may already be provider-qualified ("openai/gpt-x"). The catalog registers + // the model UNDER the slug provider, and `environment.ts` requests the fully qualified + // `/` — so strip the exact declared provider prefix here, otherwise the + // requested id becomes the double-prefixed "/openai/gpt-x" and never matches. + const declaredProvider = request.modelConnection?.provider; + const stripped = + declaredProvider && (model as string).startsWith(`${declaredProvider}/`) + ? (model as string).slice(declaredProvider.length + 1) + : (model as string); + const modelId = stripped || (model as string); + return { providerId: slug as string, providerFamily: "openai", api: "openai-completions", baseUrl: baseUrl as string, apiKeyEnv: OPENAI_API_KEY_ENV, - models: [{ id: model as string }], + models: [{ id: modelId }], }; } diff --git a/services/runner/src/engines/sandbox_agent/provider.ts b/services/runner/src/engines/sandbox_agent/provider.ts index 10f46b4c7d..6672052d1c 100644 --- a/services/runner/src/engines/sandbox_agent/provider.ts +++ b/services/runner/src/engines/sandbox_agent/provider.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { local } from "sandbox-agent/local"; import type { SandboxPermission } from "../../protocol.ts"; @@ -15,6 +17,11 @@ import { buildDaytonaClient, daytonaWithLifecycle, } from "./daytona-provider.ts"; +import { daytonaWithProcessLocalSecrets } from "./daytona-secret-provider.ts"; +import { + assertDaytonaOpaqueSecretsEnabled, + type DaytonaSecretPlan, +} from "./daytona-secret-plan.ts"; /** * Translate the Layer 2 network policy into Daytona create fields. Daytona enforces egress @@ -57,8 +64,9 @@ export function daytonaNetworkFields( export function buildDaytonaCreate( daytona: RunnerDaytonaConfig, piExtEnv: Record, - secrets: Record, + environment: Record, sandboxPermission: SandboxPermission | undefined, + secretAttachments: Record = {}, ): Record { const snapshot = daytona.image ? undefined @@ -71,7 +79,10 @@ export function buildDaytonaCreate( ...(snapshot ? { snapshot, image: undefined } : {}), ...(target ? { target } : {}), ...daytonaNetworkFields(sandboxPermission), - envVars: daytonaEnvVars(piExtEnv, secrets), + envVars: daytonaEnvVars(piExtEnv, environment), + ...(Object.keys(secretAttachments).length > 0 + ? { secrets: secretAttachments } + : {}), // `ephemeral: false` lets stop park the sandbox. Leave autoArchiveInterval unset so Daytona's // seven-day default sits beyond our 30-minute delete. The ladder is stop, then delete. // These intervals override the wrapper's hardcoded zeroes. A leaked sandbox self-reaps. @@ -81,6 +92,28 @@ export function buildDaytonaCreate( }; } +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + return `{${entries + .map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`) + .join(",")}}`; +} + +/** Opaque comparison key for every field baked into a parked Daytona sandbox at create time. */ +export function daytonaCreateFingerprint(input: { + image?: string; + create: Record; + secretPlan: DaytonaSecretPlan; +}): string { + return createHash("sha256").update(canonicalJson(input)).digest("hex"); +} + /** Recognized ids that are planned but not yet provisionable (fail with a specific message). */ export const PLANNED_SANDBOX_IDS = ["e2b"] as const; @@ -99,8 +132,9 @@ export function buildSandboxProvider( env: Record, binaryPath: string | undefined, piExtEnv: Record, - secrets: Record, + modelEnvironment: Record, sandboxPermission?: SandboxPermission, + daytonaSecretPlan?: DaytonaSecretPlan, config: RunnerConfig = loadRunnerConfig(), ) { if ( @@ -118,18 +152,63 @@ export function buildSandboxProvider( // `new Daytona()` reads during creation; hand the lifecycle wrapper an explicit client. applyDaytonaSdkEnv(config.daytona); const image = config.daytona.image; - return daytonaWithLifecycle( - { - ...(image ? { image } : {}), - create: buildDaytonaCreate( - config.daytona, - piExtEnv, - secrets, - sandboxPermission, - ) as any, - }, - { client: buildDaytonaClient(config.daytona) }, + const createFields = buildDaytonaCreate( + config.daytona, + piExtEnv, + modelEnvironment, + sandboxPermission, ); + const buildDaytona = (secretAttachments: Record) => + daytonaWithLifecycle( + { + ...(image ? { image } : {}), + create: { + ...createFields, + ...(Object.keys(secretAttachments).length > 0 + ? { secrets: secretAttachments } + : {}), + } as any, + }, + { client: buildDaytonaClient(config.daytona) }, + ); + // The process-local Secret wrapper applies to EVERY plan-bearing Daytona run + // (`buildRunPlan` builds a plan only when AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local is + // enabled), INCLUDING a zero-candidate plan: the wrapper then allocates no Secrets and + // attaches nothing, but its create-fingerprint check still governs reconnects, so a parked + // sandbox holding plaintext local_use credentials (AWS/GCP) is rebuilt — never reconnected + // with stale values — after those credentials rotate. Every flag-off run carries no plan and + // takes the plain plaintext-env provider below, unchanged from the pre-feature behavior. + // The assert is defense-in-depth against a direct caller handing a candidate-bearing plan + // while the flag is off: that plan's environment already dropped the opaque values, so + // proceeding unwrapped would silently run without credentials. + // + // Accepted design limit: Secret ownership is PROCESS-LOCAL. The wrapper's registry dies + // with the runner process, so a hard crash can orphan a Daytona Secret until Daytona's own + // auto-delete backstop fires. Durable reconciliation is an explicit follow-up (PR B of the + // Daytona-Secrets design; see PR #5278) — do not add recovery machinery here. + if (daytonaSecretPlan) { + assertDaytonaOpaqueSecretsEnabled(daytonaSecretPlan); + const client = buildDaytonaClient(config.daytona); + const createFingerprint = daytonaCreateFingerprint({ + image, + create: createFields, + secretPlan: daytonaSecretPlan, + }); + return daytonaWithProcessLocalSecrets( + buildDaytona, + daytonaSecretPlan, + client.secret, + { + createFingerprint, + // Run slightly after Daytona's own auto-delete backstop. The timer first issues an + // idempotent sandbox delete, then removes Secrets, preserving the hard deletion order. + cleanupDelayMilliseconds: + config.daytona.autodeleteMinutes * 60_000 + 5_000, + log: (message) => process.stderr.write(`[daytona] ${message}\n`), + }, + ); + } + return buildDaytona({}); } if ((PLANNED_SANDBOX_IDS as readonly string[]).includes(sandboxId)) { diff --git a/services/runner/src/engines/sandbox_agent/run-plan.ts b/services/runner/src/engines/sandbox_agent/run-plan.ts index fe51f0a122..a27f9848f4 100644 --- a/services/runner/src/engines/sandbox_agent/run-plan.ts +++ b/services/runner/src/engines/sandbox_agent/run-plan.ts @@ -36,6 +36,11 @@ import { loadRunnerConfig, type SandboxProviderId, } from "../../config/runner-config.ts"; +import { + buildDaytonaSecretPlan, + daytonaOpaqueSecretsEnabled, + type DaytonaSecretPlan, +} from "./daytona-secret-plan.ts"; type Log = (message: string) => void; @@ -93,31 +98,43 @@ export const LOCAL_SUBSCRIPTION_MOUNT_MISSING_MESSAGE = "runtime_provided local run requires a mounted subscription: set PI_CODING_AGENT_DIR " + "(Pi), CLAUDE_CONFIG_DIR (Claude), or CODEX_HOME (Codex) to a read-write mount of your harness login."; -export interface RunPlan { - harness: string; - acpAgent: string; - sandboxId: string; - isPi: boolean; - isDaytona: boolean; - prompt: string; - turnText: string; - agentsMd?: string; - secrets: Record; +/** + * How the run authenticates with the model provider. Everything here describes the credential + * itself and how it is delivered, not where the run executes or what it asks the model to do. + */ +export interface RunPlanCredentials { + /** Final plaintext model environment, after validating modelConnection. */ + modelEnvironment: Record; + /** + * Process-local opaque credential plan. Present for every Daytona run when + * AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local is enabled — even with zero candidates, so the + * Secret provider wrapper (and its create-fingerprint rotation check) governs every flag-on + * reconnect. Absent when the flag is off, so that path is the plain plaintext-env provider + * with no wrapper, unchanged from the pre-feature runner. + */ + daytonaSecretPlan?: DaytonaSecretPlan; /** * The provider api-key env var name the harness would read by default (`ANTHROPIC_API_KEY` for * Claude, `OPENAI_API_KEY` otherwise). It does not choose the provider; it only names the key - * whose presence sets `hasApiKey`. + * whose presence — in the materialized model environment — sets `hasApiKey`. */ - legacyHarnessApiKeyVar: string; - /** Whether the resolved `secrets` already carry `legacyHarnessApiKeyVar`. */ + harnessApiKeyVar: string; + /** Whether the materialized model environment already carries `harnessApiKeyVar`. */ hasApiKey: boolean; /** * How the credential is delivered: "env" (managed, resolved key) | "runtime_provided" (the * harness owns its login) | "none". From the resolved connection (provider-model-auth design, - * Concern 3). `undefined` when an un-migrated caller sends no credentialMode. Drives - * clear-then-apply env (Security rule 5). + * Concern 3). `undefined` only when a direct runner request has no resolved modelConnection; + * that request may still use the harness login. Drives clear-then-apply env (Security rule 5). */ credentialMode?: string; +} + +/** + * Where the run's files live and what gets materialized into them. These are the directories the + * engine creates, mounts, writes into, and cleans up. + */ +export interface RunPlanWorkspace { cwd: string; relayDir: string; /** @@ -130,6 +147,24 @@ export interface RunPlan { */ toolMcpDir: string; usageOutPath?: string; + skillDirs: MaterializedSkill[]; + /** Removes the per-run skills temp root. The engine runs it in its `finally` so it never leaks. */ + skillsCleanup: () => void; + sourcePiAgentDir: string; + /** + * Generic harness-rendered files to materialize in the cwd before the session starts. Each + * `{ path (relative to cwd), content }` was produced by the Python harness adapter (e.g. the + * claude adapter renders `.claude/settings.json` from its permissions slice). `prepareWorkspace` + * writes each entry blind — no harness knowledge on the runner. + */ + harnessFiles?: Array<{ path: string; content: string }>; +} + +/** + * The tools this run offers the model and how they are delivered. It covers both the resolved + * specs and the delivery switches the engine reads when it wires the relay and the gates. + */ +export interface RunPlanTools { toolSpecs: ResolvedToolSpec[]; executableToolSpecs: ResolvedToolSpec[]; /** True when the permission policy needs the extension to intercept Pi builtin calls. */ @@ -142,13 +177,31 @@ export interface RunPlan { * "pi-native", the non-Pi shim (Claude on Daytona) → "cold-acknowledge". */ clientToolPauseDisposition: ClientToolPauseDisposition; +} + +/** + * Everything the model is told for this turn. It holds the user text alongside the rendered + * transcript and the system instructions layered on top of it. + */ +export interface RunPlanPrompt { + text: string; + turnText: string; + agentsMd?: string; systemPrompt?: string; appendSystemPrompt?: string; hasSystemPrompt: boolean; - skillDirs: MaterializedSkill[]; - /** Removes the per-run skills temp root. The engine runs it in its `finally` so it never leaks. */ - skillsCleanup: () => void; - sourcePiAgentDir: string; +} + +export interface RunPlan { + harness: string; + acpAgent: string; + sandboxId: string; + isPi: boolean; + isDaytona: boolean; + credentials: RunPlanCredentials; + workspace: RunPlanWorkspace; + tools: RunPlanTools; + prompt: RunPlanPrompt; /** * The declared sandbox security boundary (Layer 2). `buildSandboxProvider` enforces the * network policy on Daytona (S1b); `buildRunPlan` rejects restricted-network runs the @@ -156,19 +209,46 @@ export interface RunPlan { * MCP) when `enforcement === "strict"`. */ sandboxPermission?: SandboxPermission; - /** - * Generic harness-rendered files to materialize in the cwd before the session starts. Each - * `{ path (relative to cwd), content }` was produced by the Python harness adapter (e.g. the - * claude adapter renders `.claude/settings.json` from its permissions slice). `prepareWorkspace` - * writes each entry blind — no harness knowledge on the runner. - */ - harnessFiles?: Array<{ path: string; content: string }>; } export type BuildRunPlanResult = | { ok: true; plan: RunPlan } | { ok: false; error: string }; +// The five wire fields this change RETIRED. They are listed here so the runner can reject a +// request that still sends them, rather than ignore them. +// +// Why reject instead of ignore: an old caller sending `secrets: {OPENAI_API_KEY: "..."}` would +// otherwise get a run that starts fine and has no key, and the failure would surface much later +// as a confusing provider auth error. Rejecting turns a silent wrong-credential run into an +// immediate, obvious contract error. Nothing in the tree sends these; the SDK and the runner +// ship together, so this is a guard against a stale caller, not a compatibility shim, and it is +// not tied to the Daytona feature flag. +// +// `connection` is deliberately NOT in this list. It looks like it belongs (it was next to these +// fields on the old wire) but it is not a credential: it is the author's choice of which Agenta +// connection to use, as `{mode, slug}`. The runner still needs it, because a named +// OpenAI-compatible run on the Pi harness is registered in Pi's own `models.json` under a +// provider named after that slug (`pi-model-config.ts`). Dropping it makes those runs silently +// fall back to the generic provider-override path. +const LEGACY_MODEL_CREDENTIAL_FIELDS = [ + "secrets", + "provider", + "deployment", + "credentialMode", + "endpoint", +] as const; + +// Always scans the raw request, `modelConnection` present or not: a caller sending BOTH the +// typed shape and a retired flat field is confused about the contract, and silently ignoring +// the legacy half could mask a credential it expected to apply. +function legacyModelCredentialFields(request: AgentRunRequest): string[] { + const raw = request as unknown as Record; + return LEGACY_MODEL_CREDENTIAL_FIELDS.filter( + (field) => Object.hasOwn(raw, field) && raw[field] !== undefined, + ); +} + export interface BuildRunPlanDeps { sandboxProvider?: string; /** Providers this deployment enables; a request for anything outside this set is rejected. */ @@ -228,6 +308,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, { @@ -261,6 +434,18 @@ export function buildRunPlan( }; } + // Model routing and credentials arrive grouped under `modelConnection`; the retired flat + // fields are rejected loudly so a stale caller cannot silently run without credentials. + const legacyFields = legacyModelCredentialFields(request); + if (legacyFields.length > 0) { + return { + ok: false, + error: + `Legacy top-level model credential fields are not supported (${legacyFields.join(", ")}); ` + + "send the resolved modelConnection object.", + }; + } + // The harness identity maps to a real ACP agent the daemon knows (`pi` / `claude`). // `pi_core` (plain Pi) and `pi_agenta` (Pi with Agenta's forced skills/prompt/policy) both // run on the `pi` ACP agent; `claude` runs on the `claude` ACP agent. `harness` remains the @@ -311,7 +496,8 @@ export function buildRunPlan( // its login on a read-write mount that lives in the runner container and is never shipped to a // third-party sandbox. Reject Daytona + runtime_provided here, before any sandbox is created, // rather than silently falling back to an unauthenticated remote run (interface.md sections 5-6). - if (isDaytona && request.credentialMode === "runtime_provided") { + const requestCredentialMode = request.modelConnection?.credentialMode; + if (isDaytona && requestCredentialMode === "runtime_provided") { return { ok: false, error: DAYTONA_SUBSCRIPTION_UNSUPPORTED_MESSAGE }; } @@ -319,7 +505,7 @@ export function buildRunPlan( // reads its login from the CODEX_HOME mount too. If the harness config var is unset there is no // mount to read, so fail up front rather than discovering the runner's own home (interface.md // section 6). Managed ("env") / "none" runs are unaffected. - if (!isDaytona && request.credentialMode === "runtime_provided") { + if (!isDaytona && requestCredentialMode === "runtime_provided") { const subscriptionEnvVar = acpAgent === "claude" ? "CLAUDE_CONFIG_DIR" @@ -331,8 +517,39 @@ export function buildRunPlan( } } - const secrets = request.secrets ?? {}; - const legacyHarnessApiKeyVar = + const materializedModel = materializeModelEnvironment(request); + if (!materializedModel.ok) return materializedModel; + // Daytona opaque-credential delivery is FLAG-GATED (AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS= + // process_local). Flag OFF: no secret plan is built at all, so behavior is identical to the + // pre-feature runner — the full materialized environment reaches sandbox create as plaintext + // env, no provider wrapper is applied, and the plan's strict endpoint/binding validation + // cannot introduce a new failure mode. Flag ON: the plan splits every opaque_http value out + // of the plaintext env and is ALWAYS kept, even with zero candidates, so the provider wrapper + // (and its create fingerprint) governs every flag-on Daytona reconnect. A zero-candidate plan + // allocates no Secrets, but a parked sandbox created with plaintext local_use credentials must + // be rebuilt — never reconnected — after those credentials rotate, and only the wrapper's + // fingerprint check enforces that (the plain reconnect path converges network policy only). + let daytonaSecretPlan: DaytonaSecretPlan | undefined; + if (isDaytona && daytonaOpaqueSecretsEnabled()) { + try { + daytonaSecretPlan = buildDaytonaSecretPlan({ + modelConnection: request.modelConnection, + mcpServers: request.mcpServers, + }); + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + // Local keeps its existing direct environment. A flag-on Daytona run with opaque credentials + // removes every opaque_http value and passes only non-secret config plus explicitly local_use + // credentials to sandbox create. With zero candidates the plan's environment equals the + // materialized one, so keeping the empty plan changes nothing here. + const modelEnvironment = + daytonaSecretPlan?.environment ?? materializedModel.environment; + const harnessApiKeyVar = acpAgent === "claude" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"; const toolSpecs = (request.customTools as ResolvedToolSpec[]) ?? []; const executableToolSpecsForRun = executableToolSpecs(toolSpecs); @@ -485,39 +702,51 @@ export function buildRunPlan( sandboxId, isPi, isDaytona, - prompt, - turnText: buildTurnText(request, log), - agentsMd: request.agentsMd?.trim() || undefined, - secrets, - legacyHarnessApiKeyVar, - hasApiKey: !!secrets[legacyHarnessApiKeyVar], - credentialMode: request.credentialMode, - cwd, - relayDir, - toolMcpDir, - // Usage capture is ephemeral runner output, not durable session data — keep it off the - // geesefs mount alongside the relay dir (a mount write would risk ENOTCONN). - usageOutPath: isPi ? join(relayDir, ".agenta-usage.json") : undefined, - toolSpecs, - executableToolSpecs: executableToolSpecsForRun, - builtinGatingActive, - // The relay carries tool EXECUTION only (permission gates ride the extension's - // `ctx.ui.confirm` dialog onto the ACP plane), so a builtin-gating-only run needs no relay. - useToolRelay: toolSpecs.length > 0, - // Pi parks through its own extension (no answer file); the non-Pi shim blocks on an answer - // file, so a parked client tool is acknowledged with a benign paused answer. - clientToolPauseDisposition: isPi ? "pi-native" : "cold-acknowledge", - systemPrompt, - appendSystemPrompt, - hasSystemPrompt: !!(systemPrompt || appendSystemPrompt), - skillDirs, - skillsCleanup, - sourcePiAgentDir: - process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), + credentials: { + modelEnvironment, + daytonaSecretPlan, + harnessApiKeyVar, + // Consult the FULL materialized environment: on a Daytona Secrets run the opaque key is + // delivered as a Secret attachment rather than plaintext env, but the harness still has it. + hasApiKey: !!materializedModel.environment[harnessApiKeyVar], + credentialMode: materializedModel.credentialMode, + }, + workspace: { + cwd, + relayDir, + toolMcpDir, + // Usage capture is ephemeral runner output, not durable session data — keep it off the + // geesefs mount alongside the relay dir (a mount write would risk ENOTCONN). + usageOutPath: isPi ? join(relayDir, ".agenta-usage.json") : undefined, + skillDirs, + skillsCleanup, + sourcePiAgentDir: + process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), + // Generic: the Python harness adapter already rendered any harness config files; the + // runner just carries them onto the plan and writes them into the cwd in + // `prepareWorkspace`. + harnessFiles: request.harnessFiles, + }, + tools: { + toolSpecs, + executableToolSpecs: executableToolSpecsForRun, + builtinGatingActive, + // The relay carries tool EXECUTION only (permission gates ride the extension's + // `ctx.ui.confirm` dialog onto the ACP plane), so a builtin-gating-only run needs no relay. + useToolRelay: toolSpecs.length > 0, + // Pi parks through its own extension (no answer file); the non-Pi shim blocks on an answer + // file, so a parked client tool is acknowledged with a benign paused answer. + clientToolPauseDisposition: isPi ? "pi-native" : "cold-acknowledge", + }, + prompt: { + text: prompt, + turnText: buildTurnText(request, log), + agentsMd: request.agentsMd?.trim() || undefined, + systemPrompt, + appendSystemPrompt, + hasSystemPrompt: !!(systemPrompt || appendSystemPrompt), + }, sandboxPermission: request.sandboxPermission, - // Generic: the Python harness adapter already rendered any harness config files; the runner - // just carries them onto the plan and writes them into the cwd in `prepareWorkspace`. - harnessFiles: request.harnessFiles, }, }; } diff --git a/services/runner/src/engines/sandbox_agent/run-turn.ts b/services/runner/src/engines/sandbox_agent/run-turn.ts index e208769334..893d46a9fe 100644 --- a/services/runner/src/engines/sandbox_agent/run-turn.ts +++ b/services/runner/src/engines/sandbox_agent/run-turn.ts @@ -86,7 +86,8 @@ import { resolveRunUsage } from "./usage.ts"; * controller / decisions / responder into `env.currentTurn`, restart the tool relay, * send the prompt, resolve usage, and finish + flush the trace. It does NOT tear down the * environment (the caller owns `env.destroy`). On a continuation the prompt is only the new user - * text (`buildTurnText` does not run); on a cold turn it is `plan.turnText`, exactly as before. + * text (`buildTurnText` does not run); on a cold turn it is `plan.prompt.turnText`, + * exactly as before. */ export async function runTurn( env: SessionEnvironment, @@ -226,37 +227,37 @@ export async function runTurn( ? resolvePromptText(request) : currentUserTurn(request).text; // Cold: replay the full transcript. Continuation or loaded: send only new text. When history - // was rebuilt from records, recompute the transcript from it — the prebuilt plan.turnText - // predates the reconstruction. An approval reply has no new text either way, so it sends the - // approval-resume frame `buildTurnText` renders (the harness already holds the prior turns - // when the session was loaded natively; otherwise the rebuilt transcript comes with it). + // was rebuilt from records, recompute the transcript from it — the prebuilt + // plan.prompt.turnText predates the reconstruction. An approval reply has no new text + // either way, so it sends the approval-resume frame `buildTurnText` renders (the + // harness already holds the prior turns when the session was loaded natively; + // otherwise the rebuilt transcript comes with it). const turnText = sendLastMessageOnly(opts) ? approvalReplyOnly ? buildTurnText(inboundRequest, logger) : promptText : reconstructed || historicalAttachmentsPresent ? buildTurnText(request, logger) - : plan.turnText; + : plan.prompt.turnText; const run = (deps.createOtel ?? createSandboxAgentOtel)({ harness: plan.harness, model: env.model, - skills: plan.skillDirs.map((s) => s.name), + skills: plan.workspace.skillDirs.map((s) => s.name), traceparent: request.context?.propagation?.traceparent, baggage: request.context?.propagation?.baggage, endpoint: request.telemetry?.exporters?.otlp?.endpoint, authorization: request.telemetry?.exporters?.otlp?.headers?.authorization, captureContent: request.telemetry?.capture?.content?.enabled, - // Seed from the keys actually APPLIED to this run (`plan.secrets`) plus the mount's STS - // pair — neither lives in the sidecar's process env. - redactor: seedForRun( - { secrets: plan.secrets, telemetry: request.telemetry }, - [ - env.mountCreds?.accessKey, - env.mountCreds?.secretKey, - env.mountCreds?.sessionToken, - ], - ), + // Seed from the request's typed model/MCP credential material (`requestSecretValues` — + // on a Daytona Secrets run the opaque values left the plaintext env for the secret plan + // but still transit runner memory) plus the mount's STS pair — none of which lives in the + // sidecar's process env. + redactor: seedForRun(request, [ + env.mountCreds?.accessKey, + env.mountCreds?.secretKey, + env.mountCreds?.sessionToken, + ]), emitSpans: !plan.isPi || plan.isDaytona, // Every emitted event is a progress signal for the idle/TTFB deadlines (message/thought // deltas, tool calls and results, usage, ...) — the one seam every harness's output flows @@ -288,7 +289,7 @@ export async function runTurn( plan, capabilities: env.capabilities, modelCapabilities: request.modelCapabilities, - provider: request.provider, + provider: request.modelConnection?.provider, emit: (event) => run.emitEvent(event), }) : []; @@ -297,7 +298,7 @@ export async function runTurn( for (const image of legacyImages) { const gate = attachmentCapabilityGate({ acpAgent: plan.acpAgent, - provider: request.provider, + provider: request.modelConnection?.provider, capabilities: env.capabilities, // Legacy inline images predate the catalog, so a caller that declares nothing keeps // the historical image-capable assumption; a caller that declares modalities is @@ -641,7 +642,7 @@ export async function runTurn( const serverPermissions = serverPermissionsFromRequest(request); // The SAME name->spec index the relay execute loop hands to the relay execution guard, so // the approval card and the guard cannot disagree about a tool's permission/readOnly. - const specsByName = toolSpecsByName(plan.toolSpecs); + const specsByName = toolSpecsByName(plan.tools.toolSpecs); const settleBufferedPausedCompletions = (): void => { for (const [toolCallId, update] of [ ...bufferedPausedCompletedFrames.entries(), @@ -734,7 +735,7 @@ export async function runTurn( // policy). Absent for Claude, so a title collision there keeps the base path. piToolSpecsByName: plan.isPi ? new Map( - plan.toolSpecs.map((spec) => [ + plan.tools.toolSpecs.map((spec) => [ spec.name, { permission: spec.permission, @@ -817,15 +818,15 @@ export async function runTurn( executionGrants, }); - if (plan.useToolRelay) { + if (plan.tools.useToolRelay) { turn.toolRelay = (deps.startToolRelay ?? startToolRelay)( plan.isDaytona ? (deps.sandboxRelayHost ?? sandboxRelayHost)(env.sandbox, { log: logger, }) : (deps.localRelayHost ?? localRelayHost)(), - plan.relayDir, - plan.toolSpecs, + plan.workspace.relayDir, + plan.tools.toolSpecs, request.toolCallback as ToolCallbackContext | undefined, request.runContext, env.clientToolRelayRef.current, @@ -835,7 +836,7 @@ export async function runTurn( // Derived from the run plan's client-tool pause disposition (the closed set lives at // the client-tool boundary; the relay only needs the boolean). writePausedAnswer: relayWritesPausedAnswer( - plan.clientToolPauseDisposition, + plan.tools.clientToolPauseDisposition, ), }, ); @@ -1032,7 +1033,7 @@ export async function runTurn( const usage = await resolveRunUsage({ sandbox: env.sandbox, - usageOutPath: plan.usageOutPath, + usageOutPath: plan.workspace.usageOutPath, isDaytona: plan.isDaytona, promptResult: result, streamUsage: run.usage(), @@ -1044,18 +1045,19 @@ export async function runTurn( !plan.isDaytona && !run.output().trim() && !run.events().some((e) => e.type === "tool_call") - ? // The helper derives the transcript location from `piSessionWorkspaceDir(plan.cwd)`, - // the same shared helper `configurePiSessionWorkspace` used to point Pi at it. - findSwallowedPiError(plan.cwd) + ? // The helper derives the transcript location from + // `piSessionWorkspaceDir(plan.workspace.cwd)`, the same shared helper + // `configurePiSessionWorkspace` used to point Pi at it. + findSwallowedPiError(plan.workspace.cwd) : undefined; let swallowedError: string | undefined; if (swallowedPiError) { swallowedError = conciseError( new Error(swallowedPiError), plan.harness, - request.provider, + request.modelConnection?.provider, ); - run.recordError(swallowedError, request.provider); + run.recordError(swallowedError, request.modelConnection?.provider); run.emitEvent({ type: "error", message: swallowedError }); } @@ -1121,8 +1123,8 @@ export async function runTurn( traceId: run.traceId(), } as AgentRunResult; } catch (err) { - const error = conciseError(err, plan.harness, request.provider); - otel?.recordError(error, request.provider); + const error = conciseError(err, plan.harness, request.modelConnection?.provider); + otel?.recordError(error, request.modelConnection?.provider); otel?.emitEvent({ type: "error", message: error }); // An aborted turn may have left a partial turn in the native transcript. invalidateContinuity(sessionId, plan.harness, deps); diff --git a/services/runner/src/engines/sandbox_agent/runtime-policy.ts b/services/runner/src/engines/sandbox_agent/runtime-policy.ts index 7d883bc2c4..34a509af8a 100644 --- a/services/runner/src/engines/sandbox_agent/runtime-policy.ts +++ b/services/runner/src/engines/sandbox_agent/runtime-policy.ts @@ -80,9 +80,9 @@ export function applyClaudeConnectionEnv( // so it is never stripped, and it reaches the Daytona sandbox like `ANTHROPIC_BASE_URL`. env.ENABLE_TOOL_SEARCH = "false"; - const deployment = request.deployment; + const deployment = request.modelConnection?.deployment; const selectedModel = request.model; - const baseUrl = request.endpoint?.baseUrl; + const baseUrl = request.modelConnection?.endpoint?.baseUrl; if (baseUrl) { env.ANTHROPIC_BASE_URL = baseUrl; logger(`claude base_url: ${baseUrl}`); @@ -90,7 +90,7 @@ export function applyClaudeConnectionEnv( if (deployment === "bedrock") { env.CLAUDE_CODE_USE_BEDROCK = "1"; - const region = request.endpoint?.region; + const region = request.modelConnection?.endpoint?.region; if (region) { env.AWS_REGION = region; env.AWS_DEFAULT_REGION ??= region; diff --git a/services/runner/src/engines/sandbox_agent/session-identity.ts b/services/runner/src/engines/sandbox_agent/session-identity.ts index 455bf8f8e8..ce954d5404 100644 --- a/services/runner/src/engines/sandbox_agent/session-identity.ts +++ b/services/runner/src/engines/sandbox_agent/session-identity.ts @@ -1,4 +1,5 @@ -import { createHash } from "node:crypto"; +import { createHash, timingSafeEqual } from "node:crypto"; +import { inspect } from "node:util"; import { currentUserTurn, @@ -66,8 +67,10 @@ function nonNegativeIntEnv(name: string, fallback: number): number { function boolEnv(name: string, fallback: boolean): boolean { const raw = (process.env[name] ?? "").trim().toLowerCase(); if (!raw) return fallback; - if (raw === "1" || raw === "true" || raw === "yes" || raw === "on") return true; - if (raw === "0" || raw === "false" || raw === "no" || raw === "off") return false; + if (raw === "1" || raw === "true" || raw === "yes" || raw === "on") + return true; + if (raw === "0" || raw === "false" || raw === "no" || raw === "off") + return false; return fallback; } @@ -88,10 +91,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 { @@ -122,6 +122,55 @@ function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } +/** + * The credential material a session was built with, kept only so a later turn can answer one + * question: did any of it change? + * + * It holds the values rather than a digest of them, and that is deliberate. A digest of an API + * key is not much protection (keys have little enough entropy that a leaked digest can be + * attacked offline), and it invites exactly the misreading that a security scanner makes: that + * this is a password hash, which it is not. Nothing here authenticates anything. + * + * The real risk with holding the values is that one leaks into a log line, so this class makes + * that structurally impossible instead of relying on a convention. The material lives in a + * private field with no getter, and every way of turning an object into text (`String()`, a + * template literal, `JSON.stringify`, `console.log` / `util.inspect`) is overridden to print a + * placeholder. This mirrors what the Python side already does for the same reason + * (`ResolvedCredential` masks its value on dump and hides it from `repr`). + * + * Comparison is constant time so the check cannot be turned into a way to learn a key one byte + * at a time. + */ +export class CredentialMaterial { + readonly #canonical: string; + + constructor(canonical: string) { + this.#canonical = canonical; + } + + /** True when both were built from identical credential material. */ + equals(other: CredentialMaterial): boolean { + const mine = Buffer.from(this.#canonical, "utf8"); + const theirs = Buffer.from(other.#canonical, "utf8"); + // `timingSafeEqual` throws on a length mismatch, and lengths differ freely here, so the + // length check comes first. Length is not secret: it follows from the request's shape, + // which the config fingerprint already covers in the clear. + return mine.length === theirs.length && timingSafeEqual(mine, theirs); + } + + toString(): string { + return "[credential-material]"; + } + + toJSON(): string { + return "[credential-material]"; + } + + [inspect.custom](): string { + return "[credential-material]"; + } +} + /** Deterministic JSON: object keys sorted recursively so equal values hash equal. */ function canonicalJson(value: unknown): string { if (value === null || typeof value !== "object") return JSON.stringify(value); @@ -139,10 +188,13 @@ function canonicalJson(value: unknown): string { /** * A canonical hash over the config-bearing request fields (the continuation-versus-cold * decision). Per-turn volatiles are excluded: `messages`, `turnId`, trace propagation - * (`context`), the rotating telemetry headers, and secret VALUES (`secrets` — the credential - * epoch covers rotation, and values must never enter any hash used for logging). The - * tool-callback ENDPOINT is included (routing config); its authorization is a credential and - * lives in the credential epoch instead. + * (`context`), the rotating telemetry headers, and credential VALUES + * (`modelConnection.credentials` / MCP `connection.credentials` — the credential epoch covers + * rotation, and values must never enter any hash used for logging). The tool-callback ENDPOINT + * is included (routing config); its authorization is per-turn credential material excluded from + * every hash, the credential epoch included — each turn's relay uses the INCOMING request's + * `toolCallback` (see `CredentialEpoch` and `run-turn.ts`), so the parked copy never executes + * anything. */ export function configFingerprint(request: AgentRunRequest): string { const workflow = request.runContext?.workflow; @@ -156,18 +208,41 @@ export function configFingerprint(request: AgentRunRequest): string { request.harness === "codex" ? resolveCodexMode(request.harnessMode) : null, - provider: request.provider ?? null, connection: request.connection ?? null, - deployment: request.deployment ?? null, - endpoint: request.endpoint ?? null, + modelConnection: request.modelConnection + ? { + provider: request.modelConnection.provider, + deployment: request.modelConnection.deployment, + endpoint: request.modelConnection.endpoint ?? null, + credentialMode: request.modelConnection.credentialMode, + environment: request.modelConnection.environment ?? null, + credentials: (request.modelConnection.credentials ?? []).map( + (credential) => ({ + binding: credential.binding, + usage: credential.usage, + }), + ), + } + : null, modelCapabilities: request.modelCapabilities ?? null, - credentialMode: request.credentialMode ?? null, agentsMd: request.agentsMd ?? null, systemPrompt: request.systemPrompt ?? null, appendSystemPrompt: request.appendSystemPrompt ?? null, skills: request.skills ?? null, customTools: request.customTools ?? null, - mcpServers: request.mcpServers ?? null, + // Credential VALUES are stripped (binding + usage identify the shape); public headers are + // config and stay in. + mcpServers: + request.mcpServers?.map((server) => ({ + ...server, + connection: { + ...server.connection, + credentials: server.connection?.credentials?.map((credential) => ({ + binding: credential.binding, + usage: credential.usage, + })), + }, + })) ?? null, toolCallbackEndpoint: request.toolCallback?.endpoint ?? null, permissions: request.permissions ?? null, sandboxPermission: request.sandboxPermission ?? null, @@ -218,8 +293,7 @@ function inlineMediaDigests( typeof block.uri === "string" && block.uri.startsWith("data:") ? block.uri : String(block.data ?? ""); - const mediaType = - typeof block.mimeType === "string" ? block.mimeType : ""; + const mediaType = typeof block.mimeType === "string" ? block.mimeType : ""; digests.push(sha256(`${mediaType}\n${payload}`)); } return digests; @@ -436,22 +510,22 @@ export function tailIsFreshUserMessage(request: AgentRunRequest): boolean { } /** - * The credential epoch bounds how long a parked session may reuse its baked credentials. It is - * a PROCESS-LOCAL hash over the actual resolved secret VALUES (held only in runner memory — - * never logged, persisted, or emitted), combined with the mount credential expiry. A rotated - * same-slug secret changes the hash; an elapsed expiry invalidates the epoch. Either way the - * dispatch evicts and cold-starts with fresh credentials. + * The credential epoch bounds how long a parked session may reuse its baked credentials. It pairs + * the resolved secret material (see `CredentialMaterial`: process-local, never logged, persisted, + * or emitted) with the mount credential expiry. A rotated same-slug secret changes the material; + * an elapsed expiry invalidates the epoch. Either way the dispatch evicts and cold-starts with + * fresh credentials. * * The tool-callback bearer is deliberately EXCLUDED: it is per-turn material the backend * re-mints on its auth-cache cadence (~60s), and every turn — continuation included — starts * its tool relay from the INCOMING request's `toolCallback`, so the parked copy is never used - * to execute anything. Hashing it made warm sessions evict as "credentials-rotated" on every + * to execute anything. Including it made warm sessions evict as "credentials-rotated" on every * cache rollover for no protective value. Only material actually BAKED into the parked - * environment (the sandbox env secrets) belongs in the hash; the mount expiry bounds the rest. + * environment (the sandbox env secrets) belongs here; the mount expiry bounds the rest. */ export interface CredentialEpoch { - /** sha256 over canonical(secrets). In-memory only; never surfaced. */ - secretsHash: string; + /** The credentials this session was built with. Compared, never read. */ + secrets: CredentialMaterial; /** * Parked epochs only: the environment's installed-mount lease as epoch millis, or undefined when * it has no mounts. Incoming epochs never carry one. @@ -472,7 +546,9 @@ export function mountExpiryMs( } /** - * The epoch an INCOMING request carries: just the secret material. An incoming request has no + * The epoch an INCOMING request carries: just the secret material — the typed model credentials + * and the typed MCP header credentials, i.e. exactly what gets BAKED into the sandbox/session + * environment (plaintext locally; Daytona Secret records remotely). An incoming request has no * mount lease of its own to contribute; a parked epoch's `mountExpiresAtMs` is stamped from the * environment's installed mounts at park time (see `installedMountLease`). */ @@ -480,9 +556,33 @@ export function computeCredentialEpoch( request: AgentRunRequest, ): CredentialEpoch { const material = canonicalJson({ - secrets: request.secrets ?? {}, + modelEnvironment: request.modelConnection?.environment ?? {}, + modelCredentials: (request.modelConnection?.credentials ?? []).map( + (credential) => ({ + binding: credential.binding, + value: credential.value, + usage: credential.usage, + }), + ), + mcpCredentials: (request.mcpServers ?? []).flatMap((server) => + (server.connection?.credentials ?? []).map((credential) => ({ + server: server.name, + url: server.connection?.url ?? null, + binding: credential.binding, + value: credential.value, + usage: credential.usage, + })), + ), }); - return { secretsHash: sha256(material) }; + return { secrets: new CredentialMaterial(material) }; +} + +/** True when credentials baked into a parked sandbox/session changed (rotation ⇒ evict). */ +export function sandboxCredentialsRotated( + parked: CredentialEpoch, + incoming: CredentialEpoch, +): boolean { + return !parked.secrets.equals(incoming.secrets); } /** @@ -542,7 +642,7 @@ export function credentialEpochMismatch( now = Date.now(), ): "credentials-expired" | "credentials-rotated" | undefined { if (mountCredentialsExpired(parked, now)) return "credentials-expired"; - if (parked.secretsHash !== incoming.secretsHash) return "credentials-rotated"; + if (!parked.secrets.equals(incoming.secrets)) return "credentials-rotated"; return undefined; } @@ -603,7 +703,8 @@ export function projectScopeFor( mountProjectId: string | undefined, ): { id: string; source: PoolScopeSource } | undefined { const runContextProject = request.runContext?.project?.id?.trim(); - if (runContextProject) return { id: runContextProject, source: "run-context" }; + if (runContextProject) + return { id: runContextProject, source: "run-context" }; const mount = mountProjectId?.trim(); if (mount) return { id: mount, source: "mount" }; return undefined; diff --git a/services/runner/src/engines/sandbox_agent/workspace.ts b/services/runner/src/engines/sandbox_agent/workspace.ts index 1277bb4f5f..4ff59f4deb 100644 --- a/services/runner/src/engines/sandbox_agent/workspace.ts +++ b/services/runner/src/engines/sandbox_agent/workspace.ts @@ -1,7 +1,12 @@ import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; -import type { RunPlan } from "./run-plan.ts"; +import type { + RunPlan, + RunPlanPrompt, + RunPlanTools, + RunPlanWorkspace, +} from "./run-plan.ts"; import { materializeDaytonaPiSkillSnapshot, materializeLocalPiSkillSnapshot, @@ -18,18 +23,14 @@ export interface Workspace { export interface PrepareWorkspaceInput { sandbox: any; piSkillSnapshot?: PiSkillSnapshot; - plan: Pick< - RunPlan, - | "isDaytona" - | "isPi" - | "cwd" - | "relayDir" - | "useToolRelay" - | "agentsMd" - | "acpAgent" - | "harnessFiles" - | "skillDirs" - >; + plan: Pick & { + workspace: Pick< + RunPlanWorkspace, + "cwd" | "relayDir" | "harnessFiles" | "skillDirs" + >; + tools: Pick; + prompt: Pick; + }; log?: Log; } @@ -52,7 +53,7 @@ export async function prepareWorkspace({ piSkillSnapshot, log = () => {}, }: PrepareWorkspaceInput): Promise { - const harnessFiles = plan.harnessFiles ?? []; + const harnessFiles = plan.workspace.harnessFiles ?? []; const projectSkillRoot = plan.isPi ? undefined : `.${plan.acpAgent}/skills`; // Claude's memory loader reads CLAUDE.md, never AGENTS.md; Pi (and any other harness) reads // AGENTS.md. See the doc comment above and docs/design/agent-workflows/projects/ @@ -61,32 +62,37 @@ export async function prepareWorkspace({ plan.acpAgent === "claude" ? "CLAUDE.md" : "AGENTS.md"; if (plan.isDaytona) { - await sandbox.mkdirFs({ path: plan.cwd }).catch((err: Error) => { + await sandbox.mkdirFs({ path: plan.workspace.cwd }).catch((err: Error) => { log(`workspace mkdir skipped: ${err.message}`); }); - if (plan.useToolRelay) { + if (plan.tools.useToolRelay) { // Clear stale .req.json/.res.json from a prior turn before recreating: the relay // dir is keyed on the durable cwd and a fresh per-turn `seen` set would otherwise re-execute it. if (typeof sandbox.runProcess === "function") { // Direct argv, no shell, so an arbitrary path can't break or inject. await sandbox - .runProcess({ command: "rm", args: ["-rf", "--", plan.relayDir] }) + .runProcess({ + command: "rm", + args: ["-rf", "--", plan.workspace.relayDir], + }) .catch((err: Error) => { log(`tool relay dir clear skipped: ${err.message}`); }); } - await sandbox.mkdirFs({ path: plan.relayDir }).catch((err: Error) => { - log(`tool relay dir mkdir skipped: ${err.message}`); - }); + await sandbox + .mkdirFs({ path: plan.workspace.relayDir }) + .catch((err: Error) => { + log(`tool relay dir mkdir skipped: ${err.message}`); + }); } - if (plan.agentsMd) { + if (plan.prompt.agentsMd) { await sandbox.writeFsFile( - { path: `${plan.cwd}/${instructionsFile}` }, - plan.agentsMd, + { path: `${plan.workspace.cwd}/${instructionsFile}` }, + plan.prompt.agentsMd, ); } for (const file of harnessFiles) { - const path = `${plan.cwd}/${file.path}`; + const path = `${plan.workspace.cwd}/${file.path}`; const parent = dirname(path); await sandbox.mkdirFs({ path: parent }).catch((err: Error) => { log(`harness file dir mkdir skipped: ${err.message}`); @@ -97,11 +103,11 @@ export async function prepareWorkspace({ await materializeDaytonaPiSkillSnapshot(sandbox, piSkillSnapshot); } if (projectSkillRoot) { - for (const skill of plan.skillDirs) { + for (const skill of plan.workspace.skillDirs) { await uploadDirToSandbox( sandbox, skill.dir, - `${plan.cwd}/${projectSkillRoot}/${skill.name}`, + `${plan.workspace.cwd}/${projectSkillRoot}/${skill.name}`, ).catch((err: Error) => { log( `skill workspace upload skipped for ${skill.name}: ${err.message}`, @@ -115,25 +121,29 @@ export async function prepareWorkspace({ // A durable local cwd mount is best-effort. When geesefs cannot mount (for example, the // runner has no /dev/fuse), acquisition deliberately falls back to an ephemeral cwd. Ensure // that fallback exists before writing CLAUDE.md/AGENTS.md or any harness files into it. - mkdirSync(plan.cwd, { recursive: true }); + mkdirSync(plan.workspace.cwd, { recursive: true }); - if (plan.useToolRelay) { + if (plan.tools.useToolRelay) { // Clear stale .req.json from a prior turn: relayDir is keyed on the durable cwd and // is never otherwise cleared, so an old request would be re-picked-up by the fresh `seen` set. - rmSync(plan.relayDir, { recursive: true, force: true }); - mkdirSync(plan.relayDir, { recursive: true }); + rmSync(plan.workspace.relayDir, { recursive: true, force: true }); + mkdirSync(plan.workspace.relayDir, { recursive: true }); } - if (plan.agentsMd) - writeFileSync(join(plan.cwd, instructionsFile), plan.agentsMd, "utf-8"); + if (plan.prompt.agentsMd) + writeFileSync( + join(plan.workspace.cwd, instructionsFile), + plan.prompt.agentsMd, + "utf-8", + ); for (const file of harnessFiles) { - const path = join(plan.cwd, file.path); + const path = join(plan.workspace.cwd, file.path); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, file.content, "utf-8"); } if (piSkillSnapshot) materializeLocalPiSkillSnapshot(piSkillSnapshot); if (projectSkillRoot) { - for (const skill of plan.skillDirs) { - const dest = join(plan.cwd, projectSkillRoot, skill.name); + for (const skill of plan.workspace.skillDirs) { + const dest = join(plan.workspace.cwd, projectSkillRoot, skill.name); mkdirSync(dirname(dest), { recursive: true }); cpSync(skill.dir, dest, { recursive: true, dereference: true }); } @@ -141,7 +151,7 @@ export async function prepareWorkspace({ return { cleanup: async () => { - rmSync(plan.cwd, { recursive: true, force: true }); + rmSync(plan.workspace.cwd, { recursive: true, force: true }); }, }; } diff --git a/services/runner/src/extensions/agenta.ts b/services/runner/src/extensions/agenta.ts index 419f48abb4..7f93f63510 100644 --- a/services/runner/src/extensions/agenta.ts +++ b/services/runner/src/extensions/agenta.ts @@ -48,6 +48,10 @@ import { PI_GATE_DIALOG_TITLE, type PiGateKind, } from "../engines/sandbox_agent/pi-gate-envelope.ts"; +import { + decodePiModelProviderOverride, + PI_MODEL_PROVIDER_OVERRIDE_ENV, +} from "./model-provider-override.ts"; /** Read the OTLP bearer from its runner-written file once, then best-effort delete it. */ export function readOtlpAuthFile(path?: string): string | undefined { @@ -328,6 +332,12 @@ function registerTools(pi: ExtensionAPI): void { /** The Pi ExtensionFactory: tools + (env-driven) tracing + usage writeback. */ const factory = (pi: ExtensionAPI): void => { + const modelProviderOverrideRaw = + process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV]; + const modelProviderOverride = + modelProviderOverrideRaw === undefined + ? undefined + : decodePiModelProviderOverride(modelProviderOverrideRaw); // Fully inert unless Agenta wired this run (so it is safe to install globally in a // shared Pi agent dir — a normal `pi` session with no Agenta env does nothing). const hasTracing = !!( @@ -343,6 +353,7 @@ const factory = (pi: ExtensionAPI): void => { ); const usageOut = process.env.AGENTA_AGENT_USAGE_CAPTURE_PATH; if ( + !modelProviderOverride && !hasTracing && !hasTools && !hasBuiltinActivation && @@ -351,6 +362,14 @@ const factory = (pi: ExtensionAPI): void => { ) return; + // Extension factories complete before Pi selects the configured model. Registering only a + // baseUrl here overrides the built-in provider without replacing its model catalog or auth. + if (modelProviderOverride) { + pi.registerProvider(modelProviderOverride.provider, { + baseUrl: modelProviderOverride.baseUrl, + }); + } + if (hasTools) registerTools(pi); if (hasBuiltinActivation) registerBuiltinActivation(pi); if (hasBuiltinGating) registerBuiltinGating(pi); diff --git a/services/runner/src/extensions/model-provider-override.ts b/services/runner/src/extensions/model-provider-override.ts new file mode 100644 index 0000000000..35096e18e6 --- /dev/null +++ b/services/runner/src/extensions/model-provider-override.ts @@ -0,0 +1,65 @@ +export const PI_MODEL_PROVIDER_OVERRIDE_ENV = + "AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE"; + +export interface PiModelProviderOverride { + provider: string; + baseUrl: string; +} + +const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +/** Validate the public routing config shared by the runner and the in-Pi extension. */ +export function validatePiModelProviderOverride( + value: unknown, +): PiModelProviderOverride { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("model provider override must be an object"); + } + + const provider = (value as { provider?: unknown }).provider; + if (typeof provider !== "string" || !PROVIDER_ID.test(provider)) { + throw new Error("model provider override has an invalid provider"); + } + + const baseUrl = (value as { baseUrl?: unknown }).baseUrl; + if (typeof baseUrl !== "string" || baseUrl.trim() !== baseUrl) { + throw new Error("model provider override has an invalid baseUrl"); + } + + let url: URL; + try { + url = new URL(baseUrl); + } catch { + throw new Error("model provider override baseUrl must be a valid URL"); + } + if ( + url.protocol !== "https:" || + !url.hostname || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error( + "model provider override baseUrl must be an HTTPS URL without credentials, query, or fragment", + ); + } + + return { provider, baseUrl }; +} + +export function encodePiModelProviderOverride(value: unknown): string { + return JSON.stringify(validatePiModelProviderOverride(value)); +} + +export function decodePiModelProviderOverride( + raw: string, +): PiModelProviderOverride { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("model provider override must be valid JSON"); + } + return validatePiModelProviderOverride(parsed); +} diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts index f06b25b2c2..ea7ceb60e7 100644 --- a/services/runner/src/protocol.ts +++ b/services/runner/src/protocol.ts @@ -260,13 +260,49 @@ export interface McpToolPolicy { names?: string[]; } +/** + * One secret HTTP header an MCP server needs, e.g. `Authorization: Bearer `. + * + * The three fields answer three separate questions, and keeping them separate is what makes a + * credential substitutable on a remote sandbox: + * + * - `binding` says WHERE the value goes. Today the only answer for MCP is `header`, because the + * only MCP transport we accept is HTTP and a remote server reads its credential from a header. + * A future stdio MCP server would need `{ kind: "environment", name }` instead, which is why + * this is an object with a `kind` rather than a bare header name. + * - `value` is the secret itself. + * - `usage` says WHO reads the value, which decides whether we can hide it. See the note on + * `ModelCredential.usage` below for the full reasoning; `opaque_http` means the value is only + * ever read by the remote server on the other end of an HTTPS request, so the sandbox never + * needs to see it and Daytona can substitute it into the outbound request. + * + * MCP has only `opaque_http` today because every MCP server we support is remote and HTTPS. When + * OAuth lands, the token is still an `opaque_http` header credential; what changes is who mints + * it and how often, which is a resolver concern upstream of this wire, not a new `usage`. A + * gateway MCP server is the same shape too: it is an HTTP MCP server whose URL happens to be + * ours. Neither needs a new field here. + */ +export interface McpCredential { + binding: { kind: "header"; name: string }; + value: string; + usage: "opaque_http"; +} + +/** + * A user-declared HTTP MCP server attached to the run. Public HTTP headers and secret HTTP + * header credentials remain separate by protocol role: `headers` carries non-secret values + * only, and every secret header rides a typed `McpCredential` so the runner can materialize + * it (plaintext locally; a Daytona Secret placeholder on a remote sandbox). + */ export interface McpServerConfig { name: string; connection: { type: "http"; url: string; - /** Resolved per-run headers. Values may be secret and must never be logged. */ + /** Resolved per-run PUBLIC headers. Secret headers ride `credentials`. */ headers?: Record; + /** Typed secret header bindings. Values must never be logged. */ + credentials?: McpCredential[]; }; policy: { tools: McpToolPolicy; @@ -430,6 +466,101 @@ export interface AgentUsage { cost: number; } +/** + * WHERE a model credential has to land for the harness to pick it up. + * + * `environment` is the only kind today, and it is not a placeholder for a missing case: every + * agent harness we run (Pi, Claude, Codex) reads its provider key from an environment variable, + * because that is the convention their underlying SDKs use. A `header` kind would be the natural + * addition if we ever call a provider over raw HTTP ourselves instead of handing the key to a + * harness, which is exactly what MCP does (see `McpCredential`). Modeling this as an object with + * a `kind` rather than a bare variable name is what lets the two consumers share one shape. + */ +export interface ModelCredentialBinding { + kind: "environment"; + name: string; +} + +/** + * One secret the model provider needs, plus enough information to decide whether the sandbox is + * allowed to see it. + * + * `usage` is the field that decides that, and it has exactly two answers because there are + * exactly two ways a provider credential gets consumed: + * + * - `opaque_http` — the value is a bearer token that only the provider's own server reads, over + * HTTPS, at a host we know in advance. Nothing inside the sandbox ever needs the real string. + * On Daytona we therefore replace it with a `dtn_secret_` placeholder and let Daytona's + * egress proxy substitute the real value into the outbound request, so a compromised agent + * that dumps its own environment gets a useless placeholder. `OPENAI_API_KEY` and + * `ANTHROPIC_API_KEY` are the common cases. + * - `local_use` — the value is consumed by a provider SDK running INSIDE the sandbox, which + * signs the request locally rather than sending the secret. AWS SigV4 is the reason this + * exists: `AWS_SECRET_ACCESS_KEY` never travels on the wire, boto derives a signature from it + * on the spot, so an outbound-substitution trick cannot work and the sandbox must hold the + * real value. We accept that and keep the list of names that may claim `local_use` short and + * explicit (`daytona-secret-plan.ts`), so nobody can smuggle an opaque provider key through + * this door and quietly lose the hiding. + * + * The split is deliberately about the CONSUMER, not the provider name: it is the only property + * that determines whether hiding is even possible, and it stays correct when a new provider + * arrives. + */ +export interface ModelCredential { + binding: ModelCredentialBinding; + value: string; + usage: "opaque_http" | "local_use"; +} + +/** + * Everything the runner needs to reach the model, grouped under the consumer that owns it. + * + * The organization mirrors `ResolvedConnection` in the Python SDK + * (`sdks/python/agenta/sdk/agents/connections/models.py`), which is the authority: the resolver + * builds it from the vault, validates it there, and serializes it onto this wire. The runner + * re-validates rather than trusting it, but it does not invent fields. Grouping matters because + * the run has more than one credential consumer: the model owns this object, each MCP server + * owns its own `connection.credentials`. Before this grouping, every consumer's keys were mixed + * into one flat `secrets` map and the runner could not tell whose key was whose, which is + * precisely what made hiding them impossible. + * + * The fields: + * + * - `provider` is the credential-ownership family (`openai`, `anthropic`, `bedrock`, ...). It + * says who issued the key, not which model runs. + * - `deployment` is HOW that provider is reached: `direct` (the provider's own API), `custom` + * (an OpenAI-compatible third party such as OpenRouter or a self-hosted gateway), or `bedrock` + * / `vertex` (a cloud reseller with its own auth scheme). + * - `endpoint` is the route, and it is general, not OpenAI-specific. `baseUrl` is what an + * OpenAI-compatible deployment needs; `apiVersion` is what Azure needs; `region` is what AWS + * and Vertex need; `headers` carries non-secret routing headers some gateways require. A + * given deployment fills in the subset that applies to it and leaves the rest unset. AWS and + * the other cloud resellers are covered by exactly this: `deployment: "bedrock"` plus + * `endpoint.region`, with their access keys arriving as `local_use` credentials because the + * AWS SDK signs locally. + * - `credentialMode` says where the credential comes from at all: `env` (we resolved one and it + * is in `credentials`), `runtime_provided` (the harness authenticates with its own login, e.g. + * a Claude or Codex subscription, and we inject nothing), or `none`. + * - `environment` is non-secret configuration that still has to reach the process as environment + * variables, such as `AWS_REGION`. It is a separate field from `credentials` so that nothing + * secret can hide in a map we treat as public; the runner enforces a short allowlist of names + * that may appear here. + * - `credentials` are the secrets, each one typed as above. + */ +export interface ModelConnection { + provider: string; + deployment: string; + endpoint?: { + baseUrl?: string; + apiVersion?: string; + region?: string; + headers?: Record; + }; + credentialMode: "env" | "runtime_provided" | "none"; + environment?: Record; + credentials: ModelCredential[]; +} + export interface AgentRunRequest { /** * Harness id: "pi_core" | "pi_agenta" | "claude". `pi_core` and `pi_agenta` both drive the @@ -441,8 +572,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; /** @@ -467,38 +596,26 @@ export interface AgentRunRequest { */ harnessMode?: 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). + * Which connection the author CHOSE, named portably (a slug, never a database id). Non-secret. + * + * Distinct from `modelConnection`, which is what that choice resolved to: the route and the + * credential values. This field carries only the intent (`mode`) and the identity (`slug`). + * + * It is load-bearing, not informational. A named Agenta connection on a custom + * OpenAI-compatible Pi run is registered in Pi's own models.json under a provider named after + * this slug (`pi-model-config.ts` gates on `mode === "agenta"` and reads `slug`). Without it + * those runs fall back to the generic provider-override path and route differently, which is + * a silent behavior change rather than a failure. The SDK emits it from + * `HarnessAgentTemplate.wire_connection_ref()` and the schema declares it on + * `WireRunRequest`; both are pinned by tests, so do not read this field as optional in + * practice for a named connection. + * + * Omitted for the project default (`agenta` with no slug), which carries no information + * beyond the model itself. */ connection?: { mode: string; slug?: string }; - /** - * Deployment surface for the provider: "direct" | "azure" | "bedrock" | "vertex" | - * "custom". From a resolved connection; see the provider-model-auth design (Concern 3). - */ - deployment?: string; - /** - * Non-secret connection config (custom base URL, api version, region, public headers). - * Secret values never live here; they ride `secrets`. See the provider-model-auth design - * (Concern 3). - */ - endpoint?: { - baseUrl?: string; - apiVersion?: string; - region?: string; - headers?: Record; - }; - /** - * How the credential is delivered: "env" | "runtime_provided" | "none". From a resolved - * connection; see the provider-model-auth design (Concern 3). - */ - credentialMode?: string; + /** Resolved model routing and credential bindings, grouped under their consumer. */ + modelConnection?: ModelConnection; /** The conversation so far; the runner picks the latest turn and replays the rest. */ messages?: ChatMessage[]; /** Deprecated: accepted and ignored. Pi activates every built-in tool on every run. */ diff --git a/services/runner/src/redaction.ts b/services/runner/src/redaction.ts index 96a2ada6ad..6c1b66cef8 100644 --- a/services/runner/src/redaction.ts +++ b/services/runner/src/redaction.ts @@ -389,17 +389,49 @@ export function seedFromEnv(options?: { /** The shape `seedForRun` reads off an `AgentRunRequest` (structural, to avoid importing the * wire types into the redaction primitive). */ export interface RunSeedSource { - /** Provider API keys resolved per run and applied into the run env (`daemon.ts`). */ - secrets?: Record; + /** Resolved model routing: typed credential values plus the materialized environment values. */ + modelConnection?: { + environment?: Record; + credentials?: Array<{ value?: string }>; + }; + /** Resolved MCP servers: each connection's typed secret header credential values. */ + mcpServers?: Array<{ + connection?: { credentials?: Array<{ value?: string }> }; + }>; telemetry?: { exporters?: { otlp?: { headers?: Record } }; }; } /** - * The runner's per-run deny-set (WP1.1). The run's resolved provider keys ride `secrets` on the - * wire and never appear in the sidecar's own process env, so a process-env-only seed would miss - * exactly the highest-value secrets — they must be seeded from the REQUEST. + * Every credential-bearing value the request's TYPED shapes carry: the model connection's + * credential values and materialized environment values, plus each MCP server connection's + * credential values. This is a superset of whatever subset actually lands in the sandbox env + * (on a Daytona Secrets run the opaque values leave the plaintext env for the secret plan, but + * they still transit runner memory and can be echoed by the model), so the deny-set seeds from + * the request, not from the delivered environment. + */ +export function requestSecretValues( + request: RunSeedSource, +): Array { + return [ + ...Object.values(request.modelConnection?.environment ?? {}), + ...(request.modelConnection?.credentials ?? []).map( + (credential) => credential.value, + ), + ...(request.mcpServers ?? []).flatMap((server) => + (server.connection?.credentials ?? []).map( + (credential) => credential.value, + ), + ), + ]; +} + +/** + * The runner's per-run deny-set (WP1.1). The run's resolved credential values ride the typed + * `modelConnection` / `mcpServers` wire shapes and never appear in the sidecar's own process + * env, so a process-env-only seed would miss exactly the highest-value secrets — they must be + * seeded from the REQUEST (`requestSecretValues`). */ export function seedForRun( request: RunSeedSource, @@ -412,7 +444,7 @@ export function seedForRun( "" ).trim(); return seedFromEnv({ - resolvedSecrets: Object.values(request.secrets ?? {}), + resolvedSecrets: requestSecretValues(request), runCredential: runCredential || null, extraValues, }); diff --git a/services/runner/src/server.ts b/services/runner/src/server.ts index 29eef56507..3b871214aa 100644 --- a/services/runner/src/server.ts +++ b/services/runner/src/server.ts @@ -61,7 +61,9 @@ import { mountCredentialsExpired, mountCredentialsExpireBy, MOUNT_LEASE_SKEW_MS, + sandboxCredentialsRotated, type CredentialEpoch, + type CredentialMaterial, expectedNextHistoryFingerprint, historyFingerprint, historyTailFromLastUserTurn, @@ -543,9 +545,9 @@ export async function runWithKeepalive( // from whatever this dispatch signed to compute the pool key. const parkedEpoch = ( env: SessionEnvironment, - secretsHash: string, + secrets: CredentialMaterial, ): CredentialEpoch => ({ - secretsHash, + secrets, mountExpiresAtMs: installedMountLease(env.installedMountExpiries), }); @@ -561,7 +563,7 @@ export async function runWithKeepalive( configFingerprint: cfgFp, historyFingerprint: nextHistoryFp(env), historyAsserted, - credentialEpoch: parkedEpoch(env, incomingEpoch.secretsHash), + credentialEpoch: parkedEpoch(env, incomingEpoch.secrets), teardown: (reason: TeardownReason) => env.destroy({ reason }), }; if (approvalToPark(env, result)) { @@ -601,7 +603,7 @@ export async function runWithKeepalive( configFingerprint: cfgFp, historyFingerprint: nextHistoryFp(env), historyAsserted, - credentialEpoch: parkedEpoch(env, live.credentialEpoch.secretsHash), + credentialEpoch: parkedEpoch(env, live.credentialEpoch.secrets), }; if (approvalToPark(env, result)) { klog( @@ -766,15 +768,9 @@ export async function runWithKeepalive( // An approval-parked session. A validated approval decision that matches the parked // ACP gate resumes it live; anything else evicts and degrades to cold. // - // Unlike the idle-continuation branch above, this branch does NOT require the resume request's - // configFingerprint or credential epoch to EQUAL the parked session's. Every approval reply is - // a fresh /run the backend mints carrying freshly minted short-lived material (gateway/Composio - // secret VALUES, a per-turn tool-callback bearer), so the incoming credential epoch — and often - // the config fingerprint, which can embed those per-turn tokens — practically never match the - // parked ones. But the parked live process already holds its OWN resolved credentials baked at - // acquire time; the resume request only delivers the human's yes/no. Re-minted per-turn material - // on the resume says nothing about the parked environment's validity, so matching it against the - // park would evict a perfectly good live session on every approval (the "approve twice" bug). + // Approval replies may carry re-minted per-turn callback authorization, which does not invalidate + // the parked process. Credentials baked into the model/MCP environment are different: rotation + // must evict and cold-start so the resumed process never keeps stale credential material. // // We keep the checks that DO bound the parked environment: the approval-decision match, the // history fingerprint (an edited transcript must not continue wrongly, but only for a client @@ -841,9 +837,19 @@ export async function runWithKeepalive( } else if (mountCredentialsExpired(existing.credentialEpoch)) { mismatch = "credentials-expired"; } else if ( - mountCredentialsExpireBy(existing.credentialEpoch, requiredValidThroughMs) + mountCredentialsExpireBy( + existing.credentialEpoch, + requiredValidThroughMs, + ) ) { mismatch = "credentials-expiring"; + } else if ( + sandboxCredentialsRotated(existing.credentialEpoch, incomingEpoch) + ) { + // Re-minted per-turn callback auth never invalidates the parked process, but credentials + // BAKED into the model/MCP environment are different: rotation must evict and cold-start + // so the resumed process never keeps stale credential material. + mismatch = "credentials-rotated"; } } @@ -951,9 +957,15 @@ const keepalivePools: Record< const runAgent: RunAgent = (request, emit, signal, options) => { const provider = resolveKeepaliveDispatch(request, keepaliveConfigs); if (!provider) { - return runSandboxAgent(request, emit, signal, {}, { - ...(options?.credential ? { credential: options.credential } : {}), - }); + return runSandboxAgent( + request, + emit, + signal, + {}, + { + ...(options?.credential ? { credential: options.credential } : {}), + }, + ); } const config = keepaliveConfigs[provider]; return runWithKeepalive(request, emit, signal, { @@ -1133,8 +1145,10 @@ async function runAndStreamWithApiBaseResolved( answeredTokens, watchdog.credential, ); - // Deny-set from THIS run's resolved provider keys + run credential (not process env, - // which never holds them). + // Deny-set from THIS run's typed credential material (model connection credentials + + // materialized environment values + MCP connection credentials) and the run credential — + // not process env, which never holds them. A credential value a model echoes back must + // never reach the durable session records unredacted. const { emit: persistingEmit, persist, @@ -1184,7 +1198,13 @@ async function runAndStreamWithApiBaseResolved( } catch (err) { const message = err instanceof Error ? err.message : String(err); // Stack stays server-side; the message alone goes on the wire and into the transcript. - if (err instanceof Error && err.stack) console.error(err.stack); + // Server-side is still a sink: an escaping error can capture this run's live credentials + // in its message/stack (an auth failure echoing the key, a dumped env), so the stack runs + // through the run's own deny-set before it reaches stderr — same seed the persisting + // emitter uses (`seedForRun(request)`), keeping the log shape intact with values scrubbed. + if (err instanceof Error && err.stack) { + console.error(seedForRun(request).redactString(err.stack, "stderr")); + } // A throw escaping run() itself (outside the engine's own try/catch) emitted no error // event — persist it here as the backstop. if (persistError) persistError(message); diff --git a/services/runner/tests/setup/hermetic-env.ts b/services/runner/tests/setup/hermetic-env.ts index b3a34a624b..6031b9f227 100644 --- a/services/runner/tests/setup/hermetic-env.ts +++ b/services/runner/tests/setup/hermetic-env.ts @@ -44,6 +44,7 @@ const SCRUBBED = [ "AGENTA_RUNNER_DAYTONA_IMAGE", "AGENTA_RUNNER_DAYTONA_AUTOSTOP_MINUTES", "AGENTA_RUNNER_DAYTONA_AUTODELETE_MINUTES", + "AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS", "DAYTONA_API_KEY", "DAYTONA_API_URL", "DAYTONA_TARGET", diff --git a/services/runner/tests/unit/attachment-delivery-events.test.ts b/services/runner/tests/unit/attachment-delivery-events.test.ts index fbb81657da..b5a7f5c759 100644 --- a/services/runner/tests/unit/attachment-delivery-events.test.ts +++ b/services/runner/tests/unit/attachment-delivery-events.test.ts @@ -71,7 +71,7 @@ describe("attachment delivery events", () => { auth: () => "ApiKey test", sandbox: {}, plan: { - cwd, + workspace: { cwd }, isDaytona: false, acpAgent: "pi", harness: "pi_core", @@ -151,7 +151,7 @@ describe("attachment delivery events", () => { auth: () => "ApiKey test", sandbox: {}, plan: { - cwd, + workspace: { cwd }, isDaytona: false, acpAgent: "pi", harness: "pi_core", @@ -207,7 +207,7 @@ describe("attachment delivery events", () => { auth: () => "ApiKey test", sandbox: {}, plan: { - cwd, + workspace: { cwd }, isDaytona: false, acpAgent: "pi", harness: "pi_core", diff --git a/services/runner/tests/unit/attachment-materialize.test.ts b/services/runner/tests/unit/attachment-materialize.test.ts index 4611cc624b..ae588fea88 100644 --- a/services/runner/tests/unit/attachment-materialize.test.ts +++ b/services/runner/tests/unit/attachment-materialize.test.ts @@ -39,7 +39,7 @@ describe("attachment materialization", () => { assert.equal( await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, ref, new Uint8Array([1, 2, 3]), ), @@ -56,7 +56,7 @@ describe("attachment materialization", () => { const ref = { attachmentId: ID_ONE, filename: "photo.png" }; await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, ref, new Uint8Array([1]), ); @@ -66,7 +66,7 @@ describe("attachment materialization", () => { assert.equal( await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, ref, new Uint8Array([2]), ), @@ -81,13 +81,13 @@ describe("attachment materialization", () => { const second = { attachmentId: ID_TWO, filename: "same.txt" }; await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, first, new Uint8Array([1]), ); await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, second, new Uint8Array([2]), ); @@ -125,7 +125,7 @@ describe("attachment materialization", () => { assert.equal( await materializeWorkingCopy( sandbox, - { cwd, isDaytona: true }, + { workspace: { cwd }, isDaytona: true }, ref, bytes, ), @@ -159,7 +159,7 @@ describe("attachment materialization", () => { await assert.rejects( materializeWorkingCopy( sandbox, - { cwd: "/home/sandbox/cwd", isDaytona: true }, + { workspace: { cwd: "/home/sandbox/cwd" }, isDaytona: true }, { attachmentId: ID_ONE, filename: "photo.png" }, new Uint8Array([1]), ), @@ -180,7 +180,7 @@ describe("attachment materialization", () => { const existing = { attachmentId: ids[0], filename: `.png` }; await materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, existing, new Uint8Array([9]), ); @@ -215,7 +215,7 @@ describe("attachment materialization", () => { const restored = await restoreReferencedWorkingCopies( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, messages, "session-1", () => "ApiKey test", @@ -272,7 +272,7 @@ describe("attachment materialization", () => { const restored = await restoreReferencedWorkingCopies( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, messages, "session-1", () => "ApiKey test", diff --git a/services/runner/tests/unit/attachment-path-safety.test.ts b/services/runner/tests/unit/attachment-path-safety.test.ts index 048355cf35..8b114e9247 100644 --- a/services/runner/tests/unit/attachment-path-safety.test.ts +++ b/services/runner/tests/unit/attachment-path-safety.test.ts @@ -77,7 +77,7 @@ describe("attachment path safety", () => { () => materializeWorkingCopy( {}, - { cwd, isDaytona: false }, + { workspace: { cwd }, isDaytona: false }, { attachmentId: ATTACHMENT_ID, filename: "safe.txt" }, new Uint8Array([1]), ), diff --git a/services/runner/tests/unit/daytona-secret-plan.test.ts b/services/runner/tests/unit/daytona-secret-plan.test.ts new file mode 100644 index 0000000000..13894b026a --- /dev/null +++ b/services/runner/tests/unit/daytona-secret-plan.test.ts @@ -0,0 +1,320 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "vitest"; + +import type { AgentRunRequest, McpServerConfig } from "../../src/protocol.ts"; +import { + assertDaytonaOpaqueSecretsEnabled, + buildDaytonaSecretPlan, + exactHttpsHost, +} from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; +import { buildRunPlan } from "../../src/engines/sandbox_agent/run-plan.ts"; +import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; + +// The buildRunPlan case below exercises a Daytona run, so enable the provider (with a +// provisioning credential) on top of the hermetic scrub and drop the memoized config. +beforeEach(() => { + process.env.AGENTA_RUNNER_ENABLED_SANDBOX_PROVIDERS = "local,daytona"; + process.env.AGENTA_RUNNER_DAYTONA_API_KEY = "test-key"; + resetRunnerConfigCache(); +}); + +afterEach(() => { + delete process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; +}); + +const modelConnection = { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com/v1/messages" }, + credentialMode: "env" as const, + environment: { AWS_REGION: "us-east-1" }, + credentials: [ + { + binding: { kind: "environment" as const, name: "ANTHROPIC_API_KEY" }, + value: "opaque-model-value", + usage: "opaque_http" as const, + }, + ], +}; + +describe("Daytona Secret planning", () => { + it("plans exact hosts and keeps opaque values out of the direct environment", () => { + const plan = buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + credentials: [ + ...modelConnection.credentials, + { + binding: { + kind: "environment" as const, + name: "AWS_PROFILE", + }, + value: "local-only", + usage: "local_use" as const, + }, + ], + }, + mcpServers: [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/rpc", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value: "opaque-mcp-value", + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, + ], + }); + + assert.deepEqual( + plan.candidates.map((candidate) => ({ + consumer: candidate.consumer, + binding: candidate.binding.name, + host: candidate.allowedHost, + })), + [ + { + consumer: { kind: "model" }, + binding: "ANTHROPIC_API_KEY", + host: "api.anthropic.com", + }, + { + consumer: { kind: "http_mcp", server: "linear" }, + binding: "Authorization", + host: "mcp.linear.app", + }, + ], + ); + assert.deepEqual(plan.environment, { + AWS_REGION: "us-east-1", + AWS_PROFILE: "local-only", + }); + assert.equal(JSON.stringify(plan.environment).includes("opaque-"), false); + }); + + it("rejects IP literals, internal names, wildcards, credentials, and non-default ports", () => { + for (const url of [ + "https://8.8.8.8/v1", + "https://[2001:4860:4860::8888]/v1", + "https://metadata.google.internal/v1", + "https://metadata/v1", + "https://localhost/v1", + "https://*.example.com/v1", + "https://user:pass@example.com/v1", + "https://example.com:8443/v1", + ]) { + assert.throws(() => exactHttpsHost(url), /Invalid Daytona secret plan/); + } + assert.equal( + exactHttpsHost("https://API.EXAMPLE.COM./v1"), + "api.example.com", + ); + }); + + it("rejects reserved credential bindings", () => { + assert.throws( + () => + buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + credentials: [ + { + ...modelConnection.credentials[0], + binding: { kind: "environment", name: "DAYTONA_API_KEY" }, + }, + ], + }, + }), + /credential binding 'DAYTONA_API_KEY' is reserved/, + ); + }); + + it("rejects an opaque model credential that collides with a direct environment binding", () => { + // AWS_REGION rides `envVars` directly; a same-named (case-insensitive) Secret attachment + // would put the binding in BOTH `envVars` and `secrets` with undefined precedence. + assert.throws( + () => + buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + credentials: [ + { + binding: { kind: "environment", name: "aws_region" }, + value: "opaque-collides", + usage: "opaque_http", + }, + ], + }, + }), + /collides with a direct environment binding/, + ); + // Wire order must not matter: an opaque credential listed BEFORE the same-named + // local_use credential still collides (direct bindings settle first). + assert.throws( + () => + buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + environment: {}, + credentials: [ + { + binding: { kind: "environment", name: "aws_profile" }, + value: "opaque-collides", + usage: "opaque_http", + }, + { + binding: { kind: "environment", name: "AWS_PROFILE" }, + value: "local-only", + usage: "local_use", + }, + ], + }, + }), + /collides with a direct environment binding/, + ); + }); + + it("fails closed on plaintext credential bypasses in model environment and local_use", () => { + assert.throws( + () => + buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + credentialMode: "none", + environment: { ANTHROPIC_API_KEY: "plaintext-bypass" }, + credentials: [], + }, + }), + /not approved public config/, + ); + assert.throws( + () => + buildDaytonaSecretPlan({ + modelConnection: { + ...modelConnection, + credentials: [ + { + binding: { + kind: "environment", + name: "ANTHROPIC_API_KEY", + }, + value: "plaintext-bypass", + usage: "local_use", + }, + ], + }, + }), + /not approved for local provider-SDK use/, + ); + }); + + it("secretizes every MCP header regardless of whether its name looks credential-like", () => { + const plaintext = ["Bearer plaintext-bypass", "arbitrary-secret"]; + const plan = buildDaytonaSecretPlan({ + mcpServers: [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/rpc", + headers: { + Authorization: plaintext[0], + "X-Foo": plaintext[1], + }, + credentials: [ + { + binding: { kind: "header", name: "X-Typed-Key" }, + value: "typed-secret", + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, + ], + }); + assert.deepEqual( + plan.candidates.map((candidate) => candidate.binding.name), + ["Authorization", "X-Foo", "X-Typed-Key"], + ); + assert.equal( + plaintext.some((value) => + JSON.stringify(plan.environment).includes(value), + ), + false, + ); + assert.throws( + () => + buildDaytonaSecretPlan({ + mcpServers: [ + { + name: "bad", + connection: { + type: "http", + headers: { Accept: "application/json" }, + }, + policy: { tools: { mode: "all" } }, + } as unknown as McpServerConfig, + ], + }), + /require a URL/, + ); + }); + + it("keeps the feature default-off and accepts only the explicit process_local mode", () => { + const plan = buildDaytonaSecretPlan({ modelConnection }); + assert.throws( + () => assertDaytonaOpaqueSecretsEnabled(plan), + /AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local/, + ); + assert.doesNotThrow(() => + assertDaytonaOpaqueSecretsEnabled(plan, "process_local"), + ); + assert.throws(() => assertDaytonaOpaqueSecretsEnabled(plan, "true")); + }); + + it("keeps flag-off Daytona runs on today's plaintext delivery and secretizes only when on", () => { + const request = { + harness: "claude", + sandbox: "daytona", + messages: [{ role: "user", content: "hello" }], + modelConnection, + } satisfies AgentRunRequest; + + // Flag OFF (the hermetic default): behavior-identical to main — the run proceeds and the + // opaque key rides the plaintext model environment; no secret plan, no fail-closed. + const disabled = buildRunPlan(request, { + createDaytonaCwd: () => "/sandbox/cwd", + }); + assert.equal(disabled.ok, true); + if (!disabled.ok) return; + assert.equal( + disabled.plan.credentials.modelEnvironment.ANTHROPIC_API_KEY, + "opaque-model-value", + ); + assert.equal(disabled.plan.credentials.daytonaSecretPlan, undefined); + assert.equal(disabled.plan.credentials.hasApiKey, true); + + // Flag ON: the opaque value leaves the plaintext environment for the secret plan. + process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = "process_local"; + const enabled = buildRunPlan(request, { + createDaytonaCwd: () => "/sandbox/cwd", + }); + assert.equal(enabled.ok, true); + if (!enabled.ok) return; + assert.deepEqual(enabled.plan.credentials.modelEnvironment, { + AWS_REGION: "us-east-1", + }); + // hasApiKey consults the FULL materialized environment: the opaque key left the plaintext + // env for the secret plan, but the harness still receives it as a Secret attachment. + assert.equal(enabled.plan.credentials.hasApiKey, true); + assert.equal(enabled.plan.credentials.daytonaSecretPlan?.candidates.length, 1); + }); +}); diff --git a/services/runner/tests/unit/daytona-secret-provider.test.ts b/services/runner/tests/unit/daytona-secret-provider.test.ts new file mode 100644 index 0000000000..a7eae750e4 --- /dev/null +++ b/services/runner/tests/unit/daytona-secret-provider.test.ts @@ -0,0 +1,579 @@ +import assert from "node:assert/strict"; +import { afterEach, describe, it, vi } from "vitest"; + +import type { McpServerConfig } from "../../src/protocol.ts"; +import { + daytonaWithProcessLocalSecrets, + type DaytonaProviderLike, +} from "../../src/engines/sandbox_agent/daytona-secret-provider.ts"; +import { DaytonaReconnectTerminalError } from "../../src/engines/sandbox_agent/daytona-provider.ts"; +import type { DaytonaSecretPlan } from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; +import type { DaytonaSecretApi } from "../../src/engines/sandbox_agent/daytona-secrets.ts"; +import { + buildDaytonaCreate, + daytonaCreateFingerprint, +} from "../../src/engines/sandbox_agent/provider.ts"; +import { parseRunnerConfig } from "../../src/config/runner-config.ts"; + +const plan: DaytonaSecretPlan = { + environment: {}, + candidates: [ + { + ordinal: 0, + consumer: { kind: "model" }, + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + allowedHost: "api.anthropic.com", + value: "model-plaintext", + }, + { + ordinal: 1, + consumer: { kind: "http_mcp", server: "linear" }, + binding: { kind: "header", name: "Authorization" }, + allowedHost: "mcp.linear.app", + value: "mcp-plaintext", + }, + ], +}; + +const mcpServers: McpServerConfig[] = [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/rpc", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value: "mcp-plaintext", + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, +]; + +function secretApi(events: string[]): DaytonaSecretApi { + let count = 0; + return { + async create(input) { + count += 1; + events.push(`secret:create:${input.value}`); + return { + id: `secret-${count}`, + name: input.name, + placeholder: `dtn_secret_${count}`, + hosts: input.hosts, + }; + }, + async delete(id) { + events.push(`secret:delete:${id}`); + }, + }; +} + +function providerFactory(events: string[], attachmentLog: any[]) { + return (attachments: Record): DaytonaProviderLike => { + attachmentLog.push(attachments); + return { + name: "daytona", + async create() { + events.push("sandbox:create"); + return "sandbox-1"; + }, + async destroy(id) { + events.push(`sandbox:destroy:${id}`); + }, + async pause(id) { + events.push(`sandbox:pause:${id}`); + }, + async reconnect(id) { + events.push(`sandbox:reconnect:${id}`); + }, + }; + }; +} + +afterEach(() => vi.useRealTimers()); + +describe("process-local Daytona Secret provider", () => { + it("attaches Secret names at create and substitutes MCP plaintext with placeholders", async () => { + const events: string[] = []; + const attachments: any[] = []; + const headerPlan: DaytonaSecretPlan = { + ...plan, + candidates: [ + ...plan.candidates, + { + ordinal: 2, + consumer: { kind: "http_mcp", server: "linear" }, + binding: { kind: "header", name: "X-Foo" }, + allowedHost: "mcp.linear.app", + value: "mcp-public-plaintext", + }, + ], + }; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events, attachments), + headerPlan, + secretApi(events), + { registry: new Map(), cleanupDelayMilliseconds: 1_000 }, + ); + + await provider.create(); + assert.deepEqual(attachments[0], { + ANTHROPIC_API_KEY: attachments[0].ANTHROPIC_API_KEY, + AGENTA_MCP_SECRET_1: attachments[0].AGENTA_MCP_SECRET_1, + AGENTA_MCP_SECRET_2: attachments[0].AGENTA_MCP_SECRET_2, + }); + assert.notEqual(attachments[0].ANTHROPIC_API_KEY, "model-plaintext"); + const materialized = provider.materializeMcpServers([ + { + ...mcpServers[0], + connection: { + ...mcpServers[0].connection, + headers: { "X-Foo": "mcp-public-plaintext" }, + }, + }, + ])!; + assert.equal( + materialized[0].connection.credentials?.[0].value, + "dtn_secret_2", + ); + assert.equal(materialized[0].connection.headers?.["X-Foo"], "dtn_secret_3"); + assert.equal(JSON.stringify(materialized).includes("mcp-plaintext"), false); + assert.equal( + JSON.stringify(materialized).includes("mcp-public-plaintext"), + false, + ); + }); + + it("deletes the sandbox before Secrets on destructive teardown", async () => { + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + secretApi(events), + { registry: new Map(), cleanupDelayMilliseconds: 1_000 }, + ); + const id = await provider.create(); + await provider.destroy(id); + + const destroyIndex = events.indexOf("sandbox:destroy:sandbox-1"); + const firstSecretDelete = events.findIndex((event) => + event.startsWith("secret:delete"), + ); + assert.ok(destroyIndex >= 0 && destroyIndex < firstSecretDelete); + assert.deepEqual(events.slice(firstSecretDelete), [ + "secret:delete:secret-2", + "secret:delete:secret-1", + ]); + }); + + it("retains Secrets when create rejects and remote sandbox absence is unknown", async () => { + const events: string[] = []; + const logs: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + (): DaytonaProviderLike => ({ + name: "daytona", + async create() { + events.push("sandbox:create:remote-created"); + throw new Error("daemon start failed"); + }, + async destroy() { + events.push("sandbox:destroy"); + }, + }), + plan, + secretApi(events), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + log: (message) => logs.push(message), + }, + ); + + await assert.rejects(() => provider.create(), /daemon start failed/); + assert.equal( + events.some((event) => event.startsWith("secret:delete")), + false, + ); + assert.equal(events.includes("sandbox:destroy"), false); + assert.match(logs[0], /retaining 2 Secret allocation/); + }); + + it("deletes Secrets when provider construction proves no remote create started", async () => { + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + (): DaytonaProviderLike => { + throw new Error("provider construction failed"); + }, + plan, + secretApi(events), + { registry: new Map(), cleanupDelayMilliseconds: 1_000 }, + ); + + await assert.rejects( + () => provider.create(), + /provider construction failed/, + ); + assert.deepEqual(events.slice(-2), [ + "secret:delete:secret-2", + "secret:delete:secret-1", + ]); + }); + + it("retains allocation across park/reconnect and cancels timed cleanup", async () => { + vi.useFakeTimers(); + const events: string[] = []; + const registry = new Map(); + const api = secretApi(events); + const first = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + const id = await first.create(); + await first.pause!(id); + + const second = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + await vi.advanceTimersByTimeAsync(500); + await second.reconnect!(id); + await vi.advanceTimersByTimeAsync(1_000); + + assert.equal( + events.some((event) => event.startsWith("secret:delete")), + false, + ); + assert.equal( + second.materializeMcpServers(mcpServers)?.[0].connection.credentials?.[0] + .value, + "dtn_secret_2", + ); + }); + + it("never reconnects concurrently with an already-started timer cleanup", async () => { + vi.useFakeTimers(); + const events: string[] = []; + const registry = new Map(); + const api = secretApi(events); + let cleanupStarted!: () => void; + const cleanupEntered = new Promise((resolve) => { + cleanupStarted = resolve; + }); + let releaseCleanup!: () => void; + const cleanupBlocked = new Promise((resolve) => { + releaseCleanup = resolve; + }); + const first = daytonaWithProcessLocalSecrets( + (attachments): DaytonaProviderLike => ({ + name: "daytona", + async create() { + assert.ok(Object.keys(attachments).length > 0); + return "sandbox-1"; + }, + async pause() { + events.push("sandbox:pause"); + }, + async destroy() { + events.push("sandbox:destroy:start"); + cleanupStarted(); + await cleanupBlocked; + events.push("sandbox:destroy:end"); + }, + }), + plan, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + const id = await first.create(); + await first.pause!(id); + await vi.advanceTimersByTimeAsync(1_000); + await cleanupEntered; + + const second = daytonaWithProcessLocalSecrets( + (): DaytonaProviderLike => ({ + name: "daytona", + async create() { + throw new Error("unused"); + }, + async reconnect() { + events.push("sandbox:reconnect"); + }, + async destroy() { + events.push("sandbox:destroy:second"); + }, + }), + plan, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + const reconnect = second.reconnect!(id); + await Promise.resolve(); + assert.equal( + events.includes("sandbox:reconnect"), + false, + "reconnect waits while timer cleanup owns the lifecycle operation", + ); + + releaseCleanup(); + await assert.rejects( + reconnect, + (error: unknown) => + error instanceof DaytonaReconnectTerminalError && + error.state === "missing-process-local-secret-allocation", + ); + assert.equal(events.includes("sandbox:reconnect"), false); + assert.deepEqual(events.slice(-3), [ + "sandbox:destroy:end", + "secret:delete:secret-2", + "secret:delete:secret-1", + ]); + }); + + it("cleans a parked sandbox and its Secrets slightly after the auto-delete window", async () => { + vi.useFakeTimers(); + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + secretApi(events), + { registry: new Map(), cleanupDelayMilliseconds: 1_000 }, + ); + const id = await provider.create(); + await provider.pause!(id); + await vi.advanceTimersByTimeAsync(999); + assert.equal(events.includes("sandbox:destroy:sandbox-1"), false); + await vi.advanceTimersByTimeAsync(1); + assert.deepEqual(events.slice(-3), [ + "sandbox:destroy:sandbox-1", + "secret:delete:secret-2", + "secret:delete:secret-1", + ]); + }); + + it("deletes and rejects reconnect when the process-local allocation is missing", async () => { + const events: string[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + secretApi(events), + { registry: new Map(), cleanupDelayMilliseconds: 1_000 }, + ); + await assert.rejects( + () => provider.reconnect!("old-sandbox"), + (error: unknown) => + error instanceof DaytonaReconnectTerminalError && + error.state === "missing-process-local-secret-allocation", + ); + assert.deepEqual(events, ["sandbox:destroy:old-sandbox"]); + }); + + it("deletes the old sandbox and Secrets instead of reconnecting rotated credentials", async () => { + const events: string[] = []; + const registry = new Map(); + const api = secretApi(events); + const first = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + plan, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + const id = await first.create(); + const rotated: DaytonaSecretPlan = { + ...plan, + candidates: plan.candidates.map((candidate, index) => + index === 0 + ? { ...candidate, value: "rotated-model-plaintext" } + : candidate, + ), + }; + const second = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + rotated, + api, + { registry, cleanupDelayMilliseconds: 1_000 }, + ); + + await assert.rejects( + () => second.reconnect!(id), + (error: unknown) => + error instanceof DaytonaReconnectTerminalError && + error.state === "process-local-secret-allocation-mismatch", + ); + assert.equal(events.includes("sandbox:reconnect:sandbox-1"), false); + assert.deepEqual(events.slice(-3), [ + "sandbox:destroy:sandbox-1", + "secret:delete:secret-2", + "secret:delete:secret-1", + ]); + }); + + // Zero-candidate plans ARE a production state: buildRunPlan keeps the (empty) plan on every + // flag-on Daytona run and provider.ts wraps unconditionally on plan presence, so a run whose + // credentials are all local_use/direct still flows through this wrapper — creating no Secrets + // but subjecting every reconnect to the create-fingerprint check. These tests pin exactly the + // fingerprints production computes (`daytonaCreateFingerprint` over `buildDaytonaCreate`). + describe("zero-candidate plan (flag-on run with no opaque credentials)", () => { + const emptyPlan: DaytonaSecretPlan = { environment: {}, candidates: [] }; + const daytonaConfig = parseRunnerConfig({ + AGENTA_RUNNER_DAYTONA_API_KEY: "test-key", + }).daytona; + const fingerprintFor = ( + piExtEnv: Record, + environment: Record, + ) => + daytonaCreateFingerprint({ + create: buildDaytonaCreate( + daytonaConfig, + piExtEnv, + environment, + undefined, + ), + secretPlan: emptyPlan, + }); + + it("creates no Secrets and attaches nothing, end to end", async () => { + const events: string[] = []; + const attachments: any[] = []; + const provider = daytonaWithProcessLocalSecrets( + providerFactory(events, attachments), + emptyPlan, + secretApi(events), + { + registry: new Map(), + cleanupDelayMilliseconds: 1_000, + createFingerprint: fingerprintFor({}, { AWS_PROFILE: "profile-a" }), + }, + ); + + const id = await provider.create(); + await provider.destroy(id); + + assert.equal( + events.some((event) => event.startsWith("secret:")), + false, + "no Secret is ever created or deleted for a zero-candidate plan", + ); + assert.deepEqual( + attachments[0], + {}, + "sandbox create gets no attachments", + ); + // No http_mcp candidates: MCP servers pass through untouched. + assert.deepEqual(provider.materializeMcpServers(undefined), undefined); + }); + + it("reconnects a parked sandbox when the create fingerprint is unchanged", async () => { + const events: string[] = []; + const registry = new Map(); + const fingerprint = fingerprintFor({}, { AWS_PROFILE: "profile-a" }); + const first = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + emptyPlan, + secretApi(events), + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: fingerprint, + }, + ); + const id = await first.create(); + const second = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + emptyPlan, + secretApi(events), + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: fingerprint, + }, + ); + + await second.reconnect!(id); + + assert.equal(events.includes("sandbox:reconnect:sandbox-1"), true); + assert.equal(events.includes("sandbox:destroy:sandbox-1"), false); + assert.equal( + events.some((event) => event.startsWith("secret:")), + false, + ); + }); + + type FingerprintInputs = [Record, Record]; + const rotations: Array<{ + name: string; + before: FingerprintInputs; + after: FingerprintInputs; + }> = [ + { + name: "local_use credential rotation", + before: [{}, { AWS_REGION: "us-east-1", AWS_PROFILE: "profile-a" }], + after: [{}, { AWS_REGION: "us-east-1", AWS_PROFILE: "profile-b" }], + }, + { + name: "custom endpoint override rotation", + before: [ + { + AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE: + '{"baseUrl":"https://a.test"}', + }, + { AWS_PROFILE: "profile-a" }, + ], + after: [ + { + AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE: + '{"baseUrl":"https://b.test"}', + }, + { AWS_PROFILE: "profile-a" }, + ], + }, + ]; + for (const { name, before, after } of rotations) { + it(`deletes instead of reconnecting after ${name}`, async () => { + const events: string[] = []; + const registry = new Map(); + const first = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + emptyPlan, + secretApi(events), + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: fingerprintFor(...before), + }, + ); + const id = await first.create(); + const second = daytonaWithProcessLocalSecrets( + providerFactory(events, []), + emptyPlan, + secretApi(events), + { + registry, + cleanupDelayMilliseconds: 1_000, + createFingerprint: fingerprintFor(...after), + }, + ); + + await assert.rejects( + () => second.reconnect!(id), + (error: unknown) => + error instanceof DaytonaReconnectTerminalError && + error.state === "process-local-secret-allocation-mismatch", + ); + assert.equal(events.includes("sandbox:reconnect:sandbox-1"), false); + assert.equal(events.at(-1), "sandbox:destroy:sandbox-1"); + assert.equal( + events.some((event) => event.startsWith("secret:")), + false, + ); + }); + } + }); +}); diff --git a/services/runner/tests/unit/daytona-secrets.test.ts b/services/runner/tests/unit/daytona-secrets.test.ts new file mode 100644 index 0000000000..5062f4308b --- /dev/null +++ b/services/runner/tests/unit/daytona-secrets.test.ts @@ -0,0 +1,195 @@ +import assert from "node:assert/strict"; +import { DaytonaNotFoundError } from "@daytonaio/sdk"; +import { describe, it } from "vitest"; + +import type { DaytonaSecretPlan } from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; +import { + allocateDaytonaSecrets, + DAYTONA_SECRETS_PERMISSION_MESSAGE, + deleteDaytonaSecrets, + isDaytonaNotFound, + isDaytonaPermissionDenied, + 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 not-found (typed or 404-shaped) as idempotent success", async () => { + const deletes: string[] = []; + const api: DaytonaSecretApi = { + async create() { + throw new Error("unused"); + }, + async delete(id) { + deletes.push(id); + // Both absence shapes the shared predicate recognizes: the SDK's typed error + // (no statusCode needed) and a bare 404-shaped object. + if (id === "id-2") throw { statusCode: 404 }; + if (id === "id-1") throw new DaytonaNotFoundError("secret not found"); + }, + }; + await deleteDaytonaSecrets( + { + 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"]); + }); + + it("shares one not-found predicate across Secret and sandbox cleanup", () => { + assert.equal(isDaytonaNotFound(new DaytonaNotFoundError("gone")), true); + assert.equal(isDaytonaNotFound({ statusCode: 404 }), true); + assert.equal(isDaytonaNotFound({ statusCode: 500 }), false); + assert.equal(isDaytonaNotFound(new Error("gone")), false); + }); + + it("recognizes a permission refusal by status code or by message", () => { + assert.equal(isDaytonaPermissionDenied({ statusCode: 403 }), true); + assert.equal(isDaytonaPermissionDenied({ statusCode: 401 }), true); + assert.equal( + isDaytonaPermissionDenied(new Error("Forbidden: missing permission")), + true, + ); + // Not a permission problem: a missing record, a server fault, a plain failure. + assert.equal(isDaytonaPermissionDenied({ statusCode: 404 }), false); + assert.equal(isDaytonaPermissionDenied({ statusCode: 500 }), false); + assert.equal(isDaytonaPermissionDenied(new Error("network reset")), false); + assert.equal(isDaytonaPermissionDenied(undefined), false); + }); + + it("explains an under-permissioned API key instead of surfacing a bare 403", async () => { + // The whole point: a key that can create sandboxes but not manage Secrets fails EVERY run + // with a hideable credential, and the raw provider message never mentions the flag that + // caused it. This is the one failure an operator hits on first enabling the feature. + const api: DaytonaSecretApi = { + async create() { + throw { statusCode: 403, message: "Forbidden" }; + }, + async delete() {}, + }; + + await assert.rejects( + allocateDaytonaSecrets(plan, api), + (error: Error) => { + assert.equal(error.message, DAYTONA_SECRETS_PERMISSION_MESSAGE); + assert.match(error.message, /AGENTA_RUNNER_DAYTONA_API_KEY/); + assert.match(error.message, /AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS/); + // The provider's own error is kept as the cause so the logs still have the detail. + assert.equal((error.cause as { statusCode: number }).statusCode, 403); + return true; + }, + ); + }); + + it("leaves a non-permission create failure with its original error", async () => { + const api: DaytonaSecretApi = { + async create() { + throw new Error("daytona is having a bad day"); + }, + async delete() {}, + }; + await assert.rejects(allocateDaytonaSecrets(plan, api), { + message: "daytona is having a bad day", + }); + }); +}); diff --git a/services/runner/tests/unit/extension-tools.test.ts b/services/runner/tests/unit/extension-tools.test.ts index 339ad5a6de..cca77c62f0 100644 --- a/services/runner/tests/unit/extension-tools.test.ts +++ b/services/runner/tests/unit/extension-tools.test.ts @@ -26,6 +26,7 @@ import factory, { readOtlpAuthFile, replaceActiveBuiltinTools, } from "../../src/extensions/agenta.ts"; +import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../src/extensions/model-provider-override.ts"; const TOOL_ENV = [ "AGENTA_AGENT_TOOLS_PUBLIC_SPECS", @@ -37,6 +38,7 @@ const TOOL_ENV = [ "AGENTA_AGENT_CONTENT_CAPTURE_ENABLED", "AGENTA_AGENT_BUILTIN_ACTIVATION", "AGENTA_AGENT_BUILTIN_GATING", + PI_MODEL_PROVIDER_OVERRIDE_ENV, ]; /** A fake extension UI context whose `confirm` records its calls and returns a scripted answer. */ @@ -59,14 +61,19 @@ function fakeDialogCtx(answer: boolean | (() => Promise)) { function fakePi(opts: { activeTools?: string[]; allTools?: string[] } = {}) { const registered: any[] = []; + const registeredProviders: Array<{ name: string; config: unknown }> = []; const handlers: Record = {}; let activeTools = opts.activeTools ?? []; return { registered, + registeredProviders, handlers, registerTool(spec: any) { registered.push(spec); }, + registerProvider(name: string, config: unknown) { + registeredProviders.push({ name, config }); + }, on(event: string, handler: any) { (handlers[event] ??= []).push(handler); }, @@ -88,6 +95,44 @@ function clearEnv() { afterEach(clearEnv); +describe("agenta extension model provider override", () => { + it("overrides the built-in provider during extension initialization", () => { + clearEnv(); + process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = JSON.stringify({ + provider: "anthropic", + baseUrl: "https://proxy.example.test/anthropic", + }); + const pi = fakePi(); + + factory(pi as any); + + assert.deepEqual(pi.registeredProviders, [ + { + name: "anthropic", + config: { baseUrl: "https://proxy.example.test/anthropic" }, + }, + ]); + assert.equal(pi.registered.length, 0); + assert.deepEqual(pi.handlers, {}); + }); + + it("rejects malformed public override config before registration", () => { + clearEnv(); + process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = JSON.stringify({ + provider: "anthropic", + baseUrl: "http://proxy.example.test", + }); + const pi = fakePi(); + + assert.throws(() => factory(pi as any), /must be an HTTPS URL/); + assert.deepEqual(pi.registeredProviders, []); + + process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = ""; + assert.throws(() => factory(pi as any), /must be valid JSON/); + assert.deepEqual(pi.registeredProviders, []); + }); +}); + describe("agenta extension tool registration", () => { it("registers one tool per public spec, schema passed through", () => { clearEnv(); diff --git a/services/runner/tests/unit/mcp-servers.test.ts b/services/runner/tests/unit/mcp-servers.test.ts index 5b6345e49b..28bb4dc51d 100644 --- a/services/runner/tests/unit/mcp-servers.test.ts +++ b/services/runner/tests/unit/mcp-servers.test.ts @@ -1,11 +1,20 @@ -/** External HTTP MCP conversion and SSRF policy. */ +/** External HTTP MCP conversion, typed credential materialization, and SSRF policy. */ import { afterEach, beforeEach, describe, it } from "vitest"; import assert from "node:assert/strict"; import { toAcpMcpServers } from "../../src/engines/sandbox_agent.ts"; -import type { McpServerHttp } from "../../src/engines/sandbox_agent/mcp.ts"; +import { + validateUserMcpServers, + type McpServerHttp, +} from "../../src/engines/sandbox_agent/mcp.ts"; import type { McpServerConfig } from "../../src/protocol.ts"; +const credential = (name = "Authorization", value = "Bearer secret") => ({ + binding: { kind: "header" as const, name }, + value, + usage: "opaque_http" as const, +}); + const http = ( url: string, headers: Record = { Authorization: "Bearer secret" }, @@ -42,12 +51,141 @@ describe("toAcpMcpServers", () => { ); }); + it("combines public headers and secret credential bindings only at the ACP boundary", async () => { + const out = await toAcpMcpServers([ + { + name: "linear", + connection: { + type: "http", + url: "https://93.184.216.34:8443/sse", + headers: { "X-Client": "agenta" }, + credentials: [ + credential("Authorization", "Bearer secret-token-value"), + ], + }, + policy: { tools: { mode: "all" } }, + }, + ]); + const server = out[0] as McpServerHttp; + assert.equal( + server.url, + "https://93.184.216.34:8443/sse", + "exact URL and port preserved", + ); + assert.deepEqual(server.headers, [ + { name: "X-Client", value: "agenta" }, + { name: "Authorization", value: "Bearer secret-token-value" }, + ]); + }); + it("delivers an http server with no secrets as an empty header list", async () => { const out = await toAcpMcpServers(http("https://93.184.216.34/sse", {})); const server = out[0] as McpServerHttp; assert.equal(server.type, "http"); assert.deepEqual(server.headers, [], "no headers stays empty"); }); + + it("skips a url-less server and still delivers the valid one", async () => { + const out = await toAcpMcpServers([ + { + name: "remote", + connection: { + type: "http", + url: "https://93.184.216.34/mcp", + credentials: [credential("X-Api-Key", "k-secret")], + }, + policy: { tools: { mode: "all" } }, + }, + // A url-less server was never deliverable and is skipped, not fatal. + { + name: "no-url", + connection: { type: "http" }, + policy: { tools: { mode: "all" } }, + } as unknown as McpServerConfig, + ]); + assert.deepEqual( + out.map((server) => server.name), + ["remote"], + ); + }); +}); + +describe("validateUserMcpServers structural validation", () => { + const server = ( + connection: Partial, + ): McpServerConfig[] => [ + { + name: "s", + connection: { + type: "http", + url: "https://93.184.216.34", + ...connection, + }, + policy: { tools: { mode: "all" } }, + }, + ]; + + it("requires a url", async () => { + await assert.rejects( + () => + validateUserMcpServers([ + { + name: "no-url", + connection: { type: "http" }, + policy: { tools: { mode: "all" } }, + } as unknown as McpServerConfig, + ]), + /requires url/, + ); + }); + + it("rejects empty or malformed credentials and headers", async () => { + const cases: McpServerConfig[][] = [ + server({ headers: { "": "x" } }), + server({ headers: { X: "" } }), + server({ credentials: [credential("", "secret")] }), + server({ credentials: [credential("X", "")] }), + server({ + credentials: [ + { ...credential("X", "secret"), usage: "environment" as never }, + ], + }), + server({ + credentials: [ + { + ...credential("X", "secret"), + binding: { kind: "environment" as never, name: "X" }, + }, + ], + }), + ]; + for (const servers of cases) + await assert.rejects(() => validateUserMcpServers(servers)); + }); + + it("rejects duplicate public and secret bindings case-insensitively", async () => { + await assert.rejects( + () => + validateUserMcpServers( + server({ + headers: { Authorization: "public" }, + credentials: [credential("authorization", "secret")], + }), + ), + /duplicate HTTP MCP header binding/, + ); + }); + + it("accepts a well-formed credentialed server", async () => { + await assert.doesNotReject(() => + validateUserMcpServers( + server({ + headers: { "X-Client": "agenta" }, + credentials: [credential()], + }), + ), + ); + }); }); describe("toAcpMcpServers SSRF guard (http url scheme/host)", () => { @@ -122,6 +260,24 @@ describe("toAcpMcpServers SSRF guard (http url scheme/host)", () => { assert.equal((out[0] as McpServerHttp).url, "https://93.184.216.34/sse"); }); + it("guards a credentialed server the same way (typed credentials ride headers)", async () => { + await assert.rejects( + () => + toAcpMcpServers([ + { + name: "s", + connection: { + type: "http", + url: "https://169.254.169.254/mcp", + credentials: [credential()], + }, + policy: { tools: { mode: "all" } }, + }, + ]), + /internal\/metadata host/, + ); + }); + it("allowlist opts a host out of the https + internal-host checks", async () => { process.env.AGENTA_AGENT_MCPS_HOST_ALLOWLIST = "localhost,10.0.0.5"; // http://localhost is normally rejected twice over (non-https + internal); allowlisted -> ok. diff --git a/services/runner/tests/unit/pi-builtin-activation.test.ts b/services/runner/tests/unit/pi-builtin-activation.test.ts index 5ad1f4c1eb..0ff0b1a490 100644 --- a/services/runner/tests/unit/pi-builtin-activation.test.ts +++ b/services/runner/tests/unit/pi-builtin-activation.test.ts @@ -49,8 +49,8 @@ describe("the deprecated `tools` field does not influence the plan", () => { ]) { it(`produces the same gating decision for tools=${JSON.stringify(tools)}`, () => { const plan = planFor({ permissions, tools } as Partial); - assert.equal(plan.builtinGatingActive, false); - assert.equal(plan.useToolRelay, false); + assert.equal(plan.tools.builtinGatingActive, false); + assert.equal(plan.tools.useToolRelay, false); }); } }); @@ -58,7 +58,7 @@ describe("the deprecated `tools` field does not influence the plan", () => { describe("builtin gating follows the permission policy alone", () => { it("stays off under a blanket allow with no builtin rules", () => { assert.equal( - planFor({ permissions: { default: "allow", rules: [] } }) + planFor({ permissions: { default: "allow", rules: [] } }).tools .builtinGatingActive, false, ); @@ -66,7 +66,7 @@ describe("builtin gating follows the permission policy alone", () => { it("turns on under allow_reads", () => { assert.equal( - planFor({ permissions: { default: "allow_reads", rules: [] } }) + planFor({ permissions: { default: "allow_reads", rules: [] } }).tools .builtinGatingActive, true, ); @@ -79,7 +79,7 @@ describe("builtin gating follows the permission policy alone", () => { default: "allow", rules: [{ pattern: "bash(npm:*)", permission: "deny" }], }, - }).builtinGatingActive, + }).tools.builtinGatingActive, true, ); }); diff --git a/services/runner/tests/unit/redaction-sinks.test.ts b/services/runner/tests/unit/redaction-sinks.test.ts index 191d11002a..5d1f0ce991 100644 --- a/services/runner/tests/unit/redaction-sinks.test.ts +++ b/services/runner/tests/unit/redaction-sinks.test.ts @@ -3,10 +3,11 @@ * durable/exported sinks — the persisted transcript (WP1.4) and the exported OTel spans (WP1.5), * seeded from the per-run deny-set (WP1.1). * - * The load-bearing case is the seed SOURCE. A run's provider keys ride `secrets` on the wire and - * are applied per run (`buildDaemonEnv`); they are never in the sidecar's own process env. A - * redactor seeded only from process env would therefore look wired and catch nothing that matters, - * so these tests plant a key that exists ONLY in the request and assert it is scrubbed. + * The load-bearing case is the seed SOURCE. A run's provider keys ride the typed + * `modelConnection.credentials` (and user MCP `connection.credentials`) on the wire and are + * applied per run (`buildDaemonEnv`); they are never in the sidecar's own process env. A + * redactor seeded only from process env would therefore look wired and catch nothing that + * matters, so these tests plant a key that exists ONLY in the request and assert it is scrubbed. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"; @@ -17,14 +18,45 @@ import { seedForRun } from "../../src/redaction.ts"; import { buildPersistingEmitter } from "../../src/sessions/persist.ts"; import { createSandboxAgentOtel } from "../../src/tracing/otel.ts"; -/** A per-run provider key: present in the request's `secrets`, never in `process.env`. */ +/** A per-run provider key: a typed `modelConnection` credential, never in `process.env`. */ const PER_RUN_KEY = "sk-per-run-fake-key-DO-NOT-USE-a1b2c3d4e5f6"; +/** A typed user HTTP-MCP header credential, also request-only material. */ +const MCP_HEADER_KEY = "Bearer mcp-per-run-fake-DO-NOT-USE-f6e5d4"; /** The invoke caller's credential, which rides the OTLP auth header. */ const RUN_CREDENTIAL = "ApiKey ag-run-cred-9f8e7d6c5b4a"; -/** A request carrying this run's resolved provider key + run credential. */ +/** A request carrying this run's resolved typed credentials + run credential. */ const runRequest = { - secrets: { OPENAI_API_KEY: PER_RUN_KEY }, + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: PER_RUN_KEY, + usage: "opaque_http", + }, + ], + }, + mcpServers: [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/sse", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value: MCP_HEADER_KEY, + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, + ], telemetry: { exporters: { otlp: { headers: { authorization: RUN_CREDENTIAL } } }, }, @@ -101,6 +133,13 @@ describe("seedForRun (WP1.1 — the deny-set source)", () => { redactor.redactString(`called back with ${RUN_CREDENTIAL}`, "test"), ).not.toContain("ag-run-cred-9f8e7d6c5b4a"); }); + + it("seeds typed user HTTP-MCP header credentials", () => { + const redactor = seedForRun(runRequest); + expect( + redactor.redactString(`header was ${MCP_HEADER_KEY}`, "test"), + ).not.toContain(MCP_HEADER_KEY); + }); }); describe("persisted transcript sink (WP1.4)", () => { @@ -306,7 +345,9 @@ describe("exported span sink (WP1.5)", () => { emitSpans: false, // span-less: only recordError's standalone-span path emits here endpoint: "http://127.0.0.1:1/v1/traces", traceparent: sharedTraceparent, - redactor: seedForRun({ secrets: { A_KEY: SECRET_A } }), + redactor: seedForRun({ + modelConnection: { credentials: [{ value: SECRET_A }] }, + }), }); const runB = createSandboxAgentOtel({ harness: "claude", @@ -314,7 +355,9 @@ describe("exported span sink (WP1.5)", () => { emitSpans: false, endpoint: "http://127.0.0.1:1/v1/traces", traceparent: sharedTraceparent, - redactor: seedForRun({ secrets: { B_KEY: SECRET_B } }), + redactor: seedForRun({ + modelConnection: { credentials: [{ value: SECRET_B }] }, + }), }); runA.start({ prompt: "a" }); diff --git a/services/runner/tests/unit/sandbox-agent-codex-assets.test.ts b/services/runner/tests/unit/sandbox-agent-codex-assets.test.ts index 6d364062cc..660f0be235 100644 --- a/services/runner/tests/unit/sandbox-agent-codex-assets.test.ts +++ b/services/runner/tests/unit/sandbox-agent-codex-assets.test.ts @@ -43,9 +43,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: false, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -75,9 +75,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "claude", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: false, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -95,9 +95,9 @@ describe("Codex managed-credential assets", () => { }; const plan = { acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, isDaytona: false, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -119,9 +119,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: false, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -135,9 +135,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: true, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -154,9 +154,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: false, - cwd, + workspace: { cwd }, secrets: { OPENAI_API_KEY: "sk-live" }, legacyHarnessApiKeyVar: "OPENAI_API_KEY", } as any; @@ -171,35 +171,35 @@ describe("Codex managed-credential assets", () => { assert.equal( isManagedCodexRun({ acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, } as any), true, ); assert.equal( isManagedCodexRun({ acpAgent: "codex", - credentialMode: "none", + credentials: { credentialMode: "none" }, } as any), true, ); assert.equal( isManagedCodexRun({ acpAgent: "codex", - credentialMode: undefined, + credentials: { credentialMode: undefined }, } as any), true, ); assert.equal( isManagedCodexRun({ acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, } as any), false, ); assert.equal( isManagedCodexRun({ acpAgent: "claude", - credentialMode: "env", + credentials: { credentialMode: "env" }, } as any), false, ); @@ -209,21 +209,21 @@ describe("Codex managed-credential assets", () => { assert.equal( isSubscriptionCodexRun({ acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, } as any), true, ); assert.equal( isSubscriptionCodexRun({ acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, } as any), false, ); assert.equal( isSubscriptionCodexRun({ acpAgent: "claude", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, } as any), false, ); @@ -251,9 +251,9 @@ describe("Codex managed-credential assets", () => { const subPlan = () => ({ acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, isDaytona: false, - cwd, + workspace: { cwd }, }) as any; it("symlinks /.codex/auth.json to the mount's auth.json and links nothing else", () => { @@ -288,18 +288,23 @@ describe("Codex managed-credential assets", () => { it("is a no-op for a managed run, a Daytona subscription run, and a non-codex run", () => { const plans = [ - { acpAgent: "codex", credentialMode: "env", isDaytona: false, cwd }, { acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "env" }, + isDaytona: false, + workspace: { cwd }, + }, + { + acpAgent: "codex", + credentials: { credentialMode: "runtime_provided" }, isDaytona: true, - cwd, + workspace: { cwd }, }, { acpAgent: "claude", - credentialMode: "runtime_provided", + credentials: { credentialMode: "runtime_provided" }, isDaytona: false, - cwd, + workspace: { cwd }, }, ] as any[]; for (const plan of plans) { @@ -314,9 +319,9 @@ describe("Codex managed-credential assets", () => { const env: Record = {}; const plan = { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: true, - cwd, + workspace: { cwd }, } as any; configureDaytonaCodexEnv(plan, env); @@ -332,15 +337,30 @@ describe("Codex managed-credential assets", () => { it("configureDaytonaCodexEnv is a no-op for local, subscription, and non-codex Daytona runs", () => { const plans = [ - { acpAgent: "codex", credentialMode: "env", isDaytona: false, cwd }, { acpAgent: "codex", - credentialMode: "runtime_provided", + credentials: { credentialMode: "env" }, + isDaytona: false, + workspace: { cwd }, + }, + { + acpAgent: "codex", + credentials: { credentialMode: "runtime_provided" }, + isDaytona: true, + workspace: { cwd }, + }, + { + acpAgent: "claude", + credentials: { credentialMode: "env" }, + isDaytona: true, + workspace: { cwd }, + }, + { + acpAgent: "pi", + credentials: { credentialMode: "env" }, isDaytona: true, - cwd, + workspace: { cwd }, }, - { acpAgent: "claude", credentialMode: "env", isDaytona: true, cwd }, - { acpAgent: "pi", credentialMode: "env", isDaytona: true, cwd }, ] as any[]; for (const plan of plans) { const env: Record = {}; @@ -357,9 +377,9 @@ describe("Codex managed-credential assets", () => { configureDaytonaCodexEnv( { acpAgent: "codex", - credentialMode: "env", + credentials: { credentialMode: "env" }, isDaytona: true, - cwd, + workspace: { cwd }, } as any, env, ); diff --git a/services/runner/tests/unit/sandbox-agent-daemon.test.ts b/services/runner/tests/unit/sandbox-agent-daemon.test.ts index d44856072e..9e67a4cbd0 100644 --- a/services/runner/tests/unit/sandbox-agent-daemon.test.ts +++ b/services/runner/tests/unit/sandbox-agent-daemon.test.ts @@ -116,7 +116,7 @@ describe("buildDaemonEnv", () => { process.env.COMPOSIO_API_KEY = "composio"; process.env.DAYTONA_API_KEY = "daytona"; - // Default (clearProviderEnv: false) = a runtime_provided / un-migrated run: keep the + // Default (clearProviderEnv: false) = a runtime_provided / no-modelConnection run: keep the // sidecar's own provider/auth keys so the harness login still works. const env = buildDaemonEnv("claude"); diff --git a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts index c1cffcc30a..5d4ee4bc5b 100644 --- a/services/runner/tests/unit/sandbox-agent-orchestration.test.ts +++ b/services/runner/tests/unit/sandbox-agent-orchestration.test.ts @@ -101,6 +101,7 @@ function fakeHarness(options: FakeOptions = {}) { runFinished: 0, runFlushed: 0, recordedErrors: [] as Array<{ message: string; provider?: string }>, + handledUpdates: [] as unknown[], }; const events: AgentEvent[] = []; const logs: string[] = []; @@ -193,7 +194,9 @@ function fakeHarness(options: FakeOptions = {}) { start(input: any) { calls.runStart = input; }, - handleUpdate(_update: any) {}, + handleUpdate(update: any) { + calls.handledUpdates.push(update); + }, emitEvent(event: AgentEvent) { events.push(event); }, @@ -344,6 +347,11 @@ describe("PendingApprovalPauseController", () => { }); describe("runSandboxAgent orchestration", () => { + // NOTE: in-band redaction of the LIVE event stream / result / trace-start input was a + // daytona-secret-materialization concept that was not adopted. Redaction happens at the + // durable/exported sinks (persisted transcript + exported spans; see redaction-sinks.test.ts), + // seeded per run from the typed model and MCP credentials. + it("returns a successful one-shot result and cleans up acquired resources", async () => { const { calls, deps } = fakeHarness(); @@ -735,6 +743,8 @@ describe("runSandboxAgent orchestration", () => { // endpoint. The runner must pass the exact `/` so it always wins. const { calls, deps } = fakeHarness(); deps.prepareDaytonaPiAssets = (async () => true) as any; + // The opaque vault key on a Daytona run requires the process-local Secret gate. + process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = "process_local"; const result = await runSandboxAgent( { @@ -742,12 +752,20 @@ describe("runSandboxAgent orchestration", () => { sandbox: "daytona", messages: [{ role: "user", content: "hello" }], model: "gpt-4o", - provider: "openai", - deployment: "custom", connection: { mode: "agenta", slug: "my-conn" }, - endpoint: { baseUrl: "https://proxy.test/v1" }, - credentialMode: "env", - secrets: { OPENAI_API_KEY: "sk-vault-xyz" }, + modelConnection: { + provider: "openai", + deployment: "custom", + endpoint: { baseUrl: "https://proxy.test/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "sk-vault-xyz", + usage: "opaque_http", + }, + ], + }, }, undefined, undefined, @@ -818,7 +836,7 @@ describe("runSandboxAgent orchestration", () => { }); let agentDirSkillCount = -1; deps.prepareDaytonaPiAssets = (async ({ plan }: any) => { - agentDirSkillCount = plan.skillDirs.length; + agentDirSkillCount = plan.workspace.skillDirs.length; return true; }) as any; @@ -922,7 +940,7 @@ describe("runSandboxAgent orchestration", () => { (calls.providerArgs[1] as Record).AGENTA_AGENT_MOUNT_DIR, undefined, ); - assert.equal(calls.workspacePlan.appendSystemPrompt, undefined); + assert.equal(calls.workspacePlan.prompt.appendSystemPrompt, undefined); assert.equal(existsSync(`${cwd}-agent`), false); rmSync(cwd, { recursive: true, force: true }); }); @@ -1728,9 +1746,19 @@ describe("runSandboxAgent orchestration", () => { { harness: "claude", messages: [{ role: "user", content: "hello" }], - credentialMode: "env", - secrets: { ANTHROPIC_API_KEY: "resolved" }, - endpoint: { baseUrl: "https://claude-gw.example/v1" }, + modelConnection: { + provider: "anthropic", + deployment: "custom", + endpoint: { baseUrl: "https://claude-gw.example/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "resolved", + usage: "opaque_http", + }, + ], + }, } as AgentRunRequest, undefined, undefined, @@ -1807,10 +1835,22 @@ describe("runSandboxAgent orchestration", () => { harness: "claude", messages: [{ role: "user", content: "hello" }], model: "anthropic.claude-x", - deployment: "bedrock", - credentialMode: "env", - secrets: { AWS_ACCESS_KEY_ID: "AKIA" }, - endpoint: { region: "us-east-1" }, + modelConnection: { + provider: "anthropic", + deployment: "bedrock", + endpoint: { + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + }, + credentialMode: "env", + environment: { AWS_REGION: "us-east-1" }, + credentials: [ + { + binding: { kind: "environment", name: "AWS_ACCESS_KEY_ID" }, + value: "AKIA", + usage: "local_use", + }, + ], + }, } as AgentRunRequest, undefined, undefined, @@ -1838,11 +1878,28 @@ describe("runSandboxAgent orchestration", () => { harness: "claude", messages: [{ role: "user", content: "hello" }], model: "claude-sonnet-4", - deployment: "vertex_ai", - credentialMode: "env", - secrets: { - GOOGLE_CLOUD_PROJECT: "proj", - GOOGLE_CLOUD_LOCATION: "us-central1", + modelConnection: { + provider: "anthropic", + deployment: "vertex_ai", + endpoint: { + baseUrl: "https://us-central1-aiplatform.googleapis.com", + region: "us-central1", + }, + credentialMode: "env", + environment: { + GOOGLE_CLOUD_PROJECT: "proj", + GOOGLE_CLOUD_LOCATION: "us-central1", + }, + credentials: [ + { + binding: { + kind: "environment", + name: "GOOGLE_APPLICATION_CREDENTIALS", + }, + value: "/tmp/adc.json", + usage: "local_use", + }, + ], }, } as AgentRunRequest, undefined, @@ -1870,7 +1927,12 @@ describe("runSandboxAgent orchestration", () => { { harness: "claude", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "anthropic", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, } as AgentRunRequest, undefined, undefined, @@ -1902,9 +1964,12 @@ describe("runSandboxAgent orchestration", () => { { harness: "claude", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", - provider: "anthropic", - deployment: "bedrock", + modelConnection: { + provider: "anthropic", + deployment: "bedrock", + credentialMode: "runtime_provided", + credentials: [], + }, } as AgentRunRequest, undefined, undefined, @@ -1920,6 +1985,29 @@ describe("runSandboxAgent orchestration", () => { assert.equal(calls.daemonOptions?.provider, "anthropic"); assert.equal(calls.daemonOptions?.deployment, "bedrock"); }); + + it("clears inherited provider env when the resolved credential mode is none", async () => { + const { calls, deps } = fakeHarness(); + const result = await runSandboxAgent( + { + harness: "claude", + messages: [{ role: "user", content: "hello" }], + modelConnection: { + provider: "anthropic", + deployment: "direct", + credentialMode: "none", + credentials: [], + }, + }, + undefined, + undefined, + deps, + ); + assert.equal(result.ok, true); + // "none" means the run declared it uses NO ambient credential: the daemon must not inherit + // the sidecar's own provider keys either (only runtime_provided keeps them). + assert.equal(calls.daemonOptions?.clearProviderEnv, true); + }); }); // These exercise the engine's default ApprovalResponder by dropping the `responderFactory` @@ -2186,7 +2274,10 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { sessionUpdate: "tool_call", toolCallId: "tool-late", title: "request_connection", - rawInput: { integration: "slack" }, + rawInput: { + integration: "slack", + token: "marker-late-secret-9a21", + }, }, }, }, @@ -2201,6 +2292,19 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { harness: "claude", permissions: { default: "ask" }, messages: [{ role: "user", content: "commit and connect" }], + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "marker-late-secret-9a21", + usage: "opaque_http", + }, + ], + }, } as AgentRunRequest, undefined, undefined, @@ -2241,21 +2345,23 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { }, }, }, + ], + emitPermission: true, + permissionToolCallId: "tool-a", + permissionToolName: "commit_revision", + permissionRawInput: { revision: "r1" }, + postPermissionEvents: [ { payload: { update: { sessionUpdate: "tool_call", toolCallId: "tool-b", title: "create_subscription", - rawInput: { plan: "pro" }, + rawInput: { plan: "pro", token: "marker-live-secret-42bd" }, }, }, }, ], - emitPermission: true, - permissionToolCallId: "tool-a", - permissionToolName: "commit_revision", - permissionRawInput: { revision: "r1" }, hangPrompt: true, }); delete deps.responderFactory; @@ -2266,6 +2372,19 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { harness: "claude", permissions: { default: "ask" }, messages: [{ role: "user", content: "commit and subscribe" }], + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "marker-live-secret-42bd", + usage: "opaque_http", + }, + ], + }, } as AgentRunRequest, (event) => emitted.push(event), undefined, @@ -2277,22 +2396,49 @@ describe("runSandboxAgent default ApprovalResponder wiring", () => { const interactionIndex = emitted.findIndex( (event) => event.type === "interaction_request", ); + const siblingCallIndex = emitted.findIndex( + (event) => event.type === "tool_call" && (event as any).id === "tool-b", + ); const siblingResultIndex = emitted.findIndex( (event) => event.type === "tool_result" && (event as any).id === "tool-b", ); const doneIndex = emitted.findIndex((event) => event.type === "done"); assert.notEqual(interactionIndex, -1, "approval request is emitted"); + assert.notEqual(siblingCallIndex, -1, "late sibling call is emitted"); assert.notEqual(siblingResultIndex, -1, "sibling result is emitted"); assert.notEqual(doneIndex, -1, "done is emitted"); assert.ok( - interactionIndex < siblingResultIndex, - "approval request is emitted before the teardown sweep runs", + interactionIndex < siblingCallIndex && + siblingCallIndex < siblingResultIndex, + "the queued late call is emitted and settled after the approval request", ); assert.ok( siblingResultIndex < doneIndex, "sibling result reaches the live sink before turn finish", ); + assert.equal( + emitted.filter( + (event) => + event.type === "tool_result" && (event as any).id === "tool-b", + ).length, + 1, + "the late sibling is settled exactly once", + ); + assert.equal( + emitted.some( + (event) => + event.type === "tool_result" && (event as any).id === "tool-a", + ), + false, + "the gated call remains open for human approval", + ); + await flushPromises(); + assert.equal( + emitted.at(-1)?.type, + "done", + "done remains terminal after another event-loop turn", + ); }); it("drops teardown tool updates after a Pi approval pause while keeping other ids", async () => { diff --git a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts index 24106b9a10..264f2ae8b9 100644 --- a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts +++ b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts @@ -19,6 +19,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentRunRequest } from "../../src/protocol.ts"; +import { PI_MODEL_PROVIDER_OVERRIDE_ENV } from "../../src/extensions/model-provider-override.ts"; import { buildPiExtensionEnv, configurePiSkillSnapshot, @@ -55,7 +56,7 @@ describe("Pi session workspace", () => { const env: Record = {}; const sessionDir = configurePiSessionWorkspace( - { isPi: true, cwd: "/work/session-1" }, + { isPi: true, workspace: { cwd: "/work/session-1" } }, env, ); @@ -70,7 +71,10 @@ describe("Pi session workspace", () => { const env: Record = {}; assert.equal( - configurePiSessionWorkspace({ isPi: false, cwd: "/work/session-1" }, env), + configurePiSessionWorkspace( + { isPi: false, workspace: { cwd: "/work/session-1" } }, + env, + ), undefined, ); assert.equal(env.PI_CODING_AGENT_SESSION_DIR, undefined); @@ -91,6 +95,76 @@ afterEach(() => { }); describe("buildPiExtensionEnv", () => { + it("carries only public provider endpoint config for Pi", () => { + const request = { + modelConnection: { + provider: "anthropic", + deployment: "claude-sonnet-4-5", + endpoint: { + baseUrl: "https://proxy.example.test/anthropic", + headers: { Authorization: "Bearer do-not-expose" }, + }, + credentialMode: "env", + environment: { PUBLIC_HINT: "not-needed-by-extension" }, + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "secret-model-key", + usage: "local_use", + }, + ], + }, + } as AgentRunRequest; + + const env = buildPiExtensionEnv(request, false); + + assert.deepEqual(JSON.parse(env[PI_MODEL_PROVIDER_OVERRIDE_ENV]), { + provider: "anthropic", + baseUrl: "https://proxy.example.test/anthropic", + }); + assert.equal(JSON.stringify(env).includes("do-not-expose"), false); + assert.equal(JSON.stringify(env).includes("secret-model-key"), false); + assert.equal(JSON.stringify(env).includes("PUBLIC_HINT"), false); + }); + + it("rejects malformed provider endpoint overrides", () => { + const request = (provider: string, baseUrl: string) => + ({ + modelConnection: { + provider, + deployment: "model", + endpoint: { baseUrl }, + credentialMode: "none", + credentials: [], + }, + }) as AgentRunRequest; + + assert.throws( + () => + buildPiExtensionEnv( + request("bad/provider", "https://proxy.example.test"), + false, + ), + /invalid provider/, + ); + assert.throws( + () => + buildPiExtensionEnv( + request("anthropic", "http://proxy.example.test"), + false, + ), + /must be an HTTPS URL/, + ); + assert.throws( + () => + buildPiExtensionEnv( + request("anthropic", "https://user:pass@proxy.example.test"), + false, + ), + /without credentials/, + ); + }); + it("exposes tracing, usage, and public tool metadata only", () => { const request = { context: { @@ -198,6 +272,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)", () => { @@ -471,11 +546,16 @@ describe("prepareLocalPiAssets (managed/none routes through a throwaway dir)", ( const plainPiPlan = { isPi: true, isDaytona: false, - skillDirs: [], - hasSystemPrompt: false, - systemPrompt: undefined, - appendSystemPrompt: undefined, - sourcePiAgentDir: "/unused", + credentials: {}, + workspace: { + skillDirs: [], + sourcePiAgentDir: "/unused", + }, + prompt: { + hasSystemPrompt: false, + systemPrompt: undefined, + appendSystemPrompt: undefined, + }, }; it("installs the extension into a per-run temp dir it owns, independent of PI_CODING_AGENT_DIR", () => { @@ -532,7 +612,10 @@ describe("prepareLocalPiAssets (managed/none routes through a throwaway dir)", ( writeFileSync(join(source, "auth.json"), '{"token":"managed"}', "utf-8"); const { dir: runDir } = prepareLocalPiAssets({ - plan: { ...plainPiPlan, sourcePiAgentDir: source }, + plan: { + ...plainPiPlan, + workspace: { ...plainPiPlan.workspace, sourcePiAgentDir: source }, + }, env: {}, }); assert.ok(runDir); @@ -550,7 +633,10 @@ describe("prepareLocalPiAssets (managed/none routes through a throwaway dir)", ( const env: Record = {}; const { dir: runDir, modelConfigWritten } = prepareLocalPiAssets({ - plan: { ...plainPiPlan, sourcePiAgentDir: source }, + plan: { + ...plainPiPlan, + workspace: { ...plainPiPlan.workspace, sourcePiAgentDir: source }, + }, env, piModelConfig: MODEL_CONFIG_PLAN, }); @@ -588,8 +674,10 @@ describe("Pi skill snapshots", () => { const first = resolvePiSkillSnapshot({ isPi: true, - cwd, - skillDirs: [{ name: "release-notes", dir: skill }], + workspace: { + cwd, + skillDirs: [{ name: "release-notes", dir: skill }], + }, }); assert.ok(first); assert.match(first.dir, new RegExp(`${cwd}/agents/skills/[a-f0-9]{64}$`)); @@ -614,8 +702,10 @@ describe("Pi skill snapshots", () => { writeFileSync(join(skill, "SKILL.md"), "second", "utf-8"); const second = resolvePiSkillSnapshot({ isPi: true, - cwd, - skillDirs: [{ name: "release-notes", dir: skill }], + workspace: { + cwd, + skillDirs: [{ name: "release-notes", dir: skill }], + }, }); assert.ok(second); assert.notEqual(second.dir, first.dir); @@ -633,8 +723,10 @@ describe("Pi skill snapshots", () => { writeFileSync(join(skill, "SKILL.md"), "skill", "utf-8"); const snapshot = resolvePiSkillSnapshot({ isPi: true, - cwd, - skillDirs: [{ name: "release-notes", dir: skill }], + workspace: { + cwd, + skillDirs: [{ name: "release-notes", dir: skill }], + }, }); assert.ok(snapshot); mkdirSync(snapshot.dir, { recursive: true }); @@ -652,11 +744,17 @@ describe("Pi skill snapshots", () => { it("does not configure snapshots for non-Pi or empty-skill runs", () => { assert.equal( - resolvePiSkillSnapshot({ isPi: false, cwd: "/work", skillDirs: [] }), + resolvePiSkillSnapshot({ + isPi: false, + workspace: { cwd: "/work", skillDirs: [] }, + }), undefined, ); assert.equal( - resolvePiSkillSnapshot({ isPi: true, cwd: "/work", skillDirs: [] }), + resolvePiSkillSnapshot({ + isPi: true, + workspace: { cwd: "/work", skillDirs: [] }, + }), undefined, ); const env: Record = {}; @@ -674,17 +772,29 @@ describe("Pi skill snapshots", () => { describe("prepareLocalPiAssets (runtime_provided runs out of the mount, read-write)", () => { const subscriptionPlan = ( mount: string, - over: Record = {}, + over: { + credentials?: Record; + workspace?: Record; + prompt?: Record; + } = {}, ) => ({ isPi: true, isDaytona: false, - credentialMode: "runtime_provided", - skillDirs: [], - hasSystemPrompt: false, - systemPrompt: undefined, - appendSystemPrompt: undefined, - sourcePiAgentDir: mount, - ...over, + credentials: { + credentialMode: "runtime_provided", + ...over.credentials, + }, + workspace: { + skillDirs: [], + sourcePiAgentDir: mount, + ...over.workspace, + }, + prompt: { + hasSystemPrompt: false, + systemPrompt: undefined, + appendSystemPrompt: undefined, + ...over.prompt, + }, }); it("points PI_CODING_AGENT_DIR at the mount itself, not at a per-run copy", () => { @@ -711,9 +821,8 @@ describe("prepareLocalPiAssets (runtime_provided runs out of the mount, read-wri const { dir: runDir } = prepareLocalPiAssets({ plan: subscriptionPlan(mount, { - skillDirs: [], - hasSystemPrompt: true, - appendSystemPrompt: "extra", + workspace: { skillDirs: [] }, + prompt: { hasSystemPrompt: true, appendSystemPrompt: "extra" }, }) as never, env: {}, }); @@ -730,9 +839,8 @@ describe("prepareLocalPiAssets (runtime_provided runs out of the mount, read-wri const { dir: runDir } = prepareLocalPiAssets({ plan: subscriptionPlan(source, { - credentialMode: "env", - hasSystemPrompt: true, - appendSystemPrompt: "extra", + credentials: { credentialMode: "env" }, + prompt: { hasSystemPrompt: true, appendSystemPrompt: "extra" }, }) as never, env, }); @@ -745,6 +853,7 @@ describe("prepareLocalPiAssets (runtime_provided runs out of the mount, read-wri assert.equal(env.PI_CODING_AGENT_DIR, runDir); dirs.push(runDir as string); }); + }); describe("sandbox uploads", () => { @@ -781,8 +890,10 @@ describe("sandbox uploads", () => { writeFileSync(join(skill, "SKILL.md"), "skill", "utf-8"); const snapshot = resolvePiSkillSnapshot({ isPi: true, - cwd: "/workspace", - skillDirs: [{ name: "release-notes", dir: skill }], + workspace: { + cwd: "/workspace", + skillDirs: [{ name: "release-notes", dir: skill }], + }, }); assert.ok(snapshot); diff --git a/services/runner/tests/unit/sandbox-agent-pi-model-config.test.ts b/services/runner/tests/unit/sandbox-agent-pi-model-config.test.ts index dbbb61ff4e..68e16a95b4 100644 --- a/services/runner/tests/unit/sandbox-agent-pi-model-config.test.ts +++ b/services/runner/tests/unit/sandbox-agent-pi-model-config.test.ts @@ -18,17 +18,31 @@ import { const RAW_KEY = "sk-super-secret-value-do-not-leak"; +/** Flattened overrides: the resolved routing fields live on `modelConnection` on the wire. */ +interface RequestOverrides { + harness?: string; + provider?: string; + deployment?: string; + endpoint?: { baseUrl?: string }; + credentialMode?: "env" | "runtime_provided" | "none"; + connection?: AgentRunRequest["connection"]; + model?: string; +} + /** A complete, applicable managed OpenAI-compatible custom Pi request. */ -function completeRequest(over: Partial = {}): AgentRunRequest { +function completeRequest(over: RequestOverrides = {}): AgentRunRequest { return { - harness: "pi_core", - provider: "openai", - deployment: "custom", - connection: { mode: "agenta", slug: "my-ollama" }, - endpoint: { baseUrl: "https://example.test/v1" }, - credentialMode: "env", - model: "qwen2.5-coder:7b", - ...over, + harness: "harness" in over ? over.harness : "pi_core", + connection: + "connection" in over ? over.connection : { mode: "agenta", slug: "my-ollama" }, + model: over.model ?? "qwen2.5-coder:7b", + modelConnection: { + provider: over.provider ?? "openai", + deployment: over.deployment ?? "custom", + endpoint: over.endpoint ?? { baseUrl: "https://example.test/v1" }, + credentialMode: over.credentialMode ?? "env", + credentials: [], + }, }; } @@ -137,7 +151,7 @@ describe("buildPiModelConfigPlan (non-applicable -> no plan, current behavior)", describe("buildPiModelConfigPlan (applicable but incomplete -> typed error)", () => { const cases: Array<{ name: string; - over: Partial; + over: RequestOverrides; secrets?: Record; hint: RegExp; }> = [ diff --git a/services/runner/tests/unit/sandbox-agent-provider.test.ts b/services/runner/tests/unit/sandbox-agent-provider.test.ts index 625cddbd2e..96d08b3232 100644 --- a/services/runner/tests/unit/sandbox-agent-provider.test.ts +++ b/services/runner/tests/unit/sandbox-agent-provider.test.ts @@ -14,8 +14,14 @@ import assert from "node:assert/strict"; import { buildDaytonaCreate, buildSandboxProvider, + daytonaCreateFingerprint, daytonaNetworkFields, } from "../../src/engines/sandbox_agent/provider.ts"; +import { buildDaytonaSecretPlan } from "../../src/engines/sandbox_agent/daytona-secret-plan.ts"; +import { + DAYTONA_PI_COMMAND, + DAYTONA_PI_DIR, +} from "../../src/engines/sandbox_agent/daytona.ts"; import { DEFAULT_DAYTONA_AUTOSTOP_MINUTES, DEFAULT_DAYTONA_AUTODELETE_MINUTES, @@ -85,7 +91,95 @@ describe("daytonaNetworkFields", () => { }); }); +describe("daytonaCreateFingerprint", () => { + const secretPlan = { + environment: {}, + candidates: [], + }; + + it("changes for local_use values and Pi custom endpoint routing", () => { + const fingerprint = ( + piExtEnv: Record, + environment: Record, + ) => + daytonaCreateFingerprint({ + image: "runner-image", + create: buildDaytonaCreate( + daytonaConfig(), + piExtEnv, + environment, + undefined, + ), + secretPlan, + }); + + const base = fingerprint( + { AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE: '{"baseUrl":"https://a.test"}' }, + { AWS_PROFILE: "profile-a" }, + ); + assert.notEqual( + base, + fingerprint( + { + AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE: '{"baseUrl":"https://a.test"}', + }, + { AWS_PROFILE: "profile-b" }, + ), + ); + assert.notEqual( + base, + fingerprint( + { + AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE: '{"baseUrl":"https://b.test"}', + }, + { AWS_PROFILE: "profile-a" }, + ), + ); + }); +}); + describe("buildDaytonaCreate (lifecycle + artifact on the create object)", () => { + it("carries Secret names separately and never puts opaque plaintext in env/config", () => { + const opaque = "marker-opaque-plaintext"; + const plan = buildDaytonaSecretPlan({ + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: opaque, + usage: "opaque_http", + }, + ], + }, + }); + const create = buildDaytonaCreate( + daytonaConfig(), + { PUBLIC_EXTENSION_CONFIG: "enabled" }, + { ...plan.environment, AWS_REGION: "us-east-1" }, + undefined, + { ANTHROPIC_API_KEY: "agenta_random_secret_name" }, + ); + assert.deepEqual(create.secrets, { + ANTHROPIC_API_KEY: "agenta_random_secret_name", + }); + assert.deepEqual(create.envVars, { + PI_CODING_AGENT_DIR: DAYTONA_PI_DIR, + PUBLIC_EXTENSION_CONFIG: "enabled", + AWS_REGION: "us-east-1", + PI_ACP_PI_COMMAND: DAYTONA_PI_COMMAND, + }); + assert.equal(JSON.stringify(create).includes(opaque), false); + }); + + it("omits the secrets field when no Secret attachments exist", () => { + const create = buildDaytonaCreate(daytonaConfig(), {}, {}, undefined, {}); + assert.equal("secrets" in create, false); + }); + it("carries stop and delete intervals without auto-archive by default", () => { const create = buildDaytonaCreate(daytonaConfig(), {}, {}, undefined); // ephemeral:false so a stop PARKS (warm) instead of deleting; the intervals are the reapers. @@ -147,6 +241,7 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () {}, {}, undefined, + undefined, localOnly, ), /Unknown sandbox id 'typo-sandbox'/, @@ -155,7 +250,16 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () it("resolves 'local' without refusing", () => { assert.doesNotThrow(() => - buildSandboxProvider("local", {}, undefined, {}, {}, undefined, localOnly), + buildSandboxProvider( + "local", + {}, + undefined, + {}, + {}, + undefined, + undefined, + localOnly, + ), ); }); @@ -169,6 +273,7 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () {}, {}, undefined, + undefined, localOnly, ), /not enabled on this deployment/, @@ -184,8 +289,72 @@ describe("buildSandboxProvider (enabled-provider gate + unknown-id refusal)", () {}, {}, undefined, + undefined, runnerConfig("local,daytona"), ), ); }); + + it("wraps Daytona with process-local Secrets for EVERY plan-bearing run, zero candidates included", () => { + const plan = buildDaytonaSecretPlan({ + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "opaque", + usage: "opaque_http", + }, + ], + }, + }); + const build = (secretPlan?: typeof plan) => + buildSandboxProvider( + "daytona", + {}, + undefined, + {}, + {}, + undefined, + secretPlan, + runnerConfig("local,daytona"), + ) as { materializeMcpServers?: unknown }; + + // Flag OFF (hermetic default): a flag-off run never carries a plan (buildRunPlan builds one + // only when the flag is on), and the plain provider is unchanged from main. + assert.equal( + typeof build(undefined).materializeMcpServers, + "undefined", + "flag off (no plan) stays on the plain provider", + ); + // Defense-in-depth: a direct caller handing a candidate-bearing plan while the flag is off + // is refused — that plan's environment already dropped the opaque values, so proceeding + // unwrapped would silently run without credentials. + assert.throws( + () => build(plan), + /AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS=process_local/, + ); + + try { + process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = "process_local"; + assert.equal( + typeof build(plan).materializeMcpServers, + "function", + "flag on + candidates attaches the Secret wrapper", + ); + // Zero candidates must ALSO wrap: buildRunPlan keeps the empty plan on every flag-on + // Daytona run precisely so the wrapper's create-fingerprint check governs reconnects + // (a rotated local_use credential forces a rebuild, never a stale plaintext reconnect). + assert.equal( + typeof build(buildDaytonaSecretPlan({})).materializeMcpServers, + "function", + "flag on + zero candidates still attaches the Secret wrapper", + ); + } finally { + delete process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; + } + }); }); diff --git a/services/runner/tests/unit/sandbox-agent-qa-transcript-replay.test.ts b/services/runner/tests/unit/sandbox-agent-qa-transcript-replay.test.ts index 0efc00e938..2c26eedf0c 100644 --- a/services/runner/tests/unit/sandbox-agent-qa-transcript-replay.test.ts +++ b/services/runner/tests/unit/sandbox-agent-qa-transcript-replay.test.ts @@ -156,15 +156,15 @@ describe("runSandboxAgent replays real captured QA transcripts", () => { // The plan is where F-001 actually broke (`pi-assets.ts` never received the override): assert // directly on it, not on a downstream side effect that a fake session cannot reproduce. assert.equal( - calls.workspacePlan.appendSystemPrompt, + calls.workspacePlan.prompt.appendSystemPrompt, request.appendSystemPrompt, "buildRunPlan must carry the recorded append_system override through to the plan", ); - assert.equal(calls.workspacePlan.hasSystemPrompt, true); + assert.equal(calls.workspacePlan.prompt.hasSystemPrompt, true); // The session receives the plan's turnText (the orchestration's framing of the request), // not necessarily just the last message -- assert against it so multi-message captures hold. assert.deepEqual(calls.promptBlocks, [ - { type: "text", text: calls.workspacePlan.turnText }, + { type: "text", text: calls.workspacePlan.prompt.turnText }, ]); }); @@ -186,7 +186,7 @@ describe("runSandboxAgent replays real captured QA transcripts", () => { // `pi_agenta` drive the same "pi" ACP agent (see `run-plan.ts`'s `acpAgent` derivation). assert.equal(calls.createSessionOptions.agent, "pi"); assert.deepEqual(calls.promptBlocks, [ - { type: "text", text: calls.workspacePlan.turnText }, + { type: "text", text: calls.workspacePlan.prompt.turnText }, ]); // The recorded request's deprecated `tools` field is accepted and ignored: built-ins are // activated unconditionally, so replanning without it must reach the same gating decision. @@ -197,8 +197,8 @@ describe("runSandboxAgent replays real captured QA transcripts", () => { assert.equal(withoutTools.ok, true); if (withoutTools.ok) { assert.equal( - withoutTools.plan.builtinGatingActive, - calls.workspacePlan.builtinGatingActive, + withoutTools.plan.tools.builtinGatingActive, + calls.workspacePlan.tools.builtinGatingActive, ); } }, diff --git a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts index 6d4a4b51d8..aed997de2e 100644 --- a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts +++ b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts @@ -17,6 +17,7 @@ import { resetRunnerConfigCache } from "../../src/config/runner-config.ts"; const previousPiDir = process.env.PI_CODING_AGENT_DIR; const previousDenyPermissions = process.env.SANDBOX_AGENT_DENY_PERMISSIONS; +const previousOpaqueSecrets = process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; // These cases exercise Daytona runs, so enable it (with a provisioning credential) on top of the // hermetic scrub, then drop the memoized config so buildRunPlan reads the enabled set. @@ -32,6 +33,9 @@ afterEach(() => { if (previousDenyPermissions === undefined) delete process.env.SANDBOX_AGENT_DENY_PERMISSIONS; else process.env.SANDBOX_AGENT_DENY_PERMISSIONS = previousDenyPermissions; + if (previousOpaqueSecrets === undefined) + delete process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; + else process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = previousOpaqueSecrets; }); describe("buildRunPlan", () => { @@ -74,7 +78,7 @@ describe("buildRunPlan", () => { ); assert.equal(result.ok, true); - assert.equal(result.ok && result.plan.prompt, ""); + assert.equal(result.ok && result.plan.prompt.text, ""); }); it("accepts both legacy image-only current user turn shapes", () => { @@ -90,7 +94,7 @@ describe("buildRunPlan", () => { ); assert.equal(result.ok, true); - assert.equal(result.ok && result.plan.prompt, ""); + assert.equal(result.ok && result.plan.prompt.text, ""); } }); @@ -211,9 +215,9 @@ describe("buildRunPlan", () => { ); assert.equal(result.ok, true); - assert.equal(result.ok && result.plan.prompt, ""); + assert.equal(result.ok && result.plan.prompt.text, ""); assert.ok( - result.ok && result.plan.turnText.includes("user APPROVED Write"), + result.ok && result.plan.prompt.turnText.includes("user APPROVED Write"), "the plan's turn text carries the approval-resume frame", ); }); @@ -319,7 +323,19 @@ describe("buildRunPlan", () => { skills: [ { name: "alpha", description: "Alpha skill.", body: "Do alpha." }, ], - secrets: { OPENAI_API_KEY: "key" }, + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "key", + usage: "opaque_http", + }, + ], + }, } as AgentRunRequest, { createLocalCwd: () => "/tmp/local-cwd", @@ -339,32 +355,33 @@ describe("buildRunPlan", () => { assert.equal(result.plan.harness, "pi_agenta"); assert.equal(result.plan.acpAgent, "pi"); assert.equal(result.plan.sandboxId, "local"); - assert.equal(result.plan.cwd, "/tmp/local-cwd"); + assert.equal(result.plan.workspace.cwd, "/tmp/local-cwd"); // The relay dir + usage capture are ephemeral runner files kept OFF the (possibly geesefs) // cwd: an ephemeral sibling whose leaf is the cwd basename. - assert.ok(!result.plan.relayDir.startsWith(result.plan.cwd)); - assert.ok(result.plan.relayDir.endsWith("/agenta/relay/local-cwd")); + assert.ok(!result.plan.workspace.relayDir.startsWith(result.plan.workspace.cwd)); + assert.ok(result.plan.workspace.relayDir.endsWith("/agenta/relay/local-cwd")); assert.equal( - result.plan.usageOutPath, - `${result.plan.relayDir}/.agenta-usage.json`, + result.plan.workspace.usageOutPath, + `${result.plan.workspace.relayDir}/.agenta-usage.json`, ); - assert.equal(result.plan.prompt, " ship it "); - assert.equal(result.plan.agentsMd, "instructions"); - assert.equal(result.plan.systemPrompt, "system"); - assert.equal(result.plan.appendSystemPrompt, "append"); - assert.equal(result.plan.hasSystemPrompt, true); - assert.equal(result.plan.hasApiKey, true); - assert.equal(result.plan.sourcePiAgentDir, "/tmp/pi-agent"); + assert.equal(result.plan.prompt.text, " ship it "); + assert.equal(result.plan.prompt.agentsMd, "instructions"); + assert.equal(result.plan.prompt.systemPrompt, "system"); + assert.equal(result.plan.prompt.appendSystemPrompt, "append"); + assert.equal(result.plan.prompt.hasSystemPrompt, true); + assert.equal(result.plan.credentials.hasApiKey, true); + assert.deepEqual(result.plan.credentials.modelEnvironment, { OPENAI_API_KEY: "key" }); + assert.equal(result.plan.workspace.sourcePiAgentDir, "/tmp/pi-agent"); assert.deepEqual( - result.plan.executableToolSpecs.map((tool) => tool.name), + result.plan.tools.executableToolSpecs.map((tool) => tool.name), ["server_tool"], ); assert.deepEqual( - result.plan.toolSpecs.map((tool) => tool.name), + result.plan.tools.toolSpecs.map((tool) => tool.name), ["server_tool", "client_tool"], ); - assert.equal(result.plan.useToolRelay, true); - assert.deepEqual(result.plan.skillDirs, [ + assert.equal(result.plan.tools.useToolRelay, true); + assert.deepEqual(result.plan.workspace.skillDirs, [ { name: "alpha", dir: "/skills/alpha" }, ]); assert.deepEqual(logs, ["resolved alpha", "skills: alpha"]); @@ -379,8 +396,8 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.deepEqual(result.plan.executableToolSpecs, []); - assert.equal(result.plan.useToolRelay, true); + assert.deepEqual(result.plan.tools.executableToolSpecs, []); + assert.equal(result.plan.tools.useToolRelay, true); }); it("leaves builtin gating off under a blanket allow with no builtin rules", () => { @@ -395,9 +412,9 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.builtinGatingActive, false); + assert.equal(result.plan.tools.builtinGatingActive, false); // Builtin gating rides the ACP dialog plane, not the relay: no custom tools, no relay. - assert.equal(result.plan.useToolRelay, false); + assert.equal(result.plan.tools.useToolRelay, false); }); it("turns builtin gating on under the default allow_reads mode", () => { @@ -414,7 +431,7 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.builtinGatingActive, true); + assert.equal(result.plan.tools.builtinGatingActive, true); }); it("turns builtin gating on when the permission kill switch is set", () => { @@ -431,8 +448,8 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.builtinGatingActive, true); - assert.equal(result.plan.useToolRelay, false); + assert.equal(result.plan.tools.builtinGatingActive, true); + assert.equal(result.plan.tools.useToolRelay, false); }); it("turns builtin gating on when an all-allow policy has a builtin rule", () => { @@ -450,7 +467,7 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.builtinGatingActive, true); + assert.equal(result.plan.tools.builtinGatingActive, true); }); it("carries the sandbox permission onto the plan and leaves an unrestricted run alone", () => { @@ -740,16 +757,16 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; assert.equal( - result.plan.toolMcpDir, + result.plan.workspace.toolMcpDir, "/home/sandbox/agenta/tool-mcp/agenta-fixed", ); - assert.notEqual(result.plan.toolMcpDir, result.plan.relayDir); + assert.notEqual(result.plan.workspace.toolMcpDir, result.plan.workspace.relayDir); assert.ok( - !result.plan.toolMcpDir.startsWith(`${result.plan.relayDir}/`), + !result.plan.workspace.toolMcpDir.startsWith(`${result.plan.workspace.relayDir}/`), "the shim dir is never nested inside the relay dir (the relay loop sweeps it)", ); assert.equal( - result.plan.useToolRelay, + result.plan.tools.useToolRelay, true, "the relay loop still starts (it executes the shim's requests)", ); @@ -870,17 +887,14 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; assert.deepEqual( - result.plan.toolSpecs.map((tool) => tool.name), + result.plan.tools.toolSpecs.map((tool) => tool.name), ["server_tool", "request_connection"], ); assert.deepEqual( - result.plan.executableToolSpecs.map((tool) => tool.name), + result.plan.tools.executableToolSpecs.map((tool) => tool.name), ["server_tool"], ); - assert.equal( - result.plan.clientToolPauseDisposition, - "cold-acknowledge", - ); + assert.equal(result.plan.tools.clientToolPauseDisposition, "cold-acknowledge"); }); it("allows claude x daytona x client-ONLY tools (the shim advertises them and the relay parks)", () => { @@ -903,17 +917,17 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; assert.deepEqual( - result.plan.toolSpecs.map((tool) => tool.name), + result.plan.tools.toolSpecs.map((tool) => tool.name), ["request_connection"], ); // No executable tool remains, but the run is no longer refused. - assert.deepEqual(result.plan.executableToolSpecs, []); + assert.deepEqual(result.plan.tools.executableToolSpecs, []); assert.equal( - result.plan.clientToolPauseDisposition, + result.plan.tools.clientToolPauseDisposition, "cold-acknowledge", "non-Pi shim path acknowledges a parked client tool with a paused answer", ); - assert.equal(result.plan.useToolRelay, true); + assert.equal(result.plan.tools.useToolRelay, true); }); it("allows pi x daytona x client-only tools (Pi's extension + file relay deliver them)", () => { @@ -930,7 +944,7 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; // Pi parks through its own extension, so its disposition is "pi-native" (no paused answer). - assert.equal(result.plan.clientToolPauseDisposition, "pi-native"); + assert.equal(result.plan.tools.clientToolPauseDisposition, "pi-native"); }); it("still refuses claude x daytona x executable tools under strict restricted network", () => { @@ -1101,7 +1115,7 @@ describe("buildRunPlan", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.deepEqual(result.plan.skillDirs, [ + assert.deepEqual(result.plan.workspace.skillDirs, [ { name: "alpha", dir: "/skills/alpha" }, { name: "beta", dir: "/skills/beta" }, ]); @@ -1131,13 +1145,25 @@ describe("buildRunPlan", () => { }); it("normalizes a Daytona Claude run without Pi-only state", () => { + process.env.AGENTA_RUNNER_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", }, { @@ -1150,15 +1176,76 @@ describe("buildRunPlan", () => { assert.equal(result.plan.acpAgent, "claude"); assert.equal(result.plan.isPi, false); assert.equal(result.plan.isDaytona, true); - assert.equal(result.plan.cwd, "/home/sandbox/agenta-fixed"); - assert.equal(result.plan.usageOutPath, undefined); - assert.equal(result.plan.legacyHarnessApiKeyVar, "ANTHROPIC_API_KEY"); - assert.equal(result.plan.hasApiKey, true); + assert.equal(result.plan.workspace.cwd, "/home/sandbox/agenta-fixed"); + assert.equal(result.plan.workspace.usageOutPath, undefined); + assert.equal(result.plan.credentials.harnessApiKeyVar, "ANTHROPIC_API_KEY"); + // The FULL materialized environment sets hasApiKey: on a Daytona Secrets run the opaque key + // leaves the plaintext env for the secret plan, but the harness still receives its binding. + assert.equal(result.plan.credentials.hasApiKey, true); + assert.equal(result.plan.credentials.modelEnvironment.ANTHROPIC_API_KEY, undefined); + assert.equal(result.plan.credentials.daytonaSecretPlan?.candidates.length, 1); // The resolved credentialMode is carried onto the plan (drives clear-then-apply). - assert.equal(result.plan.credentialMode, "env"); - assert.equal(result.plan.systemPrompt, undefined); - assert.equal(result.plan.hasSystemPrompt, false); - assert.deepEqual(result.plan.skillDirs, []); + assert.equal(result.plan.credentials.credentialMode, "env"); + assert.equal(result.plan.prompt.systemPrompt, undefined); + assert.equal(result.plan.prompt.hasSystemPrompt, false); + assert.deepEqual(result.plan.workspace.skillDirs, []); + }); + + it("keeps a zero-candidate secret plan when the flag is on, and none when it is off", () => { + // A run with only local_use credentials produces ZERO opaque candidates. The plan must + // still ride the run plan when the flag is on: provider.ts applies the Secret wrapper off + // plan PRESENCE, and only the wrapper's create-fingerprint check forces a rebuild (instead + // of a stale plaintext reconnect) after the local_use credentials rotate. + const localUseRequest = { + harness: "claude", + sandbox: "daytona", + messages: [{ role: "user", content: "hello" }], + modelConnection: { + provider: "bedrock", + deployment: "bedrock", + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "AWS_ACCESS_KEY_ID" }, + value: "AKIA-local-use", + usage: "local_use", + }, + { + binding: { kind: "environment", name: "AWS_SECRET_ACCESS_KEY" }, + value: "aws-secret-local-use", + usage: "local_use", + }, + ], + }, + } as AgentRunRequest; + const deps = { createDaytonaCwd: () => "/home/sandbox/agenta-fixed" }; + + process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS = "process_local"; + const flagOn = buildRunPlan(localUseRequest, deps); + assert.equal(flagOn.ok, true); + if (!flagOn.ok) return; + assert.ok(flagOn.plan.credentials.daytonaSecretPlan, "flag on keeps the empty plan"); + assert.equal(flagOn.plan.credentials.daytonaSecretPlan.candidates.length, 0); + // local_use values still reach sandbox create as plaintext env (by design). + assert.equal( + flagOn.plan.credentials.modelEnvironment.AWS_ACCESS_KEY_ID, + "AKIA-local-use", + ); + assert.equal( + flagOn.plan.credentials.modelEnvironment.AWS_SECRET_ACCESS_KEY, + "aws-secret-local-use", + ); + + // Flag OFF stays exactly the pre-feature behavior: no plan, so no wrapper is applied. + delete process.env.AGENTA_RUNNER_DAYTONA_OPAQUE_SECRETS; + const flagOff = buildRunPlan(localUseRequest, deps); + assert.equal(flagOff.ok, true); + if (!flagOff.ok) return; + assert.equal(flagOff.plan.credentials.daytonaSecretPlan, undefined); + assert.equal( + flagOff.plan.credentials.modelEnvironment.AWS_ACCESS_KEY_ID, + "AKIA-local-use", + ); }); it("normalizes a local managed Codex run", () => { @@ -1167,8 +1254,19 @@ describe("buildRunPlan", () => { harness: "codex", sandbox: "local", messages: [{ role: "user", content: "hello" }], - secrets: { OPENAI_API_KEY: "sk-openai" }, - credentialMode: "env", + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "sk-openai", + usage: "opaque_http", + }, + ], + }, }, { createLocalCwd: () => "/tmp/local-cwd", @@ -1180,9 +1278,9 @@ describe("buildRunPlan", () => { assert.equal(result.plan.acpAgent, "codex"); assert.equal(result.plan.isPi, false); assert.equal(result.plan.isDaytona, false); - assert.equal(result.plan.legacyHarnessApiKeyVar, "OPENAI_API_KEY"); - assert.equal(result.plan.hasApiKey, true); - assert.equal(result.plan.credentialMode, "env"); + assert.equal(result.plan.credentials.harnessApiKeyVar, "OPENAI_API_KEY"); + assert.equal(result.plan.credentials.hasApiKey, true); + assert.equal(result.plan.credentials.credentialMode, "env"); }); }); @@ -1207,10 +1305,10 @@ describe("buildRunPlan durableCwd (prefix-derived cwd)", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.cwd, "/tmp/agenta/mounts/proj-1/mount-abc"); + assert.equal(result.plan.workspace.cwd, "/tmp/agenta/mounts/proj-1/mount-abc"); // Relay dir is an ephemeral sibling (leaf = cwd basename), NOT inside the durable mount. - assert.ok(!result.plan.relayDir.startsWith(result.plan.cwd)); - assert.ok(result.plan.relayDir.endsWith("/agenta/relay/mount-abc")); + assert.ok(!result.plan.workspace.relayDir.startsWith(result.plan.workspace.cwd)); + assert.ok(result.plan.workspace.relayDir.endsWith("/agenta/relay/mount-abc")); // createLocalCwd received the durableCwd value. assert.deepEqual(localCwdCalls, ["/tmp/agenta/mounts/proj-1/mount-abc"]); }); @@ -1235,7 +1333,7 @@ describe("buildRunPlan durableCwd (prefix-derived cwd)", () => { assert.equal(result.ok, true); if (!result.ok) return; assert.equal( - result.plan.cwd, + result.plan.workspace.cwd, "/home/sandbox/agenta/mounts/proj-1/mount-abc", ); assert.deepEqual(daytonaCwdCalls, [ @@ -1262,7 +1360,7 @@ describe("buildRunPlan durableCwd (prefix-derived cwd)", () => { assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.cwd, "/tmp/agenta-sandbox-agent-ephemeral"); + assert.equal(result.plan.workspace.cwd, "/tmp/agenta-sandbox-agent-ephemeral"); assert.deepEqual(localCwdCalls, [undefined]); }); @@ -1291,8 +1389,8 @@ describe("buildRunPlan durableCwd (prefix-derived cwd)", () => { assert.equal(r2.ok, true); if (!r1.ok || !r2.ok) return; // Same prefix -> same cwd across turns. - assert.equal(r1.plan.cwd, r2.plan.cwd); - assert.equal(r1.plan.cwd, localPath); + assert.equal(r1.plan.workspace.cwd, r2.plan.workspace.cwd); + assert.equal(r1.plan.workspace.cwd, localPath); }); }); @@ -1324,7 +1422,12 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "pi_core", sandbox: "daytona", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "openai", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, }, { createDaytonaCwd: () => { @@ -1345,7 +1448,12 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "codex", sandbox: "local", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "openai", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, }); assert.equal(result.ok, false); @@ -1360,12 +1468,17 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "codex", sandbox: "local", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "openai", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, }); assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.credentialMode, "runtime_provided"); + assert.equal(result.plan.credentials.credentialMode, "runtime_provided"); assert.equal(result.plan.acpAgent, "codex"); }); }); @@ -1377,7 +1490,12 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "codex", sandbox: "daytona", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "openai", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, }, { createDaytonaCwd: () => { @@ -1399,7 +1517,12 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "pi_core", sandbox: "local", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "openai", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, }); assert.equal(result.ok, false); if (result.ok) return; @@ -1413,7 +1536,12 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "claude", sandbox: "local", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "anthropic", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, }); assert.equal(result.ok, false); if (result.ok) return; @@ -1427,12 +1555,17 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "pi_core", sandbox: "local", messages: [{ role: "user", content: "hello" }], - credentialMode: "runtime_provided", + modelConnection: { + provider: "openai", + deployment: "direct", + credentialMode: "runtime_provided", + credentials: [], + }, }); assert.equal(result.ok, true); if (!result.ok) return; - assert.equal(result.plan.credentialMode, "runtime_provided"); - assert.equal(result.plan.sourcePiAgentDir, "/agenta/harness/pi"); + assert.equal(result.plan.credentials.credentialMode, "runtime_provided"); + assert.equal(result.plan.workspace.sourcePiAgentDir, "/agenta/harness/pi"); }); }); @@ -1442,10 +1575,210 @@ describe("buildRunPlan runtime_provided (subscription) gates", () => { harness: "pi_core", sandbox: "local", messages: [{ role: "user", content: "hello" }], - secrets: { OPENAI_API_KEY: "sk-test" }, - credentialMode: "env", + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "sk-test", + usage: "opaque_http", + }, + ], + }, }); assert.equal(result.ok, true); }); }); }); + +describe("modelConnection validation", () => { + const base = { + harness: "pi_core", + messages: [{ role: "user", content: "hi" }], + } satisfies AgentRunRequest; + + const connection = (overrides: Record = {}) => ({ + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "key", + usage: "opaque_http", + }, + ], + ...overrides, + }); + + for (const [name, modelConnection, error] of [ + [ + "rejects an empty binding name", + connection({ + credentials: [ + { + binding: { kind: "environment", name: "" }, + value: "key", + usage: "opaque_http", + }, + ], + }), + "modelConnection credential binding and value must be non-empty", + ], + [ + "rejects an empty credential value", + connection({ + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "", + usage: "opaque_http", + }, + ], + }), + "modelConnection credential binding and value must be non-empty", + ], + [ + "rejects opaque HTTP credentials without an endpoint", + connection({ endpoint: undefined }), + "opaque_http model credentials require an effective HTTPS endpoint", + ], + [ + "rejects non-HTTPS opaque HTTP routes", + connection({ endpoint: { baseUrl: "http://api.openai.com/v1" } }), + "opaque_http model credentials require an effective HTTPS endpoint", + ], + [ + "rejects credentials under runtime-provided mode", + connection({ credentialMode: "runtime_provided" }), + "modelConnection credentials require credentialMode env", + ], + [ + "rejects env mode without credentials", + connection({ credentials: [] }), + "modelConnection credentialMode env requires credentials", + ], + ] as const) { + it(name, () => { + // The local runtime_provided mount gate runs before credential validation; satisfy it so + // the runtime_provided case reaches the validation under test (afterEach restores this). + process.env.PI_CODING_AGENT_DIR = "/agenta/harness/pi"; + let created = false; + const result = buildRunPlan( + { ...base, modelConnection } as AgentRunRequest, + { + createLocalCwd: () => { + created = true; + return "/tmp/unused"; + }, + }, + ); + assert.deepEqual(result, { ok: false, error }); + assert.equal(created, false); + }); + } + + it("materializes local_use credentials and non-secret config only after validation", () => { + const result = buildRunPlan({ + ...base, + modelConnection: connection({ + provider: "anthropic", + deployment: "bedrock", + endpoint: { + baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + region: "us-east-1", + }, + environment: { AWS_REGION: "us-east-1" }, + credentials: [ + { + binding: { kind: "environment", name: "AWS_ACCESS_KEY_ID" }, + value: "AKIA", + usage: "local_use", + }, + ], + }), + } as AgentRunRequest); + assert.equal(result.ok, true); + if (!result.ok) return; + assert.deepEqual(result.plan.credentials.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.credentials.modelEnvironment.GOOGLE_APPLICATION_CREDENTIALS, + "/tmp/adc.json", + ); + }); + + it("fails legacy top-level credential fields instead of treating the run as unmanaged", () => { + // `connection` ({mode, slug}) is NOT legacy: it stays tolerated on the wire for the + // models.json Pi custom-provider path. + for (const field of [ + "secrets", + "provider", + "deployment", + "credentialMode", + "endpoint", + ]) { + let created = false; + const result = buildRunPlan( + { + ...base, + [field]: + field === "secrets" ? { OPENAI_API_KEY: "legacy" } : "legacy", + } as AgentRunRequest, + { + createLocalCwd: () => { + created = true; + return "/unused"; + }, + }, + ); + assert.equal(result.ok, false, field); + if (!result.ok) assert.match(result.error, /modelConnection object/); + assert.equal(created, false, field); + } + }); + + it("rejects a legacy field even when modelConnection is also present", () => { + // A caller mixing the retired flat fields with the resolved object is ambiguous about which + // credential set governs the run; fail loudly instead of silently preferring one. + const result = buildRunPlan( + { + ...base, + modelConnection: connection(), + secrets: { OPENAI_API_KEY: "legacy" }, + } as AgentRunRequest, + { createLocalCwd: () => "/tmp/unused" }, + ); + assert.equal(result.ok, false); + if (!result.ok) assert.match(result.error, /modelConnection object/); + }); +}); diff --git a/services/runner/tests/unit/sandbox-agent-workspace.test.ts b/services/runner/tests/unit/sandbox-agent-workspace.test.ts index 8caac377ed..bf96cf78b6 100644 --- a/services/runner/tests/unit/sandbox-agent-workspace.test.ts +++ b/services/runner/tests/unit/sandbox-agent-workspace.test.ts @@ -42,13 +42,19 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - skillDirs: [], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -68,13 +74,19 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: true, - agentsMd: "agent instructions", acpAgent: "pi", isPi: true, - skillDirs: [], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + skillDirs: [], + }, + tools: { + useToolRelay: true, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -102,13 +114,19 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir, - useToolRelay: true, - agentsMd: "agent instructions", acpAgent: "pi", isPi: true, - skillDirs: [], + workspace: { + cwd, + relayDir, + skillDirs: [], + }, + tools: { + useToolRelay: true, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -138,13 +156,19 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: true, - agentsMd: "agent instructions", acpAgent: "pi", isPi: true, - skillDirs: [], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + skillDirs: [], + }, + tools: { + useToolRelay: true, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -178,13 +202,19 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - skillDirs: [], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -218,14 +248,20 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - harnessFiles: [{ path: ".claude/settings.json", content }], - skillDirs: [], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + harnessFiles: [{ path: ".claude/settings.json", content }], + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -244,13 +280,19 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - skillDirs: [], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -271,13 +313,19 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: true, - agentsMd: "agent instructions", acpAgent: "pi", isPi: true, - skillDirs: [], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + skillDirs: [], + }, + tools: { + useToolRelay: true, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); await workspace.cleanup(); @@ -307,13 +355,19 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - skillDirs: [], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -352,14 +406,20 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "claude", isPi: false, - harnessFiles: [{ path: ".claude/settings.json", content }], - skillDirs: [], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + harnessFiles: [{ path: ".claude/settings.json", content }], + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -393,13 +453,19 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: false, - agentsMd: "agent instructions", acpAgent: "pi", isPi: true, - skillDirs: [], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + skillDirs: [], + }, + tools: { + useToolRelay: false, + }, + prompt: { + agentsMd: "agent instructions", + }, }, }); @@ -419,12 +485,17 @@ describe("prepareWorkspace", () => { sandbox: {}, plan: { isDaytona: false, - cwd, - relayDir: join(cwd, ".agenta-tools"), - useToolRelay: false, acpAgent: "claude", isPi: false, - skillDirs: [{ name: "release-notes", dir: skillDir }], + workspace: { + cwd, + relayDir: join(cwd, ".agenta-tools"), + skillDirs: [{ name: "release-notes", dir: skillDir }], + }, + tools: { + useToolRelay: false, + }, + prompt: {}, }, }); @@ -453,12 +524,17 @@ describe("prepareWorkspace", () => { sandbox, plan: { isDaytona: true, - cwd: "/home/sandbox/agenta-fixed", - relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", - useToolRelay: false, acpAgent: "claude", isPi: false, - skillDirs: [{ name: "release-notes", dir: skillDir }], + workspace: { + cwd: "/home/sandbox/agenta-fixed", + relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", + skillDirs: [{ name: "release-notes", dir: skillDir }], + }, + tools: { + useToolRelay: false, + }, + prompt: {}, }, }); diff --git a/services/runner/tests/unit/server.test.ts b/services/runner/tests/unit/server.test.ts index c62230891b..defcc971b1 100644 --- a/services/runner/tests/unit/server.test.ts +++ b/services/runner/tests/unit/server.test.ts @@ -433,6 +433,60 @@ describe("createAgentServer", () => { } }); + it("redacts this run's credentials from the stderr stack log when a run throws", async () => { + // A per-run provider key rides ONLY the typed request (never process env). When the run + // throws with that key captured in the error message/stack (an auth failure echoing it, + // a dumped env), the stack must pass through the run's deny-set before reaching the + // stderr sink — persistence is already redacted; stderr must be too. + const PER_RUN_KEY = "sk-escaping-stack-fake-key-DO-NOT-USE-9a8b7c"; + const throwingRun: RunAgent = async () => { + throw new Error(`provider auth failed for key ${PER_RUN_KEY}`); + }; + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const s = await listen(throwingRun); + try { + const res = await fetch(`${s.url}/run`, { + method: "POST", + headers: { accept: "application/x-ndjson", ...AUTH }, + body: JSON.stringify({ + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: PER_RUN_KEY, + usage: "opaque_http", + }, + ], + }, + }), + }); + assert.equal(res.status, 200); + const records = (await res.text()) + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record); + // The escaping error still terminates the stream with a failed result. + assert.equal(records.at(-1)!.kind, "result"); + assert.equal(records.at(-1)!.result.ok, false); + + const logged = errorSpy.mock.calls + .map((args) => args.map(String).join(" ")) + .join("\n"); + // The log keeps its shape (an Error stack was written)... + assert.match(logged, /Error: provider auth failed/); + assert.match(logged, /\n\s+at /); + // ...but the live credential value never reaches the stderr sink. + assert.equal(logged.includes(PER_RUN_KEY), false); + assert.match(logged, /\[ag:redacted/); + } finally { + await s.close(); + } + }); + it("rejects an over-cap session turn before persistence or attachment claiming", async () => { // Override the cap rather than generating a default-sized batch, so the case stays small. process.env.AGENTA_ATTACHMENTS_MAX_PER_TURN = "2"; @@ -551,9 +605,7 @@ describe("createAgentServer", () => { messages: [ { role: "user", - content: [ - { type: "image", uri: "data:image/png;base64,AQID" }, - ], + content: [{ type: "image", uri: "data:image/png;base64,AQID" }], }, ], }), diff --git a/services/runner/tests/unit/session-keepalive-approval.test.ts b/services/runner/tests/unit/session-keepalive-approval.test.ts index 34ab45ad07..b668d14cc3 100644 --- a/services/runner/tests/unit/session-keepalive-approval.test.ts +++ b/services/runner/tests/unit/session-keepalive-approval.test.ts @@ -1117,13 +1117,27 @@ describe("runWithKeepalive: an approval park from a last-message-only turn", () }); }); -describe("runWithKeepalive: approval resume ignores re-minted credentials/config", () => { - // The "approve twice" bug: every approval reply is a fresh /run carrying freshly minted - // short-lived material (gateway/Composio secret VALUES, a per-turn tool-callback bearer), so its - // credential epoch — and often its config fingerprint (per-turn tokens embed in it) — never match - // the parked session's. The parked live process already holds its own baked credentials; the - // resume only delivers the human's yes/no, so a mismatch there must NOT evict the live session. - async function parkThenResume(resume: AgentRunRequest) { +describe("runWithKeepalive: approval credential lifecycle", () => { + const modelConnection = ( + value: string, + ): NonNullable => ({ + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value, + usage: "opaque_http", + }, + ], + }); + + async function parkThenResume( + resume: AgentRunRequest, + initial: Partial = {}, + ) { const { engine, calls } = makeApprovalEngine([ { approvalPause: { @@ -1136,48 +1150,73 @@ describe("runWithKeepalive: approval resume ignores re-minted credentials/config { result: { ok: true, output: "resumed", stopReason: "complete" } }, ]); const ctx = makeCtx(engine); - await runWithKeepalive(pauseTurn(), undefined, undefined, ctx); + await runWithKeepalive( + { ...pauseTurn(), ...initial }, + undefined, + undefined, + ctx, + ); const env1 = calls.acquiredEnvs[0]; const r2 = await runWithKeepalive(resume, undefined, undefined, ctx); return { calls, env1, ctx, r2 }; } - it("resumes LIVE when the resume carries a DIFFERENT credential epoch AND config fingerprint but a matching decision + history", async () => { - // The resume request re-mints a fresh tool-callback bearer (changes both the config fingerprint - // via toolCallback.endpoint and the credential epoch via secrets + toolCallback.authorization). + it("resumes live when only per-turn callback authorization rotates", async () => { const { calls, env1, r2 } = await parkThenResume( approveResume(true, { toolCallback: { endpoint: "https://gateway/tools/call", authorization: "fresh-per-turn-bearer", }, - secrets: { OPENAI_API_KEY: "sk-freshly-minted" }, }), + { + toolCallback: { + endpoint: "https://gateway/tools/call", + authorization: "original-per-turn-bearer", + }, + }, ); assert.equal(r2.ok, true); assert.equal( calls.acquire, 1, - "no cold re-acquire; the live parked session was reused", + "transient auth does not recreate the sandbox", ); - assert.equal(env1.destroyed, 0, "the parked session was NOT evicted"); + assert.equal(env1.destroyed, 0); + assert.equal(calls.resumes.length, 1, "the parked gate is answered live"); + }); + + it("evicts and cold-acquires when a model credential baked into the sandbox rotates", async () => { + const { calls, env1, r2 } = await parkThenResume( + approveResume(true, { modelConnection: modelConnection("sk-model-b") }), + { modelConnection: modelConnection("sk-model-a") }, + ); + assert.equal(r2.ok, true); assert.equal( - calls.resumes.length, + env1.destroyed, 1, - "the gate was answered live exactly once (respondPermission)", + "the stale credential environment is evicted", + ); + assert.equal( + calls.acquire, + 2, + "the approval request cold-acquires with the new key", + ); + assert.equal( + calls.resumes.length, + 0, + "the stale live gate is never answered", ); - assert.equal(calls.resumes[0].reply, "once"); - assert.equal(calls.resumes[0].permissionId, "perm-1"); }); - it("a changed model on the resume still resumes live (config fingerprint no longer gates the approval branch)", async () => { + it("a changed model without credential rotation still resumes live", async () => { const { calls, env1, r2 } = await parkThenResume( approveResume(true, { model: "m2" }), ); assert.equal(r2.ok, true); - assert.equal(calls.acquire, 1, "no cold re-acquire"); - assert.equal(env1.destroyed, 0, "the parked session was reused"); - assert.equal(calls.resumes.length, 1, "answered live exactly once"); + assert.equal(calls.acquire, 1); + assert.equal(env1.destroyed, 0); + assert.equal(calls.resumes.length, 1); }); it("the repark after a resume keeps the parked secrets hash AND the installed mount lease", async () => { @@ -1202,13 +1241,23 @@ describe("runWithKeepalive: approval resume ignores re-minted credentials/config const ctx = makeCtx(engine); const paused: AgentRunRequest = { ...pauseTurn(), - secrets: { ANTHROPIC_API_KEY: "baked-at-acquire" }, + modelConnection: modelConnection("baked-at-acquire"), + toolCallback: { + endpoint: "https://gateway/tools/call", + authorization: "original-per-turn-bearer", + }, }; await runWithKeepalive(paused, undefined, undefined, ctx); assert.equal(ctx.pool.get(POOL_KEY)!.state, "awaiting_approval"); + // Same baked model credential (a rotated one would rightly evict), but re-minted per-turn + // callback authorization — transient material the epoch hash deliberately excludes. const resume = approveResume(true, { - secrets: { ANTHROPIC_API_KEY: "re-minted-on-the-resume" }, + modelConnection: modelConnection("baked-at-acquire"), + toolCallback: { + endpoint: "https://gateway/tools/call", + authorization: "re-minted-on-the-resume", + }, }); const r2 = await runWithKeepalive(resume, undefined, undefined, ctx); assert.equal(r2.ok, true); @@ -1216,14 +1265,18 @@ describe("runWithKeepalive: approval resume ignores re-minted credentials/config const parked = ctx.pool.get(POOL_KEY)!; assert.equal( - parked.credentialEpoch.secretsHash, - computeCredentialEpoch(paused).secretsHash, - "the repark keeps the hash of the secrets the environment actually baked", - ); - assert.notEqual( - parked.credentialEpoch.secretsHash, - computeCredentialEpoch(resume).secretsHash, - "the resume's re-minted secrets never entered this environment", + parked.credentialEpoch.secrets.equals( + computeCredentialEpoch(paused).secrets, + ), + true, + "the repark keeps the secrets the environment actually baked", + ); + assert.equal( + computeCredentialEpoch(resume).secrets.equals( + parked.credentialEpoch.secrets, + ), + true, + "the re-minted per-turn bearer never enters the baked-credential material", ); assert.equal( parked.credentialEpoch.mountExpiresAtMs, diff --git a/services/runner/tests/unit/session-keepalive-dispatch.test.ts b/services/runner/tests/unit/session-keepalive-dispatch.test.ts index c3a332381e..8724816b9c 100644 --- a/services/runner/tests/unit/session-keepalive-dispatch.test.ts +++ b/services/runner/tests/unit/session-keepalive-dispatch.test.ts @@ -523,7 +523,19 @@ describe("runWithKeepalive: validation mismatches degrade to cold", () => { it("a changed secret value evicts to cold (same config, same history)", async () => { const withSecret = turn2("s1", { - secrets: { ANTHROPIC_API_KEY: "rotated" }, + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "rotated", + usage: "opaque_http", + }, + ], + }, }); const { engine, calls } = makeEngine(); const ctx = makeCtx(engine); diff --git a/services/runner/tests/unit/session-mcp-layering.test.ts b/services/runner/tests/unit/session-mcp-layering.test.ts index ab7180d730..ecd212fed4 100644 --- a/services/runner/tests/unit/session-mcp-layering.test.ts +++ b/services/runner/tests/unit/session-mcp-layering.test.ts @@ -30,6 +30,12 @@ import type { ResolvedToolSpec, } from "../../src/protocol.ts"; +const credential = (name: string, value: string) => ({ + binding: { kind: "header" as const, name }, + value, + usage: "opaque_http" as const, +}); + const relayDir = "/tmp/agenta-tools-layering"; const mcpCapable: HarnessCapabilities = { mcpTools: true, toolCalls: true }; @@ -88,7 +94,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = connection: { type: "http", url: "https://mcp.linear.app/sse", - headers: { Authorization: "Bearer x" }, + credentials: [credential("Authorization", "Bearer x")], }, policy: { tools: { mode: "all" } }, }, @@ -420,7 +426,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = connection: { type: "http", url: "https://mcp.linear.app/sse", - headers: { Authorization: "Bearer x" }, + credentials: [credential("Authorization", "Bearer x")], }, policy: { tools: { mode: "all" } }, }, diff --git a/services/runner/tests/unit/session-pool.test.ts b/services/runner/tests/unit/session-pool.test.ts index 9571e093c9..c737a43065 100644 --- a/services/runner/tests/unit/session-pool.test.ts +++ b/services/runner/tests/unit/session-pool.test.ts @@ -5,6 +5,7 @@ */ import { afterEach, beforeEach, describe, it, vi } from "vitest"; import assert from "node:assert/strict"; +import { inspect } from "node:util"; import type { AgentRunRequest } from "../../src/protocol.ts"; import { @@ -14,6 +15,7 @@ import { credentialEpochMismatch, credentialEpochValid, mountCredentialsExpired, + sandboxCredentialsRotated, expectedNextHistoryFingerprint, historyFingerprint, poolKeyFor, @@ -22,6 +24,7 @@ import { resolvesToLocalProvider, tailIsFreshUserMessage, type CredentialEpoch, + CredentialMaterial, } from "../../src/engines/sandbox_agent/session-identity.ts"; import { SessionPool } from "../../src/engines/sandbox_agent/session-pool.ts"; @@ -61,7 +64,7 @@ function fakeEnv() { }; } -const epoch: CredentialEpoch = { secretsHash: "h" }; +const epoch: CredentialEpoch = { secrets: new CredentialMaterial("h") }; function parkInput(key: string, env = fakeEnv()) { return { @@ -216,13 +219,40 @@ describe("configFingerprint", () => { messages: [{ role: "user", content: "hi" }], }; - it("ignores per-turn volatiles (messages, turnId, telemetry, secrets)", () => { - const a = configFingerprint(base); + it("ignores per-turn volatiles and credential values", () => { + const a = configFingerprint({ + ...base, + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "original", + usage: "opaque_http", + }, + ], + }, + }); const b = configFingerprint({ ...base, messages: [{ role: "user", content: "totally different" }], turnId: "t-2", - secrets: { ANTHROPIC_API_KEY: "sekret" }, + modelConnection: { + provider: "anthropic", + deployment: "direct", + endpoint: { baseUrl: "https://api.anthropic.com" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "ANTHROPIC_API_KEY" }, + value: "sekret", + usage: "opaque_http", + }, + ], + }, telemetry: { exporters: { otlp: { headers: { authorization: "Bearer x" } } }, }, @@ -235,6 +265,33 @@ describe("configFingerprint", () => { ); }); + it("ignores MCP credential values while retaining their binding contract", () => { + const withMcp = (value: string): AgentRunRequest => ({ + ...base, + mcpServers: [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/sse", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value, + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, + ], + }); + assert.equal( + configFingerprint(withMcp("secret-a")), + configFingerprint(withMcp("secret-b")), + ); + }); + it("changes when a config-bearing field changes (model)", () => { assert.notEqual( configFingerprint(base), @@ -287,12 +344,16 @@ describe("configFingerprint", () => { // model, or endpoint must cold-start rather than reuse a mismatched live session. No new // fingerprint field is needed — these already ride configFingerprint. it("changes when the connection changes (custom provider identity)", () => { - const withConn = { + const withConn: AgentRunRequest = { ...base, - provider: "openai", - deployment: "custom", connection: { mode: "agenta", slug: "ollama-a" }, - endpoint: { baseUrl: "https://a.test/v1" }, + modelConnection: { + provider: "openai", + deployment: "custom", + endpoint: { baseUrl: "https://a.test/v1" }, + credentialMode: "none", + credentials: [], + }, }; assert.notEqual( configFingerprint(withConn), @@ -304,17 +365,25 @@ describe("configFingerprint", () => { }); it("changes when the endpoint base URL changes", () => { - const withEndpoint = { + const withEndpoint: AgentRunRequest = { ...base, - deployment: "custom", connection: { mode: "agenta", slug: "ollama-a" }, - endpoint: { baseUrl: "https://a.test/v1" }, + modelConnection: { + provider: "openai", + deployment: "custom", + endpoint: { baseUrl: "https://a.test/v1" }, + credentialMode: "none", + credentials: [], + }, }; assert.notEqual( configFingerprint(withEndpoint), configFingerprint({ ...withEndpoint, - endpoint: { baseUrl: "https://b.test/v1" }, + modelConnection: { + ...withEndpoint.modelConnection!, + endpoint: { baseUrl: "https://b.test/v1" }, + }, }), ); }); @@ -541,24 +610,79 @@ describe("tailIsFreshUserMessage", () => { }); describe("credential epoch", () => { + it("never surfaces the credential values, however it is turned into text", () => { + // The epoch holds the real values so it can compare them, so the one thing that must be + // impossible is a value reaching a log line. Every route from an object to a string is + // pinned here, because a future refactor that drops one of these overrides would leak + // silently: the code would still work and the key would appear in stderr. + const epoch = computeCredentialEpoch({ + modelConnection: { + provider: "openai", + deployment: "direct", + endpoint: { baseUrl: "https://api.openai.com/v1" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "sk-should-never-be-printed", + usage: "opaque_http", + }, + ], + }, + }); + + const renderings = [ + String(epoch.secrets), + `${epoch.secrets}`, + JSON.stringify(epoch), + inspect(epoch, { depth: null }), + inspect(epoch.secrets), + ]; + for (const rendering of renderings) { + assert.equal( + rendering.includes("sk-should-never-be-printed"), + false, + `leaked the credential value through: ${rendering}`, + ); + } + }); + + // A typed model connection whose one credential carries `value` under env var `name`. + const modelConnection = ( + value: string, + name = "A", + ): AgentRunRequest["modelConnection"] => ({ + provider: "test", + deployment: "custom", + endpoint: { baseUrl: "https://model.example" }, + credentialMode: "env", + credentials: [ + { + binding: { kind: "environment", name }, + value, + usage: "opaque_http", + }, + ], + }); + it("same secrets hash equal; a changed secret value differs", () => { const a = computeCredentialEpoch({ - secrets: { A: "1" }, + modelConnection: modelConnection("1"), toolCallback: { endpoint: "e", authorization: "z" }, }); const b = computeCredentialEpoch({ - secrets: { A: "1" }, + modelConnection: modelConnection("1"), toolCallback: { endpoint: "e", authorization: "z" }, }); const c = computeCredentialEpoch({ - secrets: { A: "2" }, + modelConnection: modelConnection("2"), toolCallback: { endpoint: "e", authorization: "z" }, }); - assert.equal(a.secretsHash, b.secretsHash); - assert.notEqual( - a.secretsHash, - c.secretsHash, - "a rotated same-slug secret changes the hash", + assert.equal(a.secrets.equals(b.secrets), true); + assert.equal( + a.secrets.equals(c.secrets), + false, + "a rotated same-slug secret changes the material", ); }); @@ -567,36 +691,68 @@ describe("credential epoch", () => { // with the fresh key rather than reuse a warm session baked with the old one (design // Decision 7 — the credential epoch already covers this; no new key is needed). const parked = computeCredentialEpoch({ - secrets: { OPENAI_API_KEY: "sk-old" }, + modelConnection: modelConnection("sk-old", "OPENAI_API_KEY"), }); const rotated = computeCredentialEpoch({ - secrets: { OPENAI_API_KEY: "sk-new" }, + modelConnection: modelConnection("sk-new", "OPENAI_API_KEY"), }); - assert.notEqual(parked.secretsHash, rotated.secretsHash); + assert.equal(parked.secrets.equals(rotated.secrets), false); + assert.equal(sandboxCredentialsRotated(parked, rotated), true); assert.equal(credentialEpochValid(parked, rotated, Date.now()), false); }); - it("a re-minted tool-callback bearer does NOT change the hash (per-turn material)", () => { + it("a re-minted tool-callback bearer does NOT change the material (per-turn)", () => { // The backend re-mints the callback bearer on its auth-cache cadence (~60s); the turn's // relay always uses the incoming bearer, so a warm continue must not evict over it. const parked = computeCredentialEpoch({ - secrets: { A: "1" }, + modelConnection: modelConnection("1"), toolCallback: { endpoint: "e", authorization: "bearer-old" }, }); const incoming = computeCredentialEpoch({ - secrets: { A: "1" }, + modelConnection: modelConnection("1"), toolCallback: { endpoint: "e", authorization: "bearer-new" }, }); - assert.equal(parked.secretsHash, incoming.secretsHash); + assert.equal(parked.secrets.equals(incoming.secrets), true); + assert.equal(sandboxCredentialsRotated(parked, incoming), false); assert.equal(credentialEpochMismatch(parked, incoming), undefined); }); + it("rotates the epoch when an MCP header credential changes", () => { + const withMcp = (value: string): AgentRunRequest => ({ + mcpServers: [ + { + name: "linear", + connection: { + type: "http", + url: "https://mcp.linear.app/sse", + credentials: [ + { + binding: { kind: "header", name: "Authorization" }, + value, + usage: "opaque_http", + }, + ], + }, + policy: { tools: { mode: "all" } }, + }, + ], + }); + assert.equal( + computeCredentialEpoch(withMcp("secret-a")).secrets.equals( + computeCredentialEpoch(withMcp("secret-b")).secrets, + ), + false, + ); + }); + it("valid until the mount expiry elapses; invalid once expired", () => { const parked = { - ...computeCredentialEpoch({ secrets: { A: "1" } }), + ...computeCredentialEpoch({ modelConnection: modelConnection("1") }), mountExpiresAtMs: Date.parse("2026-01-01T00:00:10.000Z"), }; - const incoming = computeCredentialEpoch({ secrets: { A: "1" } }); + const incoming = computeCredentialEpoch({ + modelConnection: modelConnection("1"), + }); const before = Date.parse("2026-01-01T00:00:05.000Z"); const after = Date.parse("2026-01-01T00:00:15.000Z"); assert.equal(credentialEpochValid(parked, incoming, before), true); @@ -608,18 +764,26 @@ describe("credential epoch", () => { }); it("invalid when the secret material changed even if not expired", () => { - const parked = computeCredentialEpoch({ secrets: { A: "1" } }); - const incoming = computeCredentialEpoch({ secrets: { A: "2" } }); + const parked = computeCredentialEpoch({ + modelConnection: modelConnection("1"), + }); + const incoming = computeCredentialEpoch({ + modelConnection: modelConnection("2"), + }); assert.equal(credentialEpochValid(parked, incoming, Date.now()), false); }); it("credentialEpochMismatch splits the reason: expired vs rotated vs none", () => { const parked = { - ...computeCredentialEpoch({ secrets: { A: "1" } }), + ...computeCredentialEpoch({ modelConnection: modelConnection("1") }), mountExpiresAtMs: Date.parse("2026-01-01T00:00:10.000Z"), }; - const same = computeCredentialEpoch({ secrets: { A: "1" } }); - const rotated = computeCredentialEpoch({ secrets: { A: "2" } }); + const same = computeCredentialEpoch({ + modelConnection: modelConnection("1"), + }); + const rotated = computeCredentialEpoch({ + modelConnection: modelConnection("2"), + }); const before = Date.parse("2026-01-01T00:00:05.000Z"); const after = Date.parse("2026-01-01T00:00:15.000Z"); assert.equal(credentialEpochMismatch(parked, same, before), undefined); @@ -640,7 +804,7 @@ describe("credential epoch", () => { it("mountCredentialsExpired checks only the mount lifetime, ignoring the secret hash", () => { const parked = { - ...computeCredentialEpoch({ secrets: { A: "1" } }), + ...computeCredentialEpoch({ modelConnection: modelConnection("1") }), mountExpiresAtMs: Date.parse("2026-01-01T00:00:10.000Z"), }; const before = Date.parse("2026-01-01T00:00:05.000Z"); @@ -648,7 +812,9 @@ describe("credential epoch", () => { assert.equal(mountCredentialsExpired(parked, before), false); assert.equal(mountCredentialsExpired(parked, after), true); // No expiry recorded => never expired, regardless of the secret material. - const noExpiry = computeCredentialEpoch({ secrets: { A: "1" } }); + const noExpiry = computeCredentialEpoch({ + modelConnection: modelConnection("1"), + }); assert.equal(mountCredentialsExpired(noExpiry, after), false); }); }); diff --git a/services/runner/tests/unit/wire-contract.test.ts b/services/runner/tests/unit/wire-contract.test.ts index 041f72ef37..f802b52b64 100644 --- a/services/runner/tests/unit/wire-contract.test.ts +++ b/services/runner/tests/unit/wire-contract.test.ts @@ -39,13 +39,8 @@ const KNOWN_REQUEST_KEYS = [ "model", "harnessMode", "modelCapabilities", - "provider", - "connection", - "deployment", - "endpoint", - "credentialMode", + "modelConnection", "messages", - "secrets", "context", "telemetry", "runContext", @@ -268,7 +263,7 @@ describe("wire contract: requests (vs Python golden)", () => { assert.equal(req.appendSystemPrompt, undefined); // A managed codex run carries the file-free auth provider block in `.codex/config.toml` (D-002 // final ruling): the runner writes it blind; codex reads OPENAI_API_KEY from the daemon env at - // request time. The secret is NOT in the file (it rides `secrets`). + // request time. The secret is NOT in the file (it rides `modelConnection.credentials`). const files = req.harnessFiles!; assert.equal(files.length, 1); assert.equal(files[0].path, ".codex/config.toml"); @@ -276,7 +271,13 @@ describe("wire contract: requests (vs Python golden)", () => { assert.match(files[0].content, /env_key = "OPENAI_API_KEY"/); assert.equal(files[0].content.includes("sk-openai"), false); assert.equal(req.sandboxPermission, undefined); - assert.deepEqual(req.secrets, { OPENAI_API_KEY: "sk-openai" }); + assert.deepEqual(req.modelConnection?.credentials, [ + { + binding: { kind: "environment", name: "OPENAI_API_KEY" }, + value: "sk-openai", + usage: "opaque_http", + }, + ]); assert.equal( resolveRunSessionId(req, "runner-ephemeral"), "runner-ephemeral", 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) => ({