diff --git a/api/ee/src/core/access/entitlements/types.py b/api/ee/src/core/access/entitlements/types.py index 276f31f141..7e5374ec3b 100644 --- a/api/ee/src/core/access/entitlements/types.py +++ b/api/ee/src/core/access/entitlements/types.py @@ -52,7 +52,6 @@ class Counter(str, Enum): EVALUATIONS_RUN = "evaluations_run" TRACES_INGESTED = "traces_ingested" TRACES_RETRIEVED = "traces_retrieved" - CREDITS_CONSUMED = "credits_consumed" EVENTS_INGESTED = "events_ingested" RECORDS_INGESTED = "records_ingested" @@ -345,12 +344,6 @@ class Throttle(BaseModel): period=Period.DAILY, scope=Scope.USER, ), - Counter.CREDITS_CONSUMED: Quota( - free=100, - limit=100, - strict=True, - period=Period.MONTHLY, - ), Counter.EVENTS_INGESTED: Quota( retention=Retention.WEEKLY, period=Period.MONTHLY, @@ -437,12 +430,6 @@ class Throttle(BaseModel): scope=Scope.USER, period=Period.DAILY, ), - Counter.CREDITS_CONSUMED: Quota( - free=100, - limit=100, - strict=True, - period=Period.MONTHLY, - ), Counter.EVENTS_INGESTED: Quota( retention=Retention.MONTHLY, period=Period.MONTHLY, @@ -527,12 +514,6 @@ class Throttle(BaseModel): scope=Scope.USER, period=Period.DAILY, ), - Counter.CREDITS_CONSUMED: Quota( - free=100, - limit=100, - strict=True, - period=Period.MONTHLY, - ), Counter.EVENTS_INGESTED: Quota( retention=Retention.QUARTERLY, period=Period.MONTHLY, @@ -615,12 +596,6 @@ class Throttle(BaseModel): scope=Scope.USER, period=Period.DAILY, ), - Counter.CREDITS_CONSUMED: Quota( - free=100, - limit=100, - strict=True, - period=Period.MONTHLY, - ), Counter.EVENTS_INGESTED: Quota( period=Period.MONTHLY, ), @@ -655,10 +630,6 @@ class Throttle(BaseModel): scope=Scope.USER, period=Period.DAILY, ), - Counter.CREDITS_CONSUMED: Quota( - strict=True, - period=Period.MONTHLY, - ), Counter.EVENTS_INGESTED: Quota( period=Period.MONTHLY, ), @@ -701,7 +672,6 @@ class Throttle(BaseModel): Counter.EVALUATIONS_RUN, Counter.TRACES_INGESTED, Counter.TRACES_RETRIEVED, - Counter.CREDITS_CONSUMED, Counter.EVENTS_INGESTED, Counter.RECORDS_INGESTED, ], diff --git a/api/ee/src/core/meters/types.py b/api/ee/src/core/meters/types.py index 5f9aa92f77..fe5a458601 100644 --- a/api/ee/src/core/meters/types.py +++ b/api/ee/src/core/meters/types.py @@ -25,7 +25,7 @@ class Meters(str, Enum): EVALUATIONS_RUN = Counter.EVALUATIONS_RUN.value TRACES_INGESTED = Counter.TRACES_INGESTED.value TRACES_RETRIEVED = Counter.TRACES_RETRIEVED.value - CREDITS_CONSUMED = Counter.CREDITS_CONSUMED.value + CREDITS_CONSUMED = "credits_consumed" # legacy, dropped from Counter; keeps old meter rows readable EVENTS_INGESTED = Counter.EVENTS_INGESTED.value RECORDS_INGESTED = Counter.RECORDS_INGESTED.value # GAUGES diff --git a/api/entrypoints/routers.py b/api/entrypoints/routers.py index 94c78ce850..e124d80482 100644 --- a/api/entrypoints/routers.py +++ b/api/entrypoints/routers.py @@ -163,6 +163,34 @@ from oss.src.tasks.asyncio.triggers.dispatcher import TriggersDispatcher from oss.src.tasks.taskiq.triggers.worker import TriggersWorker from oss.src.tasks.taskiq.shared.broker import ProducerOnlyRedisStreamBroker + +# GATEWAYS: core/gateways/ (entities.md §9 "Wiring"). Two router objects per plane — +# management CRUD and the data plane are separate surfaces (§1). +from oss.src.dbs.postgres.gateways.llms.dao import LLMEndpointsDAO +from oss.src.dbs.postgres.gateways.mcps.dao import MCPEndpointsDAO +from oss.src.core.gateways.policy.resolution import SecretsResolver +from oss.src.core.gateways.policy.service import GatewayPolicyService +from oss.src.core.gateways.llms.registry import LLMUpstreamRegistry +from oss.src.core.gateways.llms.service import LLMGatewayService +from oss.src.core.gateways.llms.providers.mock.adapter import MockLLMAdapter +from oss.src.core.gateways.llms.providers.passthrough.adapter import ( + RelayLLMAdapter, +) +from oss.src.core.gateways.mcps.registry import MCPUpstreamRegistry +from oss.src.core.gateways.mcps.service import MCPGatewayService +from oss.src.core.gateways.mcps.providers.mock.adapter import MockMCPAdapter +from oss.src.core.gateways.mcps.providers.http.adapter import HttpMCPAdapter +from oss.src.core.gateways.mcps.oauth.client import MCPOAuthClient +from oss.src.core.gateways.mcps.oauth.service import MCPOAuthConnectService +from oss.src.apis.fastapi.gateways.llms.router import LLMGatewayRouter +from oss.src.apis.fastapi.gateways.llms.proxy import LLMGatewayProxy +from oss.src.apis.fastapi.gateways.mcps.router import MCPGatewayRouter +from oss.src.apis.fastapi.gateways.mcps.proxy import MCPGatewayProxy +from oss.src.apis.fastapi.gateways.mcps.oauth_router import MCPOAuthClientMetadataRouter + +# ComposioMCPAdapter serves the builtin namespace and has no owner in wave 1: no brokered +# target is reachable yet, so our own servers and the mocks are the whole set (D23). + from oss.src.apis.fastapi.shared.utils import SupportHeadersMiddleware from oss.src.dbs.postgres.mounts.dao import MountsDAO from oss.src.core.mounts.service import MountsService @@ -1063,6 +1091,58 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: dispatch_task=_triggers_worker.dispatch_trigger, ) +# GATEWAYS: storage and the policy core (entities.md §9 "Wiring"). The plane services, +# their registries and the routers/proxies land with WP6-WP10. +llm_endpoints_dao = LLMEndpointsDAO(engine=_transactions_engine) +mcp_endpoints_dao = MCPEndpointsDAO(engine=_transactions_engine) + +secrets_resolver = SecretsResolver( + vault_service=vault_service, +) + +gateway_policy_service = GatewayPolicyService(resolver=secrets_resolver) + +llm_gateway_service = LLMGatewayService( + llm_endpoints_dao=llm_endpoints_dao, + policy=gateway_policy_service, + resolver=secrets_resolver, + upstream_registry=LLMUpstreamRegistry( + adapters={ + "relay": RelayLLMAdapter(), + "mock": MockLLMAdapter(), + } + ), +) + +mcp_gateway_service = MCPGatewayService( + mcp_endpoints_dao=mcp_endpoints_dao, + policy=gateway_policy_service, + resolver=secrets_resolver, + connections_service=connections_service, + upstream_registry=MCPUpstreamRegistry( + adapters={ + "http": HttpMCPAdapter(), + "mock": MockMCPAdapter(), + } + ), +) + +mcp_oauth_connect_service = MCPOAuthConnectService( + vault_service=vault_service, + client=MCPOAuthClient(), + api_url=env.agenta.api_url, + secret_key=env.agenta.crypt_key, +) + +llm_gateway_router = LLMGatewayRouter(llm_gateway_service=llm_gateway_service) +llm_gateway_proxy = LLMGatewayProxy(llm_gateway_service=llm_gateway_service) +mcp_gateway_router = MCPGatewayRouter( + mcp_gateway_service=mcp_gateway_service, + oauth_connect_service=mcp_oauth_connect_service, +) +mcp_gateway_proxy = MCPGatewayProxy(mcp_gateway_service=mcp_gateway_service) +mcp_oauth_client_metadata_router = MCPOAuthClientMetadataRouter() + simple_traces = SimpleTracesRouter( simple_traces_service=simple_traces_service, ) @@ -1499,6 +1579,32 @@ async def _dispatch_detached_run(*, project_id, user_id, request) -> str: include_in_schema=False, ) +app.include_router( + router=llm_gateway_router.router, + prefix="/gateways/llms", + tags=["Gateway: LLM"], +) +app.include_router( + router=llm_gateway_proxy.router, + prefix="/gateways/llms", + include_in_schema=False, +) +app.include_router( + router=mcp_gateway_router.router, + prefix="/gateways/mcps", + tags=["Gateway: MCP"], +) +app.include_router( + router=mcp_gateway_proxy.router, + prefix="/gateways/mcps", + include_in_schema=False, +) +app.include_router( + router=mcp_oauth_client_metadata_router.router, + prefix="/gateways/mcps", + include_in_schema=False, +) + app.include_router( router=sessions.interactions.router, prefix="/sessions/interactions", diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000022_add_gateway_endpoints.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000022_add_gateway_endpoints.py new file mode 100644 index 0000000000..2b217ad70e --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000022_add_gateway_endpoints.py @@ -0,0 +1,175 @@ +"""add gateway endpoints + +Creates the two tables the gateways domain persists (entities.md §1, §3): +llms_endpoints and mcps_endpoints. Every row in both is a custom row by +construction — standard and builtin endpoints are generated, never stored (D20). + +The two new Postgres enum types (llmdeploymentkind_enum, gatewayauthscheme_enum) +use the enum member NAMES (upper-case), matching this codebase's existing +SQLAlchemy-enum convention (see secretkind_enum) rather than the lower-case +DTO values. + +secret_id is SET NULL on both tables: a dead secret must not silently delete an +endpoint's configuration (§2.1). Each endpoint names one secret, project-owned — +user-level grants are out of scope, and reopening them adds tables rather than +changing these (out-of-scope.md). + +Revision ID: oss000000022 +Revises: oss000000020 +Create Date: 2026-08-13 00:00:00.000000 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision: str = "oss000000022" +down_revision: Union[str, None] = "oss000000021" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "llms_endpoints", + sa.Column("id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("slug", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column("provider_key", sa.String(), nullable=False), + sa.Column( + "deployment_kind", + sa.Enum( + "DIRECT", + "CUSTOM", + "AZURE", + "BEDROCK", + "SAGEMAKER", + "VERTEX", + name="llmdeploymentkind_enum", + ), + nullable=False, + ), + sa.Column("secret_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("data", postgresql.JSON(none_as_null=True), nullable=True), + sa.Column("status", postgresql.JSONB(none_as_null=True), nullable=True), + sa.Column("flags", postgresql.JSONB(none_as_null=True), nullable=True), + sa.Column("tags", postgresql.JSONB(none_as_null=True), nullable=True), + sa.Column("meta", postgresql.JSON(none_as_null=True), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.func.current_timestamp(), + nullable=True, + ), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("created_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("updated_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("deleted_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.PrimaryKeyConstraint("project_id", "id"), + sa.ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["secret_id"], + ["secrets.id"], + ondelete="SET NULL", + ), + sa.UniqueConstraint( + "project_id", + "slug", + name="uq_llms_endpoints_project_slug", + ), + ) + op.create_index( + "ix_llms_endpoints_project_provider", + "llms_endpoints", + ["project_id", "provider_key"], + ) + op.create_index( + "ix_llms_endpoints_flags", + "llms_endpoints", + ["flags"], + postgresql_using="gin", + ) + + op.create_table( + "mcps_endpoints", + sa.Column("id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("project_id", sa.UUID(as_uuid=True), nullable=False), + sa.Column("slug", sa.String(), nullable=False), + sa.Column("name", sa.String(), nullable=True), + sa.Column("description", sa.String(), nullable=True), + sa.Column( + "auth_mode", + sa.Enum( + "OAUTH", + "API_KEY", + "NONE", + name="gatewayauthscheme_enum", + ), + nullable=False, + ), + sa.Column("secret_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("data", postgresql.JSON(none_as_null=True), nullable=True), + sa.Column("status", postgresql.JSONB(none_as_null=True), nullable=True), + sa.Column("flags", postgresql.JSONB(none_as_null=True), nullable=True), + sa.Column("tags", postgresql.JSONB(none_as_null=True), nullable=True), + sa.Column("meta", postgresql.JSON(none_as_null=True), nullable=True), + sa.Column( + "created_at", + sa.TIMESTAMP(timezone=True), + server_default=sa.func.current_timestamp(), + nullable=True, + ), + sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("deleted_at", sa.TIMESTAMP(timezone=True), nullable=True), + sa.Column("created_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("updated_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.Column("deleted_by_id", sa.UUID(as_uuid=True), nullable=True), + sa.PrimaryKeyConstraint("project_id", "id"), + sa.ForeignKeyConstraint( + ["project_id"], + ["projects.id"], + ondelete="CASCADE", + ), + sa.ForeignKeyConstraint( + ["secret_id"], + ["secrets.id"], + ondelete="SET NULL", + ), + sa.UniqueConstraint( + "project_id", + "slug", + name="uq_mcps_endpoints_project_slug", + ), + ) + op.create_index( + "ix_mcps_endpoints_flags", + "mcps_endpoints", + ["flags"], + postgresql_using="gin", + ) + + +def downgrade() -> None: + + op.drop_index("ix_mcps_endpoints_flags", table_name="mcps_endpoints") + op.drop_table("mcps_endpoints") + + op.drop_index("ix_llms_endpoints_flags", table_name="llms_endpoints") + op.drop_index( + "ix_llms_endpoints_project_provider", + table_name="llms_endpoints", + ) + op.drop_table("llms_endpoints") + + op.execute("DROP TYPE IF EXISTS gatewayauthscheme_enum") + op.execute("DROP TYPE IF EXISTS llmdeploymentkind_enum") diff --git a/api/oss/databases/postgres/migrations/core_oss/versions/oss000000023_llms_endpoints_provider_key_nullable.py b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000023_llms_endpoints_provider_key_nullable.py new file mode 100644 index 0000000000..ec2964f67f --- /dev/null +++ b/api/oss/databases/postgres/migrations/core_oss/versions/oss000000023_llms_endpoints_provider_key_nullable.py @@ -0,0 +1,36 @@ +"""llms_endpoints.provider_key nullable + +D34 forbids body conversion, so `select_upstream`'s `direct` branch (the one place a +stored row's `provider_key` decided anything, entities.md §2.4) is gone. The column stays — +`query_endpoints` filters on it and an upstream error names it — but a `custom` row pointed +at a self-hosted gateway no longer has to name a provider that means nothing to it. + +Revision ID: oss000000023 +Revises: oss000000021 +Create Date: 2026-08-14 00:00:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +from sqlalchemy import String + +revision: str = "oss000000023" +down_revision: Union[str, None] = "oss000000022" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +_TABLE = "llms_endpoints" +_COLUMN = "provider_key" + + +def upgrade() -> None: + op.alter_column(_TABLE, _COLUMN, existing_type=String(), nullable=True) + + +def downgrade() -> None: + # A NULL provider_key can only exist on a row written after this migration (the DTO + # requires nothing older). Label unlabeled rows rather than fail the downgrade outright. + op.execute(f"UPDATE {_TABLE} SET {_COLUMN} = 'custom' WHERE {_COLUMN} IS NULL") + op.alter_column(_TABLE, _COLUMN, existing_type=String(), nullable=False) diff --git a/api/oss/src/apis/fastapi/access/router.py b/api/oss/src/apis/fastapi/access/router.py index 521fe2fdac..97f3878763 100644 --- a/api/oss/src/apis/fastapi/access/router.py +++ b/api/oss/src/apis/fastapi/access/router.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Optional, Union +from typing import Any, Dict, List, Optional from uuid import UUID from fastapi import APIRouter, Query, HTTPException, Request @@ -8,7 +8,6 @@ from oss.src.utils.logging import get_module_logger from oss.src.utils.caching import get_cache, set_cache from oss.src.utils.context import get_auth_context, get_auth_scope -from oss.src.utils.common import is_ee from oss.src.utils.exceptions import intercept_exceptions from oss.src.core.access.permissions.types import Permission @@ -16,9 +15,6 @@ from oss.src.core.access.permissions.controls import SCOPES from oss.src.core.access.controls import get_roles -if is_ee(): - from ee.src.core.access.entitlements.service import check_entitlements, Counter - log = get_module_logger(__name__) @@ -65,30 +61,14 @@ async def _check_scope_access( async def _check_resource_access( resource_type: Optional[str] = None, -) -> Union[bool, int]: +) -> bool: allow_resource = False if resource_type == "service": allow_resource = True if resource_type == "local_secrets": - # EE meters local secret usage against the credits counter. OSS has no - # credits meter, so it just grants access to authorized callers. - if not is_ee(): - return True - - check, meter, _ = await check_entitlements( # type: ignore - key=Counter.CREDITS_CONSUMED, # type: ignore - delta=1, - ) - - if not check: - return False - - if not meter or not meter.value: - return False - - return meter.value + allow_resource = True return allow_resource @@ -211,51 +191,25 @@ async def check_permissions( resource_type=resource_type, ) - if isinstance(allow_resource, bool): - if allow_resource is False: - log.warn("Resource access denied") - await set_cache( - project_id=project_id, - user_id=user_id, - namespace="check_permissions", - key=cache_key, - value="deny", - ) - raise Deny() - - if allow_resource is True: - await set_cache( - project_id=project_id, - user_id=user_id, - namespace="check_permissions", - key=cache_key, - value="allow", - ) - return Allow(credentials_header) - - elif isinstance(allow_resource, int): - if allow_resource <= 0: - log.warn("Resource access denied") - await set_cache( - project_id=project_id, - user_id=user_id, - namespace="check_permissions", - key=cache_key, - value="deny", - ) - raise Deny() - else: - return Allow(credentials_header) - - log.warn("Resource access denied") + if not allow_resource: + log.warn("Resource access denied") + await set_cache( + project_id=project_id, + user_id=user_id, + namespace="check_permissions", + key=cache_key, + value="deny", + ) + raise Deny() + await set_cache( project_id=project_id, user_id=user_id, namespace="check_permissions", key=cache_key, - value="deny", + value="allow", ) - raise Deny() + return Allow(credentials_header) except Exception as exc: # pylint: disable=broad-except log.warn(exc) diff --git a/api/oss/src/apis/fastapi/gateways/__init__.py b/api/oss/src/apis/fastapi/gateways/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/apis/fastapi/gateways/exceptions.py b/api/oss/src/apis/fastapi/gateways/exceptions.py new file mode 100644 index 0000000000..639485ae16 --- /dev/null +++ b/api/oss/src/apis/fastapi/gateways/exceptions.py @@ -0,0 +1,149 @@ +"""Gateway exception -> HTTP mapping (entities.md §9). + +Written once, shared by both planes' routers and both data-plane proxies — tools and +triggers each duplicate `handle_adapter_exceptions()` per router, a habit not repeated +here. Modelled on `apis/fastapi/tools/router.py::handle_adapter_exceptions()`. + +Unlike the rest of the seed this file is COMPLETE, not declared: it maps exceptions the +seed itself defines and depends on no work package, so leaving it unimplemented would +leave it unowned (R1). +""" + +from functools import wraps + +from fastapi import HTTPException, status + +from oss.src.core.gateways.types import GatewayEndpointInactiveError +from oss.src.core.gateways.llms.types import ( + LLMEndpointNotFoundError, + LLMModelNotAllowedError, + LLMUpstreamError, +) +from oss.src.core.gateways.mcps.types import ( + MCPAuthRequiredError, + MCPEndpointNotFoundError, + MCPScopeInsufficientError, + MCPToolNotAllowedError, + MCPUpstreamError, +) +from oss.src.core.gateways.mcps.oauth.types import ( + MCPOAuthClientNotRegisteredError, + MCPOAuthDiscoveryError, + MCPOAuthRegistrationError, + MCPOAuthStateInvalidError, + MCPOAuthTokenExchangeError, +) +from oss.src.core.gateways.policy.types import ( + CeilingExceededError, + SecretInvalidError, + SecretNotFoundError, + EntitlementDeniedError, + PolicyDeniedError, +) + + +def handle_gateway_exceptions(): + """Map gateway domain exceptions to HTTP. + + `*NotFoundError` -> 404. `PolicyDeniedError` / `EntitlementDeniedError` -> 403. + `*NotAllowedError` -> 403. `CeilingExceededError` -> 400, its body naming the + ceiling, the requested and the allowed values (D25). `MCPAuthRequiredError` -> + 409 carrying the `GatewayConnectionRequirement` (an interaction, not a failure + — D17). `*UpstreamError` -> 424, or 502 when the upstream answered >=500 (the + 424/502 split tools and triggers already use). + + The three secret/step-up arms are not in §9's list but follow from §5: a + missing or dead secret "says you could, once someone connects", which is the + same interaction 409 as a required authorization (D17, D18). Confirm before + checkpoint A — see R11 in `open-designs.md`. + """ + + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + try: + return await func(*args, **kwargs) + except (LLMEndpointNotFoundError, MCPEndpointNotFoundError) as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=e.message, + ) from e + except GatewayEndpointInactiveError as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=e.message, + ) from e + + except (PolicyDeniedError, EntitlementDeniedError) as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=e.message, + ) from e + except (LLMModelNotAllowedError, MCPToolNotAllowedError) as e: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=e.message, + ) from e + except CeilingExceededError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "message": e.message, + "ceiling": e.ceiling, + "requested": e.requested, + "allowed": e.allowed, + }, + ) from e + except MCPAuthRequiredError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={ + "message": e.message, + "requirement": e.requirement.model_dump(mode="json"), + }, + ) from e + except MCPScopeInsufficientError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"message": e.message, "scopes": e.scopes}, + ) from e + except (SecretNotFoundError, SecretInvalidError) as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=e.message, + ) from e + except (LLMUpstreamError, MCPUpstreamError) as e: + upstream = e.status_code + raise HTTPException( + status_code=( + status.HTTP_502_BAD_GATEWAY + if upstream is not None and upstream >= 500 + else status.HTTP_424_FAILED_DEPENDENCY + ), + detail=e.detail or e.message, + ) from e + except ( + MCPOAuthDiscoveryError, + MCPOAuthRegistrationError, + MCPOAuthTokenExchangeError, + ) as e: + # The upstream authorization server, not our own dependency — + # the message carries the cause verbatim (OD21: never generic). + raise HTTPException( + status_code=status.HTTP_424_FAILED_DEPENDENCY, + detail=e.message, + ) from e + except MCPOAuthStateInvalidError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=e.message, + ) from e + except MCPOAuthClientNotRegisteredError as e: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=e.message, + ) from e + + return wrapper + + return decorator diff --git a/api/oss/src/apis/fastapi/gateways/llms/__init__.py b/api/oss/src/apis/fastapi/gateways/llms/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/apis/fastapi/gateways/llms/models.py b/api/oss/src/apis/fastapi/gateways/llms/models.py new file mode 100644 index 0000000000..fe13be559f --- /dev/null +++ b/api/oss/src/apis/fastapi/gateways/llms/models.py @@ -0,0 +1,40 @@ +"""LLM gateway management wire models (entities.md §6). + +The house triple, matching `triggers/models.py`: create/edit requests wrap the core DTO +under a named field, queries add `Windowing`, responses carry `count` plus the entity. +""" + +from typing import List, Optional + +from pydantic import BaseModel, Field + +from oss.src.core.gateways.llms.dtos import ( + LLMEndpoint, + LLMEndpointCreate, + LLMEndpointEdit, + LLMEndpointQuery, +) +from oss.src.core.shared.dtos import Windowing + + +class LLMEndpointCreateRequest(BaseModel): + endpoint: LLMEndpointCreate + + +class LLMEndpointEditRequest(BaseModel): + endpoint: LLMEndpointEdit + + +class LLMEndpointQueryRequest(BaseModel): + endpoint: Optional[LLMEndpointQuery] = None + windowing: Optional[Windowing] = None + + +class LLMEndpointResponse(BaseModel): + count: int = 0 + endpoint: Optional[LLMEndpoint] = None + + +class LLMEndpointsResponse(BaseModel): + count: int = 0 + endpoints: List[LLMEndpoint] = Field(default_factory=list) diff --git a/api/oss/src/apis/fastapi/gateways/llms/proxy.py b/api/oss/src/apis/fastapi/gateways/llms/proxy.py new file mode 100644 index 0000000000..35620b7088 --- /dev/null +++ b/api/oss/src/apis/fastapi/gateways/llms/proxy.py @@ -0,0 +1,373 @@ +"""LLM data-plane proxy (entities.md §9): the OpenAI-compatible, Responses and Messages +surfaces (D33, WP23). + +No wire models: the caller's body relays byte for byte (§7.1), on every door. Each door +only parses just enough to route (`parse_llm_call_context`, `parse_responses_call_context`, +`parse_messages_call_context`) and puts the answer back on the wire exactly as it arrived. +Endpoint resolution, the allowlist, ceilings, policy authorization and secret resolution all +live inside `LLMGatewayService.relay_chat_completion` (WP7) — one method for every door — +this file never decides whether a call is allowed, only how to shape the denial once WP7 +says no. + +Streaming audit timing is not this file's job either: `LLMGatewayService` wraps the +returned iterator and records in its own `finally` once it is exhausted. This proxy drains +`result.body` through `StreamingResponse` and holds no `GatewayPolicyService` reference — +the constructor below takes only the one service. +""" + +from typing import TYPE_CHECKING, Any, Callable, Dict, List + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse, Response, StreamingResponse + +from oss.src.apis.fastapi.gateways.llms.utils import ( + parse_llm_call_context, + parse_messages_call_context, + parse_responses_call_context, +) +from oss.src.apis.fastapi.gateways.utils import response_headers, with_code_marker +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.llms.dtos import LLMCallContext, LLMProtocol +from oss.src.core.gateways.types import GatewayEndpointInactiveError +from oss.src.core.gateways.llms.types import ( + LLMAdapterNotFoundError, + LLMEndpointNotFoundError, + LLMModelNotAllowedError, + LLMUpstreamError, +) +from oss.src.core.gateways.policy.types import ( + CeilingExceededError, + SecretInvalidError, + SecretNotFoundError, + EntitlementDeniedError, + PolicyDeniedError, +) +from oss.src.utils.context import get_auth_scope + +if TYPE_CHECKING: + # LLMGatewayService is WP7's (core/gateways/llms/service.py) and does not exist on + # this branch yet — a TYPE_CHECKING-only import keeps the annotation honest without + # making this module's import fail before WP7 lands (out of WP6's owned paths). + from oss.src.core.gateways.llms.service import LLMGatewayService + +# The caller's own secret to US — never forwarded to the upstream (§9's pseudocode). +_STRIPPED_INBOUND_HEADERS = {"authorization"} + +_DOMAIN_EXCEPTIONS = ( + GatewayEndpointInactiveError, + LLMAdapterNotFoundError, + PolicyDeniedError, + EntitlementDeniedError, + LLMModelNotAllowedError, + CeilingExceededError, + SecretNotFoundError, + SecretInvalidError, + LLMEndpointNotFoundError, + LLMUpstreamError, +) + + +# The code marker is shared with the MCP plane (`gateways/mcps/proxy.py`) via +# `gateways/utils.py::with_code_marker` — see that module for why (WP25, OD18) and why this +# delimiter. Never applied to `upstream_error`: D16 forwards the upstream's own detail +# untouched, and this surface must not inject text into it. + + +def _openai_error( + *, + status_code: int, + message: str, + error_type: str, + code: str, + marked: bool = True, + **extra: Any, +) -> JSONResponse: + rendered = with_code_marker(message, code) if marked else message + error: Dict[str, Any] = {"message": rendered, "type": error_type, "code": code} + error.update(extra) + return JSONResponse(status_code=status_code, content={"error": error}) + + +def _map_domain_exception(exc: Exception) -> JSONResponse: + """OpenAI-shaped denial (specs-wp6.md "Error shape"): `{"error": {"message", + "type", "code"}}`, `code` carrying the stable cause. Mirrors the mapping + `handle_gateway_exceptions()` (seed, apis/fastapi/gateways/exceptions.py) + applies for the management CRUD, but that decorator collapses distinct + causes sharing one HTTP status (e.g. PolicyDeniedError and + LLMModelNotAllowedError both 403) into a bare `detail` string — this + surface needs the `code` to stay distinguishable per exception type, so it + is not reused here (see this package's own report for the full reasoning). + """ + if isinstance(exc, (PolicyDeniedError, EntitlementDeniedError)): + return _openai_error( + status_code=403, + message=exc.message, + error_type="permission_error", + code="policy_denied", + ) + if isinstance(exc, GatewayEndpointInactiveError): + return _openai_error( + status_code=403, + message=exc.message, + error_type="invalid_request_error", + code="endpoint_inactive", + ) + if isinstance(exc, LLMModelNotAllowedError): + return _openai_error( + status_code=403, + message=exc.message, + error_type="invalid_request_error", + code="model_not_allowed", + ) + if isinstance(exc, CeilingExceededError): + return _openai_error( + status_code=400, + message=exc.message, + error_type="invalid_request_error", + code="ceiling_exceeded", + ceiling=exc.ceiling, + requested=exc.requested, + allowed=exc.allowed, + ) + if isinstance(exc, SecretNotFoundError): + return _openai_error( + status_code=409, + message=exc.message, + error_type="invalid_request_error", + code="secret_missing", + ) + if isinstance(exc, SecretInvalidError): + return _openai_error( + status_code=409, + message=exc.message, + error_type="invalid_request_error", + code="secret_invalid", + ) + if isinstance(exc, LLMAdapterNotFoundError): + return _openai_error( + status_code=502, + message=exc.message, + error_type="api_error", + code="adapter_not_found", + ) + if isinstance(exc, LLMEndpointNotFoundError): + return _openai_error( + status_code=404, + message=exc.message, + error_type="invalid_request_error", + code="endpoint_not_found", + ) + # LLMUpstreamError: the upstream's own detail passes through untouched (D16) — + # never replaced with a generic message. + status_code = 502 if exc.status_code is not None and exc.status_code >= 500 else 424 + return _openai_error( + status_code=status_code, + message=exc.detail or exc.message, + error_type="api_error", + code="upstream_error", + marked=False, + ) + + +class LLMGatewayProxy: + def __init__(self, *, llm_gateway_service: "LLMGatewayService") -> None: + self.service = llm_gateway_service + self.router = APIRouter() + + self.router.add_api_route( + "/standard/{provider}/v1/chat/completions", + self.chat_completions_standard, + methods=["POST"], + operation_id="llm_gateway_chat_completions_standard", + ) + self.router.add_api_route( + "/custom/{slug}/v1/chat/completions", + self.chat_completions_custom, + methods=["POST"], + operation_id="llm_gateway_chat_completions_custom", + ) + self.router.add_api_route( + "/standard/{provider}/v1/responses", + self.responses_standard, + methods=["POST"], + operation_id="llm_gateway_responses_standard", + ) + self.router.add_api_route( + "/custom/{slug}/v1/responses", + self.responses_custom, + methods=["POST"], + operation_id="llm_gateway_responses_custom", + ) + self.router.add_api_route( + "/standard/{provider}/v1/messages", + self.messages_standard, + methods=["POST"], + operation_id="llm_gateway_messages_standard", + ) + self.router.add_api_route( + "/custom/{slug}/v1/messages", + self.messages_custom, + methods=["POST"], + operation_id="llm_gateway_messages_custom", + ) + self.router.add_api_route( + "/standard/{provider}/v1/models", + self.list_models_standard, + methods=["GET"], + operation_id="llm_gateway_list_models_standard", + ) + self.router.add_api_route( + "/custom/{slug}/v1/models", + self.list_models_custom, + methods=["GET"], + operation_id="llm_gateway_list_models_custom", + ) + + # --- chat completions ---------------------------------------------------- # + + async def chat_completions_standard( + self, request: Request, provider: str + ) -> Response: + return await self._relay( + request, + namespace=GatewayEndpointNamespace.STANDARD, + name=provider, + parser=parse_llm_call_context, + protocol=LLMProtocol.CHAT_COMPLETIONS, + ) + + async def chat_completions_custom(self, request: Request, slug: str) -> Response: + return await self._relay( + request, + namespace=GatewayEndpointNamespace.CUSTOM, + name=slug, + parser=parse_llm_call_context, + protocol=LLMProtocol.CHAT_COMPLETIONS, + ) + + # --- responses -------------------------------------------------------------- # + + async def responses_standard(self, request: Request, provider: str) -> Response: + return await self._relay( + request, + namespace=GatewayEndpointNamespace.STANDARD, + name=provider, + parser=parse_responses_call_context, + protocol=LLMProtocol.RESPONSES, + ) + + async def responses_custom(self, request: Request, slug: str) -> Response: + return await self._relay( + request, + namespace=GatewayEndpointNamespace.CUSTOM, + name=slug, + parser=parse_responses_call_context, + protocol=LLMProtocol.RESPONSES, + ) + + # --- messages --------------------------------------------------------------- # + + async def messages_standard(self, request: Request, provider: str) -> Response: + return await self._relay( + request, + namespace=GatewayEndpointNamespace.STANDARD, + name=provider, + parser=parse_messages_call_context, + protocol=LLMProtocol.MESSAGES, + ) + + async def messages_custom(self, request: Request, slug: str) -> Response: + return await self._relay( + request, + namespace=GatewayEndpointNamespace.CUSTOM, + name=slug, + parser=parse_messages_call_context, + protocol=LLMProtocol.MESSAGES, + ) + + # --- the shared relay, one per door's parser/protocol (D33) ---------------- # + + async def _relay( + self, + request: Request, + *, + namespace: GatewayEndpointNamespace, + name: str, + parser: Callable[..., LLMCallContext], + protocol: LLMProtocol, + ) -> Response: + scope = get_auth_scope() + raw_body = await request.body() + + try: + context = parser(body=raw_body) + except ValueError as exc: + return _openai_error( + status_code=400, + message=str(exc), + error_type="invalid_request_error", + code="invalid_request", + ) + + caller_headers = { + k: v + for k, v in request.headers.items() + if k.lower() not in _STRIPPED_INBOUND_HEADERS + } + + try: + result = await self.service.relay_chat_completion( + scope=scope, + namespace=namespace, + name=name, + body=raw_body, + headers=caller_headers, + protocol=protocol, + ) + + if context.stream: + return StreamingResponse( + result.body, + status_code=result.status_code, + headers=response_headers(result.headers), + media_type="text/event-stream", + ) + + chunk = await anext(result.body) + except _DOMAIN_EXCEPTIONS as exc: + return _map_domain_exception(exc) + + return Response( + content=chunk, + status_code=result.status_code, + headers=response_headers(result.headers), + ) + + # --- models ---------------------------------------------------------------- # + + async def list_models_standard(self, provider: str) -> Any: + return await self._list_models( + namespace=GatewayEndpointNamespace.STANDARD, name=provider + ) + + async def list_models_custom(self, slug: str) -> Any: + return await self._list_models( + namespace=GatewayEndpointNamespace.CUSTOM, name=slug + ) + + async def _list_models( + self, *, namespace: GatewayEndpointNamespace, name: str + ) -> Any: + # Any, not Dict[str, Any]: the success path returns the OpenAI list body, + # the denial path returns a JSONResponse — FastAPI passes a Response + # instance through unprocessed either way. + scope = get_auth_scope() + + try: + slugs: List[str] = await self.service.list_models( + scope=scope, namespace=namespace, name=name + ) + except _DOMAIN_EXCEPTIONS as exc: + return _map_domain_exception(exc) + + return {"object": "list", "data": [{"id": s, "object": "model"} for s in slugs]} diff --git a/api/oss/src/apis/fastapi/gateways/llms/router.py b/api/oss/src/apis/fastapi/gateways/llms/router.py new file mode 100644 index 0000000000..125d3c8558 --- /dev/null +++ b/api/oss/src/apis/fastapi/gateways/llms/router.py @@ -0,0 +1,256 @@ +"""LLM gateway management CRUD router (entities.md §9). + +`LLMGatewayService` is WP7's — declared here only as a `TYPE_CHECKING` forward reference +so this router can be built, wired and unit-tested against a mock before WP7 lands (rule +4: "stop at the merge point"). + +The SSRF gate at registration (D28): `LLMEndpointData.route.base_url` is the LLM plane's +equivalent of the MCP plane's `data.route.base_url` — a user-typed upstream URL for a custom endpoint +(every row this router writes is custom by construction, same as MCP). Unlike the MCP url +it is optional (only some deployments set a base URL), so the gate only runs when it is +set. Gated with the no-DNS `validate_url_format_and_literal_ip` (save-time; the resolving +variant runs again at relay time in WP6) — no new guard written, exact precedent +`core/secrets/dtos.py:140`. +""" + +from typing import TYPE_CHECKING +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Request, status + +from oss.src.apis.fastapi.gateways.exceptions import handle_gateway_exceptions +from oss.src.apis.fastapi.gateways.llms.models import ( + LLMEndpointCreateRequest, + LLMEndpointEditRequest, + LLMEndpointQueryRequest, + LLMEndpointResponse, + LLMEndpointsResponse, +) +from oss.src.apis.fastapi.shared.exceptions import FORBIDDEN_EXCEPTION +from oss.src.core.access.permissions.service import check_action_access +from oss.src.core.access.permissions.types import Permission +from oss.src.core.gateways.dtos import GatewayEndpointNamespace +from oss.src.core.gateways.llms.types import LLMEndpointNotFoundError +from oss.src.core.webhooks.utils import validate_url_format_and_literal_ip +from oss.src.utils.context import AuthScope, get_auth_scope +from oss.src.utils.exceptions import intercept_exceptions + +if TYPE_CHECKING: + from oss.src.core.gateways.llms.service import LLMGatewayService + + +def _guard_custom_endpoint_base_url(*, base_url) -> None: + """SSRF gate at registration (D28) — no-DNS variant; never a leaked ValueError. + + `base_url` is optional on `LLMEndpointRoute`; only some deployments set one.""" + if not base_url: + return + try: + validate_url_format_and_literal_ip(base_url) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"endpoint.data.route.base_url is invalid: {e}", + ) from e + + +class LLMGatewayRouter: + def __init__(self, *, llm_gateway_service: "LLMGatewayService"): + self.service = llm_gateway_service + self.router = APIRouter() + + self.router.add_api_route( + "/endpoints/", + self.create_endpoint, + methods=["POST"], + operation_id="create_llm_endpoint", + response_model=LLMEndpointResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/endpoints/", + self.list_endpoints, + methods=["GET"], + operation_id="list_llm_endpoints", + response_model=LLMEndpointsResponse, + response_model_exclude_none=True, + ) + # GET /endpoints/ is the merged listing — generated + custom (§8); + # POST /endpoints/query filters rows only, because generated endpoints + # have nothing to filter on but the provider, which GET already shows. + self.router.add_api_route( + "/endpoints/query", + self.query_endpoints, + methods=["POST"], + operation_id="query_llm_endpoints", + response_model=LLMEndpointsResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/endpoints/{endpoint_id}", + self.fetch_endpoint, + methods=["GET"], + operation_id="fetch_llm_endpoint", + response_model=LLMEndpointResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/endpoints/{endpoint_id}", + self.edit_endpoint, + methods=["PUT"], + operation_id="edit_llm_endpoint", + response_model=LLMEndpointResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/endpoints/{endpoint_id}", + self.delete_endpoint, + methods=["DELETE"], + operation_id="delete_llm_endpoint", + status_code=status.HTTP_204_NO_CONTENT, + ) + + async def _check(self, scope: AuthScope, permission: Permission) -> None: + has_permission = await check_action_access( + user_uid=str(scope.user_id), + project_id=str(scope.project_id), + permission=permission, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + @intercept_exceptions() + @handle_gateway_exceptions() + async def create_endpoint( + self, + request: Request, + *, + body: LLMEndpointCreateRequest, + ) -> LLMEndpointResponse: + scope = get_auth_scope() + await self._check(scope, Permission.EDIT_LLM_ENDPOINTS) + + _guard_custom_endpoint_base_url(base_url=body.endpoint.data.route.base_url) + + endpoint = await self.service.create_endpoint( + project_id=scope.project_id, + user_id=scope.user_id, + # + endpoint=body.endpoint, + ) + + return LLMEndpointResponse(count=1 if endpoint else 0, endpoint=endpoint) + + @intercept_exceptions() + @handle_gateway_exceptions() + async def list_endpoints( + self, + request: Request, + ) -> LLMEndpointsResponse: + scope = get_auth_scope() + await self._check(scope, Permission.VIEW_LLM_ENDPOINTS) + + endpoints = await self.service.list_endpoints(scope=scope) + + return LLMEndpointsResponse(count=len(endpoints), endpoints=endpoints) + + @intercept_exceptions() + @handle_gateway_exceptions() + async def query_endpoints( + self, + request: Request, + *, + body: LLMEndpointQueryRequest, + ) -> LLMEndpointsResponse: + scope = get_auth_scope() + await self._check(scope, Permission.VIEW_LLM_ENDPOINTS) + + endpoints = await self.service.query_endpoints( + project_id=scope.project_id, + # + endpoint=body.endpoint, + # + windowing=body.windowing, + ) + + return LLMEndpointsResponse(count=len(endpoints), endpoints=endpoints) + + @intercept_exceptions() + @handle_gateway_exceptions() + async def fetch_endpoint( + self, + request: Request, + *, + endpoint_id: UUID, + ) -> LLMEndpointResponse: + scope = get_auth_scope() + await self._check(scope, Permission.VIEW_LLM_ENDPOINTS) + + endpoint = await self.service.fetch_endpoint( + project_id=scope.project_id, + # + endpoint_id=endpoint_id, + ) + if not endpoint: + raise LLMEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, + name=str(endpoint_id), + ) + + return LLMEndpointResponse(count=1, endpoint=endpoint) + + @intercept_exceptions() + @handle_gateway_exceptions() + async def edit_endpoint( + self, + request: Request, + *, + endpoint_id: UUID, + body: LLMEndpointEditRequest, + ) -> LLMEndpointResponse: + scope = get_auth_scope() + await self._check(scope, Permission.EDIT_LLM_ENDPOINTS) + + if str(endpoint_id) != str(body.endpoint.id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Path endpoint_id does not match body id", + ) + + _guard_custom_endpoint_base_url(base_url=body.endpoint.data.route.base_url) + + endpoint = await self.service.edit_endpoint( + project_id=scope.project_id, + user_id=scope.user_id, + # + endpoint=body.endpoint, + ) + if not endpoint: + raise LLMEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, + name=str(endpoint_id), + ) + + return LLMEndpointResponse(count=1, endpoint=endpoint) + + @intercept_exceptions() + @handle_gateway_exceptions() + async def delete_endpoint( + self, + request: Request, + *, + endpoint_id: UUID, + ) -> None: + scope = get_auth_scope() + await self._check(scope, Permission.EDIT_LLM_ENDPOINTS) + + deleted = await self.service.delete_endpoint( + project_id=scope.project_id, + # + endpoint_id=endpoint_id, + ) + if not deleted: + raise LLMEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, + name=str(endpoint_id), + ) diff --git a/api/oss/src/apis/fastapi/gateways/llms/utils.py b/api/oss/src/apis/fastapi/gateways/llms/utils.py new file mode 100644 index 0000000000..d6146e5cdd --- /dev/null +++ b/api/oss/src/apis/fastapi/gateways/llms/utils.py @@ -0,0 +1,45 @@ +"""LLM proxy request parsing (entities.md §9, D33 WP23). + +One pure function per front door that reads the caller's request for routing, and +nothing else — the body itself is never re-serialized so the relay stays byte for byte +(§7.1). `model` and `stream` share field names across Chat Completions, Responses and +Messages, so the three parsers below differ only in the `LLMCallContext.protocol` they +stamp — three small functions per D33's "one door, one parser", not one generic function +branching on a protocol argument. +""" + +import json +from typing import Any, Dict + +from oss.src.core.gateways.llms.dtos import LLMCallContext, LLMProtocol + + +def _parse(*, body: bytes, protocol: LLMProtocol) -> LLMCallContext: + payload: Dict[str, Any] = json.loads(body) if body else {} + + model = payload.get("model") if isinstance(payload, dict) else None + if not model or not isinstance(model, str): + raise ValueError("request body names no model") + + stream = bool(payload.get("stream", False)) if isinstance(payload, dict) else False + + return LLMCallContext(model=model, stream=stream, protocol=protocol) + + +def parse_llm_call_context(*, body: bytes) -> LLMCallContext: + """Chat Completions (`/v1/chat/completions`). Raises ValueError when the body + names no model; the proxy translates that into the surface's own + invalid-request error shape.""" + return _parse(body=body, protocol=LLMProtocol.CHAT_COMPLETIONS) + + +def parse_responses_call_context(*, body: bytes) -> LLMCallContext: + """OpenAI Responses (`/v1/responses`). Same `model`/`stream` fields as Chat + Completions; tagged RESPONSES so the ceiling binds to `max_output_tokens`.""" + return _parse(body=body, protocol=LLMProtocol.RESPONSES) + + +def parse_messages_call_context(*, body: bytes) -> LLMCallContext: + """Anthropic Messages (`/v1/messages`). Same `model`/`stream` fields again; + tagged MESSAGES so the ceiling binds to `max_tokens`.""" + return _parse(body=body, protocol=LLMProtocol.MESSAGES) diff --git a/api/oss/src/apis/fastapi/gateways/mcps/__init__.py b/api/oss/src/apis/fastapi/gateways/mcps/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/oss/src/apis/fastapi/gateways/mcps/models.py b/api/oss/src/apis/fastapi/gateways/mcps/models.py new file mode 100644 index 0000000000..9e6b9f9ea7 --- /dev/null +++ b/api/oss/src/apis/fastapi/gateways/mcps/models.py @@ -0,0 +1,54 @@ +"""MCP gateway management wire models (entities.md §6). + +The house triple, matching `triggers/models.py`, plus the connect shapes. + +""" + +from typing import List, Optional + +from pydantic import BaseModel, Field + +from oss.src.core.gateways.mcps.dtos import ( + MCPEndpoint, + MCPEndpointCreate, + MCPEndpointEdit, + MCPEndpointQuery, +) +from oss.src.core.shared.dtos import Windowing + + +class MCPEndpointCreateRequest(BaseModel): + endpoint: MCPEndpointCreate + + +class MCPEndpointEditRequest(BaseModel): + endpoint: MCPEndpointEdit + + +class MCPEndpointQueryRequest(BaseModel): + endpoint: Optional[MCPEndpointQuery] = None + windowing: Optional[Windowing] = None + + +class MCPEndpointResponse(BaseModel): + count: int = 0 + endpoint: Optional[MCPEndpoint] = None + + +class MCPEndpointsResponse(BaseModel): + count: int = 0 + endpoints: List[MCPEndpoint] = Field(default_factory=list) + + +class MCPConnectRequest(BaseModel): + """Drives the two-step consent flow (specs-wp18.md). `scopes: None` (absent) + is the discover step — nothing chosen yet, the response carries the checklist. + `scopes` present (an empty list is a legal "no scopes") is the begin step.""" + + scopes: Optional[List[str]] = None + + +class MCPConnectResponse(BaseModel): + count: int = 0 + redirect_url: Optional[str] = None + scopes_offered: List[str] = Field(default_factory=list) diff --git a/api/oss/src/apis/fastapi/gateways/mcps/oauth_router.py b/api/oss/src/apis/fastapi/gateways/mcps/oauth_router.py new file mode 100644 index 0000000000..4cc4bca1b5 --- /dev/null +++ b/api/oss/src/apis/fastapi/gateways/mcps/oauth_router.py @@ -0,0 +1,31 @@ +"""Serves the OAuth client identity document (specs-wp20.md). + +Fetched by an authorization server, never by an authenticated caller — the path is +listed in `middlewares/auth.py`'s `_PUBLIC_ENDPOINTS`. One static, deployment-wide +document; nothing here reads a project, a secret, or any request state. +""" + +from fastapi import APIRouter + +from oss.src.core.gateways.mcps.oauth.registration import client_metadata_document +from oss.src.core.gateways.mcps.oauth.service import callback_redirect_uri +from oss.src.utils.env import env + + +class MCPOAuthClientMetadataRouter: + def __init__(self) -> None: + self.router = APIRouter() + self.router.add_api_route( + "/oauth/client-metadata.json", + self.get_client_metadata, + methods=["GET"], + operation_id="get_mcp_oauth_client_metadata", + include_in_schema=False, + ) + + async def get_client_metadata(self) -> dict: + redirect_uri = callback_redirect_uri(api_url=env.agenta.api_url) + document = client_metadata_document( + api_url=env.agenta.api_url, redirect_uri=redirect_uri + ) + return document.model_dump(mode="json", exclude_none=True) diff --git a/api/oss/src/apis/fastapi/gateways/mcps/proxy.py b/api/oss/src/apis/fastapi/gateways/mcps/proxy.py new file mode 100644 index 0000000000..32823d3779 --- /dev/null +++ b/api/oss/src/apis/fastapi/gateways/mcps/proxy.py @@ -0,0 +1,338 @@ +"""`MCPGatewayProxy`: the MCP data plane's protocol surface (entities.md §9). + +Three thin routes, one per namespace (D27); they exist because the routes carry different +path parameters, not because the behaviour differs. Each parses the caller's routing +headers, reads the raw body, and delegates to `MCPGatewayService.relay` (WP9), which owns +target resolution, the allowlist check, secret resolution and the outbound guard. No +wire models here — the data plane relays bytes (§6). + +**Error mapping is NOT `apis/fastapi/gateways/exceptions.py::handle_gateway_exceptions()`.** +That decorator (seed, R1) raises a plain `HTTPException(status_code, detail=str)`, which is +right for WP10's CRUD routers (they speak the house wire) and wrong here: it collapses every +cause that shares a status into one indistinguishable message — `MCPEndpointNotFoundError` +and `SecretNotFoundError` both become an opaque `HTTPException` a caller cannot tell +apart. §9 requires the opposite of a proxy: "the MCP proxy answers protocol-shaped errors at +the transport status the relay produced, and gateway-authored refusals as the protocol's +error result with the same stable causes in the error data." So this module keeps its own +exception -> response mapping, `_map_gateway_exception`, producing a JSON-RPC error result +carrying a stable `cause` string in `error.data` — never the house `{code,message, +retryable,...}` envelope, which must not leak onto this surface. The HTTP status each cause +takes is still exactly what `handle_gateway_exceptions()`'s table assigns; only the body +shape and the added cause differ. The upstream's own protocol-level error is untouched by +any of this: `HttpMCPAdapter` never raises for a non-2xx status or a JSON-RPC `error` body +(D16), so it reaches the caller as `MCPRelayResult` pass-through, not through this mapping. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING, Any, Dict, Optional + +from fastapi import APIRouter, Request, Response, status + +from oss.src.apis.fastapi.gateways.mcps.utils import ( + parse_mcp_call_context, + split_builtin_path, +) +from oss.src.apis.fastapi.gateways.utils import response_headers, with_code_marker +from oss.src.core.gateways.dtos import ( + GatewayConnectAffordance, + GatewayEndpointNamespace, +) +from oss.src.core.gateways.types import GatewayEndpointInactiveError +from oss.src.core.gateways.mcps.types import ( + MCPAuthRequiredError, + MCPEndpointNotFoundError, + MCPScopeInsufficientError, + MCPToolNotAllowedError, + MCPUpstreamError, +) +from oss.src.core.gateways.policy.types import ( + CeilingExceededError, + SecretInvalidError, + SecretNotFoundError, + EntitlementDeniedError, + PolicyDeniedError, +) +from oss.src.utils.context import get_auth_scope +from oss.src.utils.exceptions import intercept_exceptions + +if TYPE_CHECKING: + from oss.src.core.gateways.mcps.service import MCPGatewayService + +# JSON-RPC 2.0 reserved codes: -32600 is "Invalid Request" (our own pre-relay header +# validation); -32000 is the start of the implementation-defined "Server error" range, +# used for every gateway-authored refusal below (the numeric code carries no further +# meaning — the stable `cause` string in `error.data` is what a caller branches on). +_JSONRPC_INVALID_REQUEST = -32600 +_JSONRPC_SERVER_ERROR = -32000 + +# Every exception `_map_gateway_exception` knows how to translate. `_relay` catches +# exactly this tuple; anything else is a bug, not a gateway refusal, and is left to +# `intercept_exceptions()`'s generic path. +_MAPPED_EXCEPTIONS = ( + GatewayEndpointInactiveError, + ValueError, + MCPEndpointNotFoundError, + PolicyDeniedError, + EntitlementDeniedError, + MCPToolNotAllowedError, + CeilingExceededError, + MCPAuthRequiredError, + MCPScopeInsufficientError, + SecretNotFoundError, + SecretInvalidError, + MCPUpstreamError, +) + + +def _forwarded_headers(request: Request) -> Dict[str, str]: + """The caller's headers, stripped of Agenta's own gateway-token authorization — + an upstream `custom` server must never see the platform secret that authenticated + the caller to us (§7.1's pass-through rule stops at the body and status, not our + own credentials).""" + return { + key: value + for key, value in request.headers.items() + if key.lower() != "authorization" + } + + +def _protocol_error( + *, + status_code: int, + code: int, + message: str, + cause: str, + data: Optional[Dict[str, Any]] = None, + marked: bool = True, +) -> Response: + """A JSON-RPC error result. `id` is always `null`: this proxy never parses the + caller's body, only its headers (§9), so the request id a spec-faithful echo would + need is simply unavailable — the same situation JSON-RPC 2.0 reserves a null id + for (an error raised before the id could be read). + + `message` carries the code marker (WP25, OD18; `gateways/utils.py::with_code_marker`) + for every cause except `upstream_error` — same reasoning and same exclusion as the LLM + plane's `_openai_error`: `cause` already rides structured in `error.data`, so the marker + is redundant here whenever a harness's SDK keeps that structure, and load-bearing only + for one that keeps `message` alone and discards everything else (Codex).""" + rendered = with_code_marker(message, cause) if marked else message + error_data = {"cause": cause, **(data or {})} + payload = { + "jsonrpc": "2.0", + "id": None, + "error": {"code": code, "message": rendered, "data": error_data}, + } + return Response( + content=json.dumps(payload).encode(), + status_code=status_code, + media_type="application/json", + ) + + +def _map_gateway_exception(e: BaseException) -> Response: + if isinstance(e, ValueError): + return _protocol_error( + status_code=status.HTTP_400_BAD_REQUEST, + code=_JSONRPC_INVALID_REQUEST, + message=str(e), + cause="invalid_request", + ) + if isinstance(e, MCPEndpointNotFoundError): + return _protocol_error( + status_code=status.HTTP_404_NOT_FOUND, + code=_JSONRPC_SERVER_ERROR, + message=e.message, + cause="endpoint_not_found", + data={"namespace": e.namespace.value, "name": e.name}, + ) + if isinstance(e, PolicyDeniedError): + return _protocol_error( + status_code=status.HTTP_403_FORBIDDEN, + code=_JSONRPC_SERVER_ERROR, + message=e.message, + cause="policy_denied", + ) + if isinstance(e, EntitlementDeniedError): + return _protocol_error( + status_code=status.HTTP_403_FORBIDDEN, + code=_JSONRPC_SERVER_ERROR, + message=e.message, + cause="entitlement_denied", + ) + if isinstance(e, MCPToolNotAllowedError): + return _protocol_error( + status_code=status.HTTP_403_FORBIDDEN, + code=_JSONRPC_SERVER_ERROR, + message=e.message, + cause="tool_not_allowed", + data={"tool": e.tool}, + ) + if isinstance(e, CeilingExceededError): + return _protocol_error( + status_code=status.HTTP_400_BAD_REQUEST, + code=_JSONRPC_SERVER_ERROR, + message=e.message, + cause="ceiling_exceeded", + data={ + "ceiling": e.ceiling, + "requested": e.requested, + "allowed": e.allowed, + }, + ) + if isinstance(e, MCPAuthRequiredError): + return _protocol_error( + status_code=status.HTTP_409_CONFLICT, + code=_JSONRPC_SERVER_ERROR, + message=e.message, + cause="auth_required", + data={"requirement": e.requirement.model_dump(mode="json")}, + ) + if isinstance(e, MCPScopeInsufficientError): + scope_data: Dict[str, Any] = {"target": e.target, "scopes": e.scopes} + if e.endpoint_id is not None: + # Step-up reuses the missing-connection interaction (D17): the same + # connect route WP18 built, re-run with a wider scope choice. `body: {}` + # points at step 1 (discover) so the dialog re-offers the current scope + # set rather than this refusal guessing which ones matter (WP25: a + # marker-only recovery never carries `e.scopes` this far anyway). + scope_data["connect"] = GatewayConnectAffordance( + endpoint=f"/gateways/mcps/endpoints/{e.endpoint_id}/connect", + body={}, + ).model_dump(mode="json") + return _protocol_error( + status_code=status.HTTP_409_CONFLICT, + code=_JSONRPC_SERVER_ERROR, + message=e.message, + cause="scope_insufficient", + data=scope_data, + ) + if isinstance(e, GatewayEndpointInactiveError): + return _protocol_error( + status_code=status.HTTP_403_FORBIDDEN, + code=_JSONRPC_SERVER_ERROR, + message=e.message, + cause="endpoint_inactive", + ) + if isinstance(e, SecretNotFoundError): + return _protocol_error( + status_code=status.HTTP_409_CONFLICT, + code=_JSONRPC_SERVER_ERROR, + message=e.message, + cause="secret_missing", + ) + if isinstance(e, SecretInvalidError): + return _protocol_error( + status_code=status.HTTP_409_CONFLICT, + code=_JSONRPC_SERVER_ERROR, + message=e.message, + cause="secret_invalid", + ) + if isinstance(e, MCPUpstreamError): + upstream_status = e.status_code + return _protocol_error( + status_code=( + status.HTTP_502_BAD_GATEWAY + if upstream_status is not None and upstream_status >= 500 + else status.HTTP_424_FAILED_DEPENDENCY + ), + code=_JSONRPC_SERVER_ERROR, + message=e.detail or e.message, + cause="upstream_error", + data={"target": e.target}, + marked=False, + ) + raise e # pragma: no cover - unreachable: _MAPPED_EXCEPTIONS stays exhaustive with this + + +class MCPGatewayProxy: + def __init__(self, *, mcp_gateway_service: "MCPGatewayService") -> None: + self.service = mcp_gateway_service + self.router = APIRouter() + + self.router.add_api_route( + "/builtin/{provider}/{rest:path}", + self.relay_builtin, + methods=["POST"], + operation_id="mcp_gateway_relay_builtin", + ) + self.router.add_api_route( + "/custom/{slug}", + self.relay_custom, + methods=["POST"], + operation_id="mcp_gateway_relay_custom", + ) + + for path in ( + "/builtin/{provider}/{rest:path}", + "/custom/{slug}", + ): + self.router.add_api_route( + path, + self.reject_stream_verbs, + methods=["GET", "DELETE"], + include_in_schema=False, + ) + + async def _relay( + self, + *, + request: Request, + namespace: GatewayEndpointNamespace, + name: str, + provider: Optional[str] = None, + integration: Optional[str] = None, + ) -> Response: + scope = get_auth_scope() + headers = _forwarded_headers(request) + + try: + context = parse_mcp_call_context(headers=headers) + body = await request.body() + + result = await self.service.relay( + scope=scope, + namespace=namespace, + name=name, + provider=provider, + integration=integration, + # + context=context, + body=body, + headers=headers, + ) + except _MAPPED_EXCEPTIONS as e: + return _map_gateway_exception(e) + + return Response( + content=result.body, + status_code=result.status_code, + headers=response_headers(result.headers), + ) + + @intercept_exceptions() + async def relay_builtin( + self, request: Request, provider: str, rest: str + ) -> Response: + # Each builtin provider owns the grammar under its own segment (D30): composio + # addresses a connection as {integration}/{connection}, agenta a bare slug. + integration, name = split_builtin_path(provider=provider, rest=rest) + return await self._relay( + request=request, + namespace=GatewayEndpointNamespace.BUILTIN, + name=name, + provider=provider, + integration=integration, + ) + + @intercept_exceptions() + async def relay_custom(self, request: Request, slug: str) -> Response: + return await self._relay( + request=request, + namespace=GatewayEndpointNamespace.CUSTOM, + name=slug, + ) + + async def reject_stream_verbs(self) -> Response: + return Response(status_code=status.HTTP_405_METHOD_NOT_ALLOWED) diff --git a/api/oss/src/apis/fastapi/gateways/mcps/router.py b/api/oss/src/apis/fastapi/gateways/mcps/router.py new file mode 100644 index 0000000000..068b7d0086 --- /dev/null +++ b/api/oss/src/apis/fastapi/gateways/mcps/router.py @@ -0,0 +1,559 @@ +"""MCP gateway management CRUD router (entities.md §9). + +`MCPGatewayService` is WP9's — declared here only as a `TYPE_CHECKING` forward reference +so this router can be built, wired and unit-tested against a mock before WP9 lands (rule +4: "stop at the merge point"). `MCPOAuthConnectService` is WP17's, consumed the same way. + +`connect_endpoint` (`POST /endpoints/{endpoint_id}/connect`) and `connect_callback` +(`GET /connect/callback`) are WP18's own two routes (specs-wp18.md) — the two-step +consent flow (discover, then begin) and the unauthenticated authorization-server +callback that completes it. + +The SSRF gate at registration (D28): every `custom` endpoint this router can create or edit +carries a user-typed `data.route.base_url` the gateway will later connect to (`MCPEndpointCreate`/ +`MCPEndpointEdit` have no `namespace` field and no way to express `provider_key`/ +`integration_key`, so every row this router writes is `custom` by construction — same as +the DAO's own "every row is custom by construction" invariant). Gated here with the no-DNS +`validate_url_format_and_literal_ip` (save-time; the resolving variant runs again at relay +time in WP8) — no new guard written, exact precedent `core/secrets/dtos.py:140`. +""" + +import html as html_lib +import json +from typing import TYPE_CHECKING, Any, Dict, Optional +from urllib.parse import urlsplit +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Query, Request, status +from fastapi.responses import HTMLResponse + +from oss.src.apis.fastapi.gateways.exceptions import handle_gateway_exceptions +from oss.src.apis.fastapi.gateways.mcps.models import ( + MCPConnectRequest, + MCPConnectResponse, + MCPEndpointCreateRequest, + MCPEndpointEditRequest, + MCPEndpointQueryRequest, + MCPEndpointResponse, + MCPEndpointsResponse, +) +from oss.src.apis.fastapi.shared.exceptions import FORBIDDEN_EXCEPTION +from oss.src.core.access.permissions.service import check_action_access +from oss.src.core.access.permissions.types import Permission +from oss.src.core.gateways.dtos import GatewayAuthScheme, GatewayEndpointNamespace +from oss.src.core.gateways.mcps.dtos import MCPEndpoint, MCPEndpointEdit, MCPOAuthData +from oss.src.core.gateways.mcps.oauth.state import decode_state +from oss.src.core.gateways.mcps.types import MCPEndpointNotFoundError +from oss.src.core.gateways.types import GatewaysError +from oss.src.core.webhooks.utils import validate_url_format_and_literal_ip +from oss.src.utils.context import AuthScope, get_auth_scope +from oss.src.utils.env import env +from oss.src.utils.exceptions import intercept_exceptions + +if TYPE_CHECKING: + from oss.src.core.gateways.mcps.service import MCPGatewayService + from oss.src.core.gateways.mcps.oauth.service import MCPOAuthConnectService + + +def _guard_custom_endpoint_url(*, url: Optional[str]) -> None: + """SSRF gate at registration (D28) — no-DNS variant; never a leaked ValueError.""" + if not url: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="endpoint.data.route.base_url is required", + ) + try: + validate_url_format_and_literal_ip(url) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"endpoint.data.route.base_url is invalid: {e}", + ) from e + + +def _as_edit( + endpoint: MCPEndpoint, *, secret_id: Optional[UUID] = None +) -> MCPEndpointEdit: + """`endpoint.data` already carries any in-place mutation (e.g. the discovery + cache) the caller made before calling this — full-PUT through the one door + (entities.md §9), never a partial patch.""" + return MCPEndpointEdit( + id=endpoint.id, + name=endpoint.name, + description=endpoint.description, + auth_mode=endpoint.auth_mode, + secret_id=secret_id if secret_id is not None else endpoint.secret_id, + data=endpoint.data, + flags=endpoint.flags, + ) + + +class MCPGatewayRouter: + def __init__( + self, + *, + mcp_gateway_service: "MCPGatewayService", + oauth_connect_service: "MCPOAuthConnectService", + ): + self.service = mcp_gateway_service + self.oauth_connect_service = oauth_connect_service + self.router = APIRouter() + + self.router.add_api_route( + "/endpoints/", + self.create_endpoint, + methods=["POST"], + operation_id="create_mcp_endpoint", + response_model=MCPEndpointResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/endpoints/", + self.list_endpoints, + methods=["GET"], + operation_id="list_mcp_endpoints", + response_model=MCPEndpointsResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/endpoints/query", + self.query_endpoints, + methods=["POST"], + operation_id="query_mcp_endpoints", + response_model=MCPEndpointsResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/endpoints/{endpoint_id}", + self.fetch_endpoint, + methods=["GET"], + operation_id="fetch_mcp_endpoint", + response_model=MCPEndpointResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/endpoints/{endpoint_id}", + self.edit_endpoint, + methods=["PUT"], + operation_id="edit_mcp_endpoint", + response_model=MCPEndpointResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/endpoints/{endpoint_id}", + self.delete_endpoint, + methods=["DELETE"], + operation_id="delete_mcp_endpoint", + status_code=status.HTTP_204_NO_CONTENT, + ) + self.router.add_api_route( + "/endpoints/{endpoint_id}/connect", + self.connect_endpoint, + methods=["POST"], + operation_id="connect_mcp_endpoint", + response_model=MCPConnectResponse, + response_model_exclude_none=True, + ) + self.router.add_api_route( + "/connect/callback", + self.connect_callback, + methods=["GET"], + operation_id="mcp_connect_callback", + include_in_schema=False, + ) + + async def _check(self, scope: AuthScope, permission: Permission) -> None: + has_permission = await check_action_access( + user_uid=str(scope.user_id), + project_id=str(scope.project_id), + permission=permission, + ) + if not has_permission: + raise FORBIDDEN_EXCEPTION + + @intercept_exceptions() + @handle_gateway_exceptions() + async def create_endpoint( + self, + request: Request, + *, + body: MCPEndpointCreateRequest, + ) -> MCPEndpointResponse: + scope = get_auth_scope() + await self._check(scope, Permission.EDIT_MCP_ENDPOINTS) + + _guard_custom_endpoint_url(url=body.endpoint.data.route.base_url) + + endpoint = await self.service.create_endpoint( + project_id=scope.project_id, + user_id=scope.user_id, + # + endpoint=body.endpoint, + ) + + return MCPEndpointResponse(count=1 if endpoint else 0, endpoint=endpoint) + + @intercept_exceptions() + @handle_gateway_exceptions() + async def list_endpoints( + self, + request: Request, + ) -> MCPEndpointsResponse: + scope = get_auth_scope() + await self._check(scope, Permission.VIEW_MCP_ENDPOINTS) + + endpoints = await self.service.list_endpoints(scope=scope) + + return MCPEndpointsResponse(count=len(endpoints), endpoints=endpoints) + + @intercept_exceptions() + @handle_gateway_exceptions() + async def query_endpoints( + self, + request: Request, + *, + body: MCPEndpointQueryRequest, + ) -> MCPEndpointsResponse: + scope = get_auth_scope() + await self._check(scope, Permission.VIEW_MCP_ENDPOINTS) + + endpoints = await self.service.query_endpoints( + project_id=scope.project_id, + # + endpoint=body.endpoint, + # + windowing=body.windowing, + ) + + return MCPEndpointsResponse(count=len(endpoints), endpoints=endpoints) + + @intercept_exceptions() + @handle_gateway_exceptions() + async def fetch_endpoint( + self, + request: Request, + *, + endpoint_id: UUID, + ) -> MCPEndpointResponse: + scope = get_auth_scope() + await self._check(scope, Permission.VIEW_MCP_ENDPOINTS) + + endpoint = await self.service.fetch_endpoint( + project_id=scope.project_id, + # + endpoint_id=endpoint_id, + ) + if not endpoint: + raise MCPEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, + name=str(endpoint_id), + ) + + return MCPEndpointResponse(count=1, endpoint=endpoint) + + @intercept_exceptions() + @handle_gateway_exceptions() + async def edit_endpoint( + self, + request: Request, + *, + endpoint_id: UUID, + body: MCPEndpointEditRequest, + ) -> MCPEndpointResponse: + scope = get_auth_scope() + await self._check(scope, Permission.EDIT_MCP_ENDPOINTS) + + if str(endpoint_id) != str(body.endpoint.id): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Path endpoint_id does not match body id", + ) + + _guard_custom_endpoint_url(url=body.endpoint.data.route.base_url) + + endpoint = await self.service.edit_endpoint( + project_id=scope.project_id, + user_id=scope.user_id, + # + endpoint=body.endpoint, + ) + if not endpoint: + raise MCPEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, + name=str(endpoint_id), + ) + + return MCPEndpointResponse(count=1, endpoint=endpoint) + + @intercept_exceptions() + @handle_gateway_exceptions() + async def delete_endpoint( + self, + request: Request, + *, + endpoint_id: UUID, + ) -> None: + scope = get_auth_scope() + await self._check(scope, Permission.EDIT_MCP_ENDPOINTS) + + deleted = await self.service.delete_endpoint( + project_id=scope.project_id, + # + endpoint_id=endpoint_id, + ) + if not deleted: + raise MCPEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, + name=str(endpoint_id), + ) + + # --- the consent flow (WP18, specs-wp18.md) ------------------------------ # + + @intercept_exceptions() + @handle_gateway_exceptions() + async def connect_endpoint( + self, + request: Request, + *, + endpoint_id: UUID, + body: MCPConnectRequest, + ) -> MCPConnectResponse: + """One route, two steps. `body.scopes is None` -> discover and cache the + checklist onto the row; a list (possibly empty) -> begin and return the + authorization redirect (specs-wp18.md).""" + scope = get_auth_scope() + await self._check(scope, Permission.EDIT_MCP_ENDPOINTS) + + endpoint = await self.service.fetch_endpoint( + project_id=scope.project_id, + # + endpoint_id=endpoint_id, + ) + if not endpoint: + raise MCPEndpointNotFoundError( + namespace=GatewayEndpointNamespace.CUSTOM, + name=str(endpoint_id), + ) + if ( + endpoint.namespace != GatewayEndpointNamespace.CUSTOM + or endpoint.auth_mode != GatewayAuthScheme.OAUTH + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="endpoint is not a custom OAuth target", + ) + + server_url = endpoint.data.route.base_url + if not server_url: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="endpoint.data.route.base_url is required", + ) + + if body.scopes is None: + discovery = await self.oauth_connect_service.discover(server_url=server_url) + + endpoint.data.oauth = MCPOAuthData( + resource=discovery.resource, + authorization_server=discovery.authorization_server, + scopes_offered=discovery.scopes_offered, + ) + await self.service.edit_endpoint( + project_id=scope.project_id, + user_id=scope.user_id, + # + endpoint=_as_edit(endpoint), + ) + + return MCPConnectResponse(count=1, scopes_offered=discovery.scopes_offered) + + start = await self.oauth_connect_service.begin( + project_id=scope.project_id, + user_id=scope.user_id, + server_url=server_url, + scopes=body.scopes, + ) + + return MCPConnectResponse(count=1, redirect_url=start.authorization_url) + + async def connect_callback( + self, + request: Request, + *, + code: Optional[str] = Query(default=None), + state: Optional[str] = Query(default=None), + error: Optional[str] = Query(default=None), + error_description: Optional[str] = Query(default=None), + ) -> HTMLResponse: + """Unauthenticated: the browser lands here straight from the authorization + server, not from an authenticated Agenta API call — every fact needed comes + out of the signed `state` (specs-wp18.md, mirroring + `tools/router.py::callback_connection`).""" + if error: + return HTMLResponse( + status_code=400, + content=_connect_card(success=False, error=error_description or error), + ) + if not state: + return HTMLResponse( + status_code=400, + content=_connect_card(success=False, error="Missing state parameter."), + ) + if not code: + return HTMLResponse( + status_code=400, + content=_connect_card( + success=False, error="Missing authorization code." + ), + ) + + state_payload = decode_state(state, secret_key=env.agenta.crypt_key) + if state_payload is None: + return HTMLResponse( + status_code=400, + content=_connect_card( + success=False, error="OAuth state is invalid or expired." + ), + ) + + try: + completion = await self.oauth_connect_service.complete( + code=code, state=state + ) + except GatewaysError as e: + return HTMLResponse( + status_code=400, content=_connect_card(success=False, error=e.message) + ) + + user_id = UUID(state_payload["user_id"]) + + endpoints = await self.service.query_endpoints(project_id=completion.project_id) + target = next( + ( + e + for e in endpoints + if e.data.route.base_url == completion.server_url + and e.auth_mode == GatewayAuthScheme.OAUTH + ), + None, + ) + if target is None: + return HTMLResponse( + status_code=400, + content=_connect_card( + success=False, + error="No matching MCP endpoint found for this server.", + ), + ) + + await self.service.edit_endpoint( + project_id=completion.project_id, + user_id=user_id, + # + endpoint=_as_edit(target, secret_id=completion.secret_id), + ) + + return HTMLResponse( + status_code=200, + content=_connect_card(success=True, agenta_url=env.agenta.web_url), + ) + + +def _json_for_inline_script(value: Any) -> str: + # `` inside a JSON string would terminate the block early — escape it, + # same precaution as `tools/router.py::_json_for_inline_script`. + return json.dumps(value).replace("<", "\\u003c") + + +def _connect_card( + *, + success: bool, + error: Optional[str] = None, + agenta_url: Optional[str] = None, +) -> str: + """A small self-contained HTML page for the browser landing on the callback + directly — trimmed from `tools/router.py::_oauth_card`'s Composio card to what + the MCP consent flow needs (specs-wp18.md). Posts `mcp:oauth:connected` to + `window.opener` so a popup-driven dashboard reacts without polling.""" + safe_error = html_lib.escape(error) if error else None + agenta_origin = None + if agenta_url: + parsed = urlsplit(agenta_url) + if parsed.scheme and parsed.netloc: + agenta_origin = f"{parsed.scheme}://{parsed.netloc}" + agenta_post_message_origin_js = _json_for_inline_script(agenta_origin) + + payload: Dict[str, Any] = {"type": "mcp:oauth:connected", "success": success} + oauth_complete_message_js = _json_for_inline_script(payload) + + accent = "#16a34a" if success else "#dc2626" + icon = "✓" if success else "✕" + if success: + heading_html = '
The MCP server is connected.
' + else: + heading_html = f'{safe_error or "Something went wrong."}
' + auto_return_html = ( + 'This tab will close automatically in 3 seconds...
' # noqa: E501 + if success + else "" + ) + + return f""" + + + + +⟧`, `_with_code_marker`, `proxy.py`), and `gateway-error.ts` scans for it
+as a **fallback**, after the JSON-body parse:
+
+- **U+27E6/U+27E7** (MATHEMATICAL LEFT/RIGHT WHITE SQUARE BRACKET) were picked because they
+ never occur in ordinary error prose, a model's own output, JSON delimiters (`{}`/`[]`), or
+ markdown — nothing else can produce this exact byte sequence or be mistaken for it, and it
+ cannot collide with the separate `{...}` body scan (different bracket characters entirely).
+- **The body path stays primary.** It carries `retryable`, `next_step` and `details`, none of
+ which a bare code can express; the marker path recovers `code` only.
+- **Excluded from `upstream_error`.** D16 forwards the upstream's own detail untouched, and
+ this surface must not inject text into a body it promised not to touch.
+- **A real gap surfaced along the way.** Building the marker meant rendering every typed
+ refusal's message, which required enumerating them — and `SecretInvalidError` (the LLM
+ plane's actual "rejected credential": a secret that exists but is revoked or failed refresh,
+ `policy/types.py`) turned out to be raised by the shared resolver but never caught by
+ `_map_domain_exception` OR listed in `_DOMAIN_EXCEPTIONS`. It would have reached the caller
+ as an unhandled 500, not a typed refusal at all. Fixed alongside the marker: `secret_invalid`
+ is now mapped (409, same status family as `secret_missing`) and carries the marker like every
+ other typed code.
+
+**What is still lost on a marker-only harness.** `retryable` and `next_step` and `details` do
+not survive Codex — the marker fallback returns `code` and a (marker-stripped) `message` only,
+never backfilled from `NEXT_STEPS`, so a caller can tell "code only" from "the full envelope."
+WP19 must degrade to a generic step-up prompt when `next_step`/`details` are absent rather than
+assume a specific one exists.
+
+**Claude Code's unknown behavior matters less now.** Item 2's limit (the CLI is a closed-source
+compiled binary) still stands and is not resolved here — but since the marker rides inside the
+one field (`message`) every harness examined keeps, `code` survives on Claude Code whether or
+not its SDK also preserves the full JSON body. The unverified question narrows to
+`retryable`/`next_step`/`details`, which were never load-bearing for WP19's own need (a code to
+act on).
+
+**Consequence for the runner.** `gateway-error.ts` gained the marker fallback described above;
+its body-path parser is otherwise unchanged — already correct for the bodies that do survive.
+`tests/unit/gateway-error-harness-formats.test.ts` pins both paths per refusal: the Pi/Anthropic-
+SDK shape recovering the full envelope via the body, and Codex's stripped shape (with the marker
+still inside `message`) recovering `code` alone via the marker, with `next_step`/`details`
+asserted absent. A future edit that changes either SDK's formatting, or the gateway's marker
+rendering, fails a test instead of degrading silently.
+
+**The MCP plane needed the same marker, and turned out to need it more.** The first pass of
+this section covered the LLM gateway only (`gateways/llms/proxy.py`); WP26 depends on the MCP
+plane's version of this exact channel — an agent that cannot reach an MCP server and asks the
+user to connect it is D35's second consequence, and it is dead on Codex without a code, same as
+the LLM case. `with_code_marker` moved to `gateways/utils.py` (already shared by both proxies
+for `response_headers`) so `gateways/mcps/proxy.py::_protocol_error` could apply it too, rather
+than a second copy drifting from the first (the duplication CU12 spent this wave proving is
+expensive). Same exclusion: `MCPUpstreamError`/`upstream_error` stays unmarked, D16 applying
+identically on this plane.
+
+**The MCP plane's wire shape makes the marker load-bearing for every harness, not only
+Codex's.** The JSON-RPC error result's stable identifier is `error.data.cause` (a string),
+under a numeric JSON-RPC `error.code` (e.g. `-32000`) — not the LLM plane's string `error.code`
+`gateway-error.ts`'s body scan looks for. That scan's `typeof body.code === "string"` check
+fails on an MCP body regardless of whether a harness preserves it whole, so **the marker is the
+only channel that ever recovers an MCP cause**, independent of OD18's per-harness LLM findings.
+Proven in `tests/unit/gateway-error-harness-formats.test.ts` with two MCP fixtures: the full
+JSON-RPC body embedded verbatim (still only the marker recovers `code`, because the body scan
+doesn't recognize the shape), and Codex's stripped-to-`message` shape (the marker survives for
+the same reason it does on the LLM plane).
+
+**The same audit run on the MCP plane, because the LLM plane's version of it found a real
+gap.** Every exception `core/gateways/mcps/service.py` and `core/gateways/mcps/registry.py`
+actually raise (`grep -rn "raise [A-Z]"`) — `MCPEndpointNotFoundError`, `PolicyDeniedError`,
+`GatewayEndpointInactiveError`, `MCPToolNotAllowedError`, `SecretInvalidError`,
+`SecretNotFoundError`, `MCPUpstreamError` — has a branch in `_map_gateway_exception` and is
+listed in `_MAPPED_EXCEPTIONS`. `CeilingExceededError`, `EntitlementDeniedError`,
+`MCPAuthRequiredError` and `MCPScopeInsufficientError` are mapped too but not currently raised
+anywhere on this plane (reserved for the not-yet-built ceiling/entitlement/step-up paths,
+WP16-20) — present defensively, not a gap. **Unlike the LLM plane, this proxy had no hole**:
+`SecretInvalidError` (the one the LLM side had silently dropped) was already both mapped
+(`cause="secret_invalid"`) and in `_MAPPED_EXCEPTIONS` here from the start.
+### OD19. What does `base_url` mean on a Bedrock or Vertex endpoint row — CLOSED
+
+**One field, host-only, shared by every door a kind serves.** `base_url` is never a full per-door
+URL; it overrides the host (and, on Vertex, the shared project/location prefix), and each door
+appends its own tail on top of it.
+
+- **Bedrock: one host, all three doors.** The Messages door was reassigned from `InvokeModel` on
+ `bedrock-runtime` to `bedrock-mantle.{region}.api.aws` — the same host the OpenAI-compatible
+ doors already use, and AWS's current-generation endpoint for exactly this purpose. Mantle
+ serves the Anthropic Messages API natively: the model stays in the body, unchanged, and
+ `anthropic-version` travels as the same HTTP header a native Anthropic client already sends —
+ no body field, nothing to add or remove. `base_url` on a Bedrock row is the host alone; the
+ three tails are `/v1/chat/completions`, `/v1/responses`, `/anthropic/v1/messages`.
+- **Vertex: one host, two doors.** Its OpenAI-compatible door and its Anthropic door share the
+ host and the `/v1/projects/{project}/locations/{region}` prefix; only the tail differs
+ (`/endpoints/openapi/chat/completions` versus `/publishers/anthropic/models/{model}
+ :rawPredict`). `base_url` on a Vertex row is *host plus that common prefix*; each door appends
+ only its own tail beyond it.
+
+**The consequence for D40.** Bedrock's rewrite came out of the table entirely — it was an
+artefact of routing the Messages door to `InvokeModel`, not a fact about the vendor. Vertex has
+no equivalent second door (`rawPredict` is the only way to reach Claude models on Vertex), so its
+entry is unchanged. D40's table now has exactly one entry.
+
+**The version and auth headers were verified to survive, not assumed.** `RelayLLMAdapter`'s
+stripped-header set never named `anthropic-version`, so a caller's header already passed through
+untouched — nothing needed fixing there. Bedrock's auth strategy already presented a bearer token
+rather than SigV4 (written when the OpenAI-compatible doors moved to mantle), which is exactly
+what mantle's Messages surface accepts too.
+
+**The trade AWS's own documentation disagrees on, recorded rather than resolved.** AWS's Bedrock
+endpoint-comparison page says `bedrock-mantle` rejects structured outputs on Messages
+(`output_config.format`, 400), cross-region inference profiles, guardrails, and intelligent
+prompt routing; the separate Messages API reference page lists structured outputs as supported
+with no endpoint carve-out. Both pages are AWS's own and they disagree; this design does not pick
+a side. What is settled regardless: `bedrock-runtime` remains the endpoint for the other three
+capabilities, and no fallback between the two Bedrock endpoints is built — an endpoint is fixed
+by `deployment_kind` and door, never switched per request. That is a later decision if a caller
+needs those capabilities on a Messages-shaped Bedrock call.
+
+**An override has real uses, so the field is not decoration.** Both vendors publish private-access
+addresses that replace the host and nothing else: Bedrock through VPC interface endpoints
+(`https://vpce-{id}.bedrock-runtime.{region}.vpce.amazonaws.com`), Vertex through Private Service
+Connect, which answers on a user-defined internal address or an assigned name such as
+`aiplatform-genai1.p.googleapis.com`. With `base_url` defined as a host override shared by every
+door, one stored string makes both vendors usable from a VPC-only deployment on every door at
+once, not only the one a row happened to be tested against.
+
+**Still latent.** Nothing in this codebase registers a Bedrock or Vertex endpoint with an explicit
+`base_url` — no seed, no fixture, no test. `LLMEndpointCreate` accepts the field with no
+per-`deployment_kind` validation, so nothing stops a future caller, but no path does today. What
+was open — whether one field or two — is answered: one, because it is defined as a host (plus, on
+Vertex, a shared prefix) rather than a per-door address. See `workstreams/specs-wp27.md` for the
+full routing-table detail.
+
+
+### OD2. Is a user's own secret the norm or the exception — CLOSED
+
+**Project-level secrets are the model.** User-level secrets are out of scope and recorded as such
+in [`out-of-scope.md`](out-of-scope.md). Whether a user puts their own personal credential into a
+project-level secret rather than an account one is their choice, not a distinction the platform
+draws.
+
+The original framing, for the record:
+
+User-owned secrets are not implemented, so this waits until they are. The mechanism is designed
+in `secrets.md` and the lookup already takes an owner (D10), so nothing is foreclosed.
+
+Per-endpoint tokens arrive with this, not before.
+
+### OD6. OAuth callback reachability — CLOSED, and it was never a real problem
+
+**Nothing to build.** The user is already looking at the Agenta interface in a browser when they
+click connect, so the address that got them there is one their browser reaches. The authorization
+server never fetches the redirect target; it only sends the browser somewhere it has already
+been. Cloud has a domain, a self-hosted production deployment has a domain, and development has
+the tunnel that is already wired into the compose files. See D26.
+
+The one thing that can genuinely fail is unrelated to the redirect: the newer client-registration
+mechanism has the **authorization server** fetch a client identity document over the internet, so
+a deployment on an internal-only domain cannot use it. The fallback is registering outbound, and
+D26 makes that the standing rule.
+
+Two questions were wrong rather than open, and `notes.md` records both: this was written up first
+as a firewall problem, then as a private-address problem, and the deployment shape both worried
+about — a production web application with no address — does not exist.
+
+To establish at implementation time, neither blocking: whether the servers we care about still
+accept the older outbound registration, and whether any of them reject a redirect target on a
+non-public domain.
+
+Belongs to the OAuth wave, not the first one.
+
+### OD12. Should a clamped parameter be silent — CLOSED
+
+**No. A governance ceiling rejects, visibly. It never silently lowers a value.** Settled as D25;
+the evidence is below, since the question was to be answered by looking at comparable gateways
+rather than by assertion.
+
+**The question conflates two different collisions**, and the ecosystem answers them differently.
+
+*A stated value colliding with a physical limit.* Asking for more output tokens than the context
+window can hold is impossible rather than forbidden. Here the direction of travel is to clamp:
+the OpenAI-compatible reading treats the output ceiling as an upper bound rather than a demand,
+and inference servers that reject instead are being asked to clamp so that callers who set a
+safety cap are not punished for it. This case is the upstream's to handle, not ours.
+
+*A stated value colliding with an operator's ceiling.* This is what our ceilings are, and every
+comparable gateway rejects. A managed API gateway's token-limit policy answers a rate breach with
+"too many requests" and an exhausted quota with "forbidden" — two distinct statuses, neither of
+them a quiet edit. Another gateway's prompt-guard plugin answers a denied or non-allowed prompt
+with "bad request", and its size limiter rejects the whole request rather than truncating it.
+
+**Why that split is right for us and not merely conventional.** A governance ceiling exists to be
+accounted for. Silently lowering a value produces a run whose output differs from what was asked
+for, with nothing in the result explaining why — and the compliance claim the ceiling exists to
+support becomes unverifiable from the caller's side. Worse, the caller cannot tell a policy
+ceiling from a bad prompt, so the failure is invisible exactly where it is most expensive.
+
+The objection that rejecting "breaks a harness that did nothing wrong" is real and is answered by
+the error rather than by silence: the denial names the ceiling, the value asked for and the value
+allowed, so the caller can retry correctly on the first attempt.
+
+**Consequence for the north ports.** Both surfaces have externally-fixed error shapes, so this
+needs a denial that fits inside them and still carries the three facts above. That is
+`contract.md`'s open item on expressing a policy denial, and this closes half of it — the content
+is settled even where the envelope is not.
+
+### OD21. OAuth discovery guesses a well-known path instead of reading the 401 that names it — CLOSED
+
+`MCPOAuthClient.discover()` makes an unauthenticated probe of the MCP server first. When that
+probe returns `401` with a `WWW-Authenticate` header carrying a `resource_metadata` parameter
+(RFC 9728), the client fetches protected-resource metadata from that exact URL. Only when there
+is no `401`, no `WWW-Authenticate` header, or no `resource_metadata` parameter does it fall back
+to the well-known URIs (path-based, then root-based `/.well-known/oauth-protected-resource`) —
+the same three-tier order `mcp.client.auth.oauth2.OAuthClientProvider` uses internally, read for
+ordering only (WP17's reason for not calling into that class directly still holds: it blocks one
+coroutine across a redirect-and-wait, a shape a web deployment's two-separate-HTTP-requests
+callback cannot satisfy). A header URL that itself 404s falls through to the well-known chain
+rather than failing outright. Authorization-server metadata discovery is unchanged — well-known
+only, as no comparable header exists at that step.
+
+---
+
+## Closed in this pass
+
+- **MCP endpoint shape** — one URL per server, namespaced identifier, transparent pass-through
+ (D16). A merged endpoint with renamed tools was rejected.
+- **Step-up scopes** — scope selection at connect time plus an interaction at step-up (D17).
+ Failing with an error was rejected; it is the same situation as a missing connection, where we
+ already do not fail.
+- **Dead secrets** — tools stay listed and the call fails (D18). Hiding tools was rejected.
+- **New secret kinds** — `oauth_provider` and `oauth_grant`, two kinds rather than sub-kinds of
+ one (D14). No static MCP kind in this scope, and no kind at all for the inbound credentials.
+- **The inbound credentials** — minted, ephemeral, never stored, using the signer that already
+ exists (D13).
+- **Embeddings in the model registry** — deferred with the whole evaluator path, which is out of
+ the current scope (D15).
+
+## Closed earlier
+
+- **Where the policy plane runs**, and **how a policy decision is cached** — both settled by the
+ parallel credits design (`raw/related-work.md`).
+- **The token store** — there is none; the gateways reference secrets by id (D3).
+- **Spend attribution mechanism** — `secret_origin` carries it.
+- **The model call-site count** and **whether the routing library runs in-process** —
+ `raw/model-call-sites.md`.
+
+## Not open questions
+
+Two items previously listed here as prerequisites were neither prerequisites nor design
+questions. Both are **outcomes the gateway enables**, and `notes.md` records why the reasoning
+was backwards.
diff --git a/docs/design/gateways-research/v1/open-reviews.md b/docs/design/gateways-research/v1/open-reviews.md
new file mode 100644
index 0000000000..038b0d761a
--- /dev/null
+++ b/docs/design/gateways-research/v1/open-reviews.md
@@ -0,0 +1,224 @@
+# Open reviews
+
+Things to check against the code when the ports are implemented. Each is a claim to verify
+or a seam to inspect, not a decision. Close an entry by recording what was found.
+
+---
+
+## Ports to define
+
+### OR1. `TokenStorage` — delegate to the secrets service
+
+The official MCP Python SDK defines a `TokenStorage` protocol and its `OAuthClientProvider`
+handles everything above it. **Implement the protocol as a thin adapter over the secrets
+service; do not write an OAuth client and do not add a second place secrets live.**
+
+Verify the adapter stores only a `secret_id` on the gateway's own rows and resolves through
+`get_secret_by_id`, matching the webhook dispatcher and SSO provider precedent, and that no
+gateway response can serialize the secret **material**. The id itself is a handle and does
+travel in responses — see `secrets.md` for why withholding it would break full-PUT edits.
+
+To verify at implementation time:
+
+- The protocol's exact method set and value types in the pinned SDK version.
+- That `OAuthClientProvider` accepts our storage implementation unchanged.
+- That the `redirect_handler` and `callback_handler` hooks can be wired to the dashboard
+ connect flow rather than to a local browser opener, which is the shape the SDK's examples
+ assume.
+- Whether `client_metadata_url` (the Client ID Metadata Document path) works against the
+ authorization servers we care about, and what the fallback to dynamic registration costs.
+
+### OR2. Secret lookup signature
+
+The lookup must take the owner as a parameter from the start even while the only answer is
+the project (`secrets.md`). Review that no call site hardcodes the project, and that
+the owner resolves from `AuthScope` rather than being passed separately.
+
+### OR3. Model routing extraction
+
+The model-routing logic to move behind the gateway is the provider-settings builder in the
+SDK's secrets manager, not the callback handler in the SDK's model folder — the folder name
+is misleading. Confirmed against the code.
+
+**There are two copies of the builder**, differing only in which execution context they read
+secrets from: the older routing context and the newer workflow context. The workflow copy is the
+one both production chat call sites use. An extraction taking only one leaves a live second
+implementation behind, so review that it takes both, plus the call, and leaves the observability
+callback where it is.
+
+---
+
+## Seams to inspect
+
+### OR4. Three duplicated auth-scheme enums — ANSWERED, and the question was mis-framed
+
+The same `oauth | api_key` enum, and the same ready / needs-auth / needs-input state machine,
+exist as a connection, a tool and a trigger variant. The question was whether they collapse into
+one definition or whether the duplication is load-bearing.
+
+**Neither.** The gateways are a separate domain, so they define their own copy, and the three
+existing ones are outside the current scope (D15) — not ours to collapse. A draft that unified
+them at the older domain's root, with its leaves aliasing over, was rejected: it would have
+coupled the gateways to an integrations domain through the back door, which is worse than a
+fourth definition.
+
+If all four ever converge, the neutral home is `core/shared/dtos.py`, which already holds the
+shared identifier, slug and header types. Available later; not done now, and not a prerequisite
+for anything. `entities.md` §4.1 carries the reasoning and the definitions.
+
+### OR5. `project_id`-only DAO signatures
+
+Every connections DAO verb is keyed by project. Review each against OD2's outcome before
+adding a user dimension, and check whether `create_connection`'s `user_id` parameter is
+authorship only, as it currently appears to be.
+
+### OR6. Wire secret arrays — CLOSED
+
+If the gateway holds all upstream secrets, the runner wire's per-server secret
+arrays and the model secret array should collapse to a single gateway token. Review
+what still populates them, and whether the `local_use` secret category can be removed
+outright once cloud-reseller signing moves to the gateway.
+
+**Nothing populates them with a real upstream secret on the connected path.** The model's
+`ModelConnection.credentials` stays empty (`build_gateway_resolved_connection` in
+`connections/endpoints.py`) and its one token rides `gatewayCredentials`; each MCP server's
+`connection.credentials` (`mcp/resolver.py`'s `_resolve_gateway`) holds exactly one entry, our
+own `X-AG-Credentials`, whatever secret refs the author named. Both are covered by the shared
+golden (`model_connection.gateway.json`, asserted by both `test_gateway_credentials.py` and
+`gateway-credentials.test.ts`) and by `mcp/test_resolver.py`, which now also proves the
+per-server collapse across more than one server.
+
+**`local_use` is not removable, and does not need to be.** It is dead on the connected path —
+`_resolve_from_secrets` routes every deployment, including bedrock and vertex, through the
+gateway with `credentials: []` — but stays reachable from the two offline, standalone-SDK
+resolvers (`EnvConnectionResolver`, `StaticConnectionResolver`), which run with no Agenta backend
+and so have no gateway to hold a cloud-reseller secret on their behalf; the sandbox in that mode
+signs with a real value because there is no other account to sign with. `daytona-secret-plan.ts`
+already scopes its `local_use` allowlist to exactly that reasoning.
+
+### OR7. Redaction deny-set
+
+The per-run deny-set is built from every secret value on the wire. Once those collapse
+to one short-lived token, review whether the deny-set construction still earns its
+complexity.
+
+### OR8. Provider enum coupling — enumerated
+
+Verified, and there are more than the three previously flagged. Widen them together rather than
+piecemeal; the full set, all in the Python SDK unless noted:
+
+- The static model catalogue itself, eleven providers each with a model list, plus the flat
+ model-to-provider map derived from it, plus the per-model cost table derived from that.
+- Two secret-kind enums naming providers: one for standard providers, one for custom ones, which
+ additionally carries the reseller deployment kinds.
+- In the harness capability table: the set of providers reachable with a vault key, the
+ subscription-authenticated set, per-harness model alias lists, and a per-harness map of which
+ provider family that harness's OpenAI-compatible deployment accepts. **That last one is the
+ table a gateway route has to satisfy.**
+- The canonical provider-to-environment-variable map, which the runner **mirrors by hand** in
+ TypeScript for its clear-then-apply step. Two copies that must agree.
+- A hard-coded provider-to-base-URL map used when no custom endpoint is supplied.
+- A provider-kind alias map fixing one vendor spelling.
+
+Two of these are worth separating from the rest: the environment-variable map is duplicated
+across languages, and the harness deployment map is the one the gateway must satisfy rather than
+merely widen.
+
+---
+
+## Claims to re-verify
+
+### OR9. Model call sites — CLOSED, and recounted
+
+Counted twice. **Six sites across three shapes**, not the four first recorded. See
+`raw/model-call-sites.md`, which also records what the first count got wrong.
+
+Five sit in one SDK file, `sdks/python/agenta/sdk/engines/running/handlers.py`: three chat calls
+(two through a shared retry wrapper, one bypassing it) and two similarity evaluators using the
+OpenAI client directly for embeddings. The sixth is the harness inside the sandbox. **The API
+calls no models at all**, and the runner only picks and checks a model id.
+
+Three things carried into the design. The embeddings sites are deferred with the whole evaluator
+path (D15) rather than forcing a route now. The `llm_v0` handler's module-level key assignment
+must not reach a shared process, as an outcome of the conversion rather than a gate in front of
+it — see OR13. And **the routing library's `Router` class is never instantiated anywhere in this
+repo**, so none of its retry, fallback or load-balancing behaviour is inherited by moving the
+call.
+
+### OR14. The secrets read surface — close it once nothing needs it
+
+The secrets read route returns plaintext material to any caller holding the view permission,
+and the agent path resolves straight through it.
+
+**This is an outcome, not a prerequisite.** Callers read that route because it is how they get
+a provider key at all, so it cannot be restricted while they still depend on it. Once
+everything goes through the gateway, nothing needs it — and that is the moment to close it.
+
+Track it as the last review of the conversion, not the first. Parallel bring-your-own-secrets
+work wants the same outcome, so coordinate on who closes it.
+
+### OR13. Module-level provider keys — CLOSED, and the handler is not unused
+
+One handler sets provider keys on module-level attributes of the routing library, which is
+process-wide state and would be a cross-tenant leak in a shared process.
+
+**Not a prerequisite either.** That pattern exists because nothing hands the handler a resolved
+connection; proper injection through the gateway is what removes it.
+
+**The "reported unused" premise does not hold.** `llm_v0` is registered under
+`agenta:builtin:llm:v0` and mounted at `/llm/v0` in `services/entrypoints/main.py` — a live,
+reachable managed-workflow route, not dead code.
+
+**Verified closed instead.** The module-attribute pattern is gone: `_call_llm_with_fallback`
+resolves `provider_settings` per LLM entry (through the same slug-first resolver the prompt path
+uses) and passes them as call kwargs, with no `setattr(litellm, ...)` anywhere in the tree. This
+landed in commit `50d6a2b3ed` ("per-entry llm_v0 keys"), ahead of and independent of this review.
+Two regression tests were added to `test_llm_v0_provider_key_binding.py` covering the no-module-
+attribute invariant and concurrent-call isolation across two connections.
+
+### OR15. The audit pipeline is lossy, and compliance is not — NOT GATEWAYS SCOPE
+
+**Ruled out of this workstream.** The drop behaviour is the events domain's existing posture and
+predates the gateways; WP4 emits onto it rather than changing it. If a compliance-grade class of
+event is wanted, that is a change the events domain owns, raised there and not here.
+
+The original finding, for the record:
+
+Found while writing `entities.md`. The events stream the audit record rides (D22) **drops writes**
+under a Redis outage and under its own first-layer quota, and its publish helper swallows
+failures rather than surfacing them. That is a reasonable posture for telemetry and the wrong one
+for a compliance record, which `policy.md` requires to be non-lossy.
+
+**D22 stands** — one pipeline, no second audit table. This is a durability gap in the events
+domain, not an argument for routing around it, and D12 is explicit that if the gateway needs
+something the shared mechanism does not offer, the mechanism grows it.
+
+Review at the point the audit record ships: what the drop rate actually is, whether a
+compliance-grade class of event can be marked non-droppable within the existing stream, and who
+owns that change. Coordinate with the events domain rather than solving it inside a gateway.
+
+### OR10. Subscription-authenticated harnesses
+
+A harness that authenticates with its own login injects no secret today. Verify what it
+does when pointed at a gateway, and whether it must stay an exception to the transit rule.
+
+### OR11. Existing policy checks on model calls
+
+Establish what policy, if any, runs on each current model call site. This is the baseline
+the gateway has to at least preserve.
+
+### OR12. MCP SDK is not a direct dependency — CONFIRMED, with a wrinkle
+
+Neither the runner nor any Python project declares an MCP SDK. Verified: no
+`@modelcontextprotocol/*` entry in the runner's package manifest, and no `mcp` package in any of
+the four Python lock files.
+
+**The wrinkle: it is already resolved transitively and deliberately not used.** The runner's lock
+file pins the official TypeScript SDK, but only underneath the harness adapter packages. The
+runner's own internal MCP server — the loopback channel that delivers first-party tools to a
+harness — **hand-rolls the JSON-RPC framing rather than importing it**, with a comment saying to
+pin against whatever version the installed harness bundles if the framing drifts.
+
+So adding an SDK is still a new dependency decision, and there is now a second question beside
+version pinning: whether the gateway's own MCP surface follows the hand-rolled precedent or
+breaks with it.
diff --git a/docs/design/gateways-research/v1/out-of-scope.md b/docs/design/gateways-research/v1/out-of-scope.md
new file mode 100644
index 0000000000..25809ab9e8
--- /dev/null
+++ b/docs/design/gateways-research/v1/out-of-scope.md
@@ -0,0 +1,125 @@
+# Gateways: out of scope
+
+Things this design deliberately does **not** build, with enough of the research kept that
+picking one up later starts from an argument rather than a blank page. Distinct from
+[`cleanups.md`](cleanups.md), which is repo debt this work touches, and from
+[`scope-checklist.md`](scope-checklist.md), which sequences what *is* in scope.
+
+---
+
+## User-level secrets on either plane
+
+**Out of scope.** Not deferred to a later wave — removed from the plan. Every gateway
+secret is **project-owned**: one key or one consent per endpoint, held by the project, used
+by everyone in it.
+
+### What ships instead
+
+Both planes say it the same way, in one column:
+
+```python
+llms_endpoints.secret_id -> secrets.id # the provider key
+mcps_endpoints.secret_id -> secrets.id # the oauth_grant, or nothing
+```
+
+`secret_id` is nullable on both, because an endpoint with no secret is legitimate — a mock
+(D23), an unauthenticated self-hosted server. Health rides with the endpoint:
+`flags.is_valid` for a secret that stopped working, `status` for the last failure that
+explains why. D18 holds unchanged — a dead secret does not hide the endpoint, its tools
+stay listed, the call fails with a connect affordance.
+
+### The extension is additive, and identical on both planes
+
+The direct `secret_id` **is** the project-level answer. Adding user-level secrets does not
+change it, replace it, or migrate it. It adds a table per plane whose rows point at the
+endpoint and narrow the answer for one user:
+
+```sql
+CREATE TABLE llms_grants ( -- and mcps_grants, field for field
+ project_id uuid NOT NULL,
+ id uuid NOT NULL,
+ endpoint_id uuid NOT NULL REFERENCES llms_endpoints (id) ON DELETE CASCADE,
+ user_id uuid NOT NULL, -- NOT nullable: the project's answer is the column
+ secret_id uuid NOT NULL REFERENCES secrets (id) ON DELETE CASCADE,
+ ...
+);
+CREATE UNIQUE INDEX uq_llms_grants_user ON llms_grants (project_id, endpoint_id, user_id);
+```
+
+Resolution then reads: *this user's grant if one exists, else the endpoint's `secret_id`*.
+Nothing that exists today moves. An endpoint keeps working for everyone who has not
+connected their own, which is the behaviour you want anyway — a per-user key should be an
+upgrade for that user, never a new requirement for everybody.
+
+`user_id` is **NOT NULL** there, and that is the point of putting the project's answer in
+the endpoint column instead. One table holding both owners would need a nullable `user_id`,
+and then two partial unique indexes to express "one grant per owner" — because SQL treats
+every `NULL` as distinct, so a single unique index would let a project-owned row be inserted
+twice. Splitting the two answers across the column and the table removes the nullable owner,
+and with it the need for partial indexes at all.
+
+**Both planes or neither.** The pressure that would add user-level secrets — a member using
+their own OpenAI key, or their own consent to a tool server — arrives on both planes at
+once, and the shape is the same either way. Building one and not the other is what would
+make this expensive later.
+
+### What survives, so reopening stays additive
+
+The resolver's signature. `SecretsResolverInterface.resolve()` takes the full `AuthScope`
+and a `SecretMode` — `PROJECT_ONLY`, `USER_REQUIRED`, `USER_OPTIONAL` — per D10, even
+though only the project arm can answer today. That is the expensive thing to retrofit; the
+modes are already written and already tested.
+
+So reopening is: two tables, the user arm of `USER_REQUIRED` / `USER_OPTIONAL` wired to
+them, and a connect flow that mints per-user consent. No data migration. No signature
+change. No change to either endpoint table.
+
+**What would justify it:** a customer whose compliance rules forbid sharing one consent
+across a workspace, or a server whose tokens carry per-user identity the tools actually
+read — a "who am I" call answering differently per member. Neither is worth building for in
+advance; both are unmistakable the moment they arrive.
+
+## Upstreams a relay-only gateway cannot reach
+
+One exclusion from WP24's per-provider verification (OD16), recorded so it is not rediscovered as a
+bug. It is unreachable for a stated reason rather than merely unbuilt.
+
+**SageMaker.** Its invoke API has no platform-level request schema: AWS forwards opaque bytes to
+whatever container the customer deployed. There is no "SageMaker wire" to check a front door
+against, so the answer is per-deployment rather than a fact this design can pin. `select_upstream`
+raises, naming that it has no fixed protocol rather than naming one it needs. Every other model
+provider OD16 examined is reachable through one door or another.
+
+Bedrock's legacy `InvokeModel` path and Vertex's Claude `rawPredict` path were listed here and are
+**no longer out of scope**: D40 carves out a static, named field rewrite for exactly these two, and
+WP27 implements it. Both also have wired alternatives that need no rewrite at all — Bedrock through
+its newer `bedrock-mantle` endpoint with a plain bearer key, Vertex through its OpenAI-compatible
+layer — so neither vendor was ever unreachable; one path per vendor was.
+
+## Paths that still reach an upstream without the gateway
+
+**Out of scope, and not a finding.** D1 ends with everything transiting a gateway and D24 makes it
+the sole mechanism, but both are end-states. Every path below predates this work, still resolves a
+secret or calls an upstream on its own, and is **already scheduled to change when it is migrated**.
+None of them is a gap this design opened, a regression to explain, or a question to reopen. They are
+listed once, here, so that finding one again reads as "not yet migrated" instead of "undiscovered
+problem" — which has now happened more than once.
+
+**The gate that came off with the credits counter.** The `local_secrets` permission check used a
+single call to both meter and gate access to platform-owned secrets: it incremented
+`credits_consumed` and denied outright when the quota ran down, at `free=100, limit=100, monthly` on
+cloud plans. The counter measured access checks rather than usage, so CU10 removed it, and the gate
+went with it because nothing separated the two jobs. Local-secret use is uncapped until that path
+transits the gateway. Restoring the counter is not the answer — it would restore a number nobody
+wanted, not a cap.
+
+**Services that resolve their own secrets.** Anything that fetches provider material and calls an
+upstream directly is outside the boundary. The two similarity evaluators are the concrete case:
+they call the OpenAI client directly, hand-roll their vault lookup by scanning for a `provider_key`
+secret of inner kind `openai`, and bypass the provider-settings builder entirely. Embeddings also
+have no north-port route on the gateway, so there is nowhere for them to go yet. The SDK's own
+direct secret resolution belongs in the same bucket.
+
+**Why not `cleanups.md`.** That file is repo debt this work touches and can finish. These are not
+finishable here: each one ends when its own path is migrated, on its own schedule, and listing them
+as pending cleanups invites re-auditing them every wave.
diff --git a/docs/design/gateways-research/v1/plan.md b/docs/design/gateways-research/v1/plan.md
new file mode 100644
index 0000000000..bfbe8873c3
--- /dev/null
+++ b/docs/design/gateways-research/v1/plan.md
@@ -0,0 +1,379 @@
+# Gateways: work packages, merges, checkpoints, waves
+
+Assumes the rest of `v1/`. Packages and their dependencies — no sizing and no schedule beyond
+what the dependencies force.
+
+**Status: draft for review.** The package boundaries are the proposal; the checkpoint structure
+is the part to argue with first, because everything else hangs off it.
+
+---
+
+## The four words
+
+**Work package.** A unit that can be built, reviewed and merged on its own, in its own worktree.
+Where two could be one, they are split if they can land independently or belong to different
+owners.
+
+**Merge.** A point where packages come together. Most merges are not deployed. At a merge we fix
+static issues — types, lint, contract tests, unit tests — and move on.
+
+**Checkpoint.** A merge we **deploy** and run acceptance tests against, because it is the first
+point where something real runs: live processes, live servers, a request that travels. At a
+checkpoint we deploy, fix dynamic issues, and only then start the next fan-out.
+
+**Wave.** Everything between two checkpoints. A wave is fan out, fan in, fan out, fan in, ending
+at a deploy. Each wave gets its own specs, tasks and findings written before it starts.
+
+The rhythm per wave: define the wave → write specs and tasks for its packages and merges →
+prepare → run the packages in parallel → merge and fix static issues → deploy → fix dynamic
+issues → next wave.
+
+---
+
+## Scope confirmation
+
+Permission checks and entitlement checks are **in for both gateways**. Credit checks are
+**postponed for both**, and arrive with the metering and billing work rather than here.
+
+---
+
+## The checkpoints
+
+Three, and the middle one is the big one.
+
+### C1 — both gateways serve traffic against mocks
+
+The LLM gateway and the MCP gateway both accept a call, authorise it, resolve and inject a
+secret, reach a mock upstream, and return. Policy fires. Nothing is recorded and nothing is
+configurable yet — this checkpoint proves the call path, and only that.
+
+**Why here.** It is the first point where anything runs end to end, and it needs no third-party
+dependency, no OAuth and no converted caller. Everything it proves is proved against our own
+mocks, which is what makes it a clean acceptance-test surface.
+
+**Acceptance tests:** a request with no token is refused; a request for an endpoint the caller
+may not use is refused; a permitted request reaches the mock with the caller's token replaced by
+the upstream secret; a streamed response arrives byte for byte on **both** gateways, tool names,
+schemas and errors included; a tool call outside the allowlist is refused.
+
+### C2 — the real callers go through the gateways
+
+Agent v0, the runner and the harnesses reach models and MCP servers only through the gateways.
+This is "everything except OAuth works." It also picks up the one thing wave 1 left out: an audit
+event per call.
+
+**Acceptance tests:** a real agent run completes with no provider secret anywhere in the
+sandbox; the run's model calls and tool calls appear as audit events with the right principal;
+a run naming a model it may not use fails cleanly.
+
+### C3 — OAuth works
+
+An OAuth-protected MCP server can be connected from the dashboard, used in a run, refreshed
+without a human, and step-up raises an interaction.
+
+**Acceptance tests:** connect an OAuth server end to end; run a tool through it; force a refresh
+and confirm the run continues; force a scope challenge and confirm an interaction is raised;
+revoke and confirm the tool stays listed and the call fails with something actionable.
+
+---
+
+## The waves
+
+| Wave | From | To | What it delivers |
+|---|---|---|---|
+| 0 | — | the seed | The shared state: every layer declared, column by column |
+| 1 | seed | **C1** | Both gateways, the shared policy core, and the mocks |
+| 2 | A | **C2** | Every caller converted |
+| 3 | B | **C3** | OAuth end to end |
+
+Intermediate merges inside a wave are listed with the wave. They are not deployed.
+
+**Wave 0 ends at the seed, not at a checkpoint.** Nothing runs, so there is nothing to deploy or
+acceptance-test. It is the one wave whose output is a document and a commit rather than
+behaviour.
+
+**C1 is not split.** Separating the registry from the rest was considered and is not
+worth the extra deploy; the two fan-outs inside wave 1 already give the parallelism.
+
+---
+
+## Dependency graph
+
+```mermaid
+flowchart LR
+ W0["wave 0
entities.md
shared state"] --> S["seed
ports + DTOs
(verbatim)"]
+ S --> WP1["WP1
domain + storage"]
+ S --> WP2["WP2
secret resolution"]
+ S --> WP3["WP3
policy core"]
+ WP5["WP5
test doubles
(no deps)"]
+ WP1 & WP2 & WP3 --> IM1{{"IM1
foundation"}}
+ IM1 --> WP6["WP6
LLM ingress"]
+ IM1 --> WP7["WP7
LLM routing"]
+ IM1 --> WP8["WP8
MCP ingress"]
+ IM1 --> WP9["WP9
MCP registry"]
+ IM1 --> WP10["WP10
endpoint CRUD"]
+ WP6 & WP7 & WP8 & WP9 & WP10 & WP5 --> CA(["C1
DEPLOY"])
+ CA --> WP12["WP12
SDK resolution"]
+ CA --> WP4["WP4
audit events"]
+ WP12 --> WP13["WP13
runner + harnesses"]
+ WP12 --> WP14["WP14
agent v0"]
+ WP12 --> WP15["WP15
MCP on the wire"]
+ CA --> WP23["WP23
front doors"]
+ WP23 --> WP24["WP24
relay-only
south port"]
+ WP13 & WP14 & WP15 & WP4 & WP24 --> CB(["C2
DEPLOY"])
+ CB --> WP16["WP16
secret kinds"]
+ WP16 --> WP17["WP17
OAuth client"]
+ WP17 --> WP18["WP18
consent flow"]
+ WP17 --> WP20["WP20
registration fallback"]
+ WP18 --> WP19["WP19
step-up"]
+ WP19 & WP20 --> CC(["C3
DEPLOY"])
+```
+
+The two fan-outs in wave 1 are the widest points: three packages, then five. Wave 2 carries a
+chain behind one package plus three independent ones. Wave 3 is mostly serial because OAuth's
+pieces genuinely depend on each other.
+
+**Wave 1 is deliberately the thinnest thing that works**, and recording, configuration and tuning
+sit outside the three waves entirely.
+
+**A checkpoint is a deploy, not a release.** No user traffic passes before C3, so
+nothing observable happens that could have been recorded and was not. That removes the only real
+argument for building the meter early, and leaves the cost of guessing what to meter — which the
+pricing model answers, not the gateway.
+
+---
+
+## Wave 0 — the shared state — DONE
+
+**Nothing forks until the shared state is written down**, because the seed commit is taken
+**verbatim** from the entity document, so anything vague there becomes a conflict later across
+every worktree that inherited it.
+
+`entities.md` is now written in full — every layer, column by column, for both planes, the policy
+core and the two new secret kinds. It carries no unresolved markers.
+
+| Layer | What wave 0 settled |
+|---|---|
+| `dbas` | Shared mixins, and what the owner dimension needs in each signature now versus in storage later |
+| `dbes` | Three new tables and every column, with the foreign key on the secret reference chosen per table |
+| `dtos` | Domain contracts, the two secret kinds' settings pairs and union arms, and the family-shared enums that end the triplicate copies |
+| `types` | The domain exception hierarchy on both planes plus the policy core |
+| `models` | Request and response schemas for the management routers |
+| DAO methods | Every verb with its exact signature, each taking the owner (D10) |
+| Service methods | Orchestration against interfaces, never concrete DAOs or adapters |
+| Router methods | Route declarations, with the data plane and the management CRUD as separate router objects because their shapes are incompatible |
+
+Beyond the layers, it settled where the code lives: a **separate domain**, `gateways/` beside the
+existing `gateway/`, holding both planes and the shared policy core. The existing family is an
+integrations domain that happens to carry the word; sharing a word is not sharing a concern, and
+`notes.md` records the two drafts that got this wrong before it was settled.
+
+It also settled that the policy core is a module with a service facade and no tables of its own,
+that new code uses the frozen auth scope rather than the neighbouring domains' habit of reading
+request state directly, and the six new permission subjects.
+
+**Done test, met:** every symbol a wave 1 package needs to import exists in the document with its
+signature, and no package's surface is described only in prose.
+
+### The seed
+
+The output of wave 0. One commit on the base branch carrying the declared surface, all raising
+not-implemented: the gateway ports, the endpoint and policy DTOs, the domain exceptions, and the
+secret-resolution signature — each taken from `entities.md` rather than invented at commit time.
+
+**The one thing that must be right:** the secret resolution signature takes the owner as a
+parameter even though the only answer today is the project (D10). Every package that resolves a
+secret inherits it.
+
+Every worktree branches from that commit, so interface dependencies never serialise the work.
+
+---
+
+## Wave 1 — to C1
+
+### Fan-out 1: foundation
+
+**WP1 — Gateway domain and storage.** The entity stack for custom endpoints on both gateways:
+mixins, entities, DAO, mappings, migration. Standard endpoints are generated and store nothing
+(D20).
+*Depends on:* seed. *Blocks:* WP6, WP9, WP10.
+*Done when:* a custom endpoint round-trips, and every DAO verb takes the owner.
+
+**WP2 — Secret resolution.** The resolve function over the secrets service, returning the secret,
+its owner, and its `secret_origin`. Pure logic, so fully unit testable — and it must be, because
+the interesting cases are the failures.
+*Depends on:* seed. *Blocks:* WP6, WP8.
+*Done when:* each resolution mode behaves as specified and no path silently returns no secret.
+
+**WP3 — Policy core.** The principal from the existing auth scope, the permission check on a
+target, and the entitlement check. No credit check.
+*Depends on:* seed. *Blocks:* WP6, WP8.
+*Done when:* a caller without permission on an endpoint is refused before any upstream call.
+
+**WP5 — Test doubles.** A mock LLM endpoint and a mock MCP server, both controllable from tests:
+forced errors, forced slowness, forced scope challenges later.
+*Depends on:* nothing. **Start immediately.** *Blocks:* every acceptance test.
+*Done when:* both mocks run in the local stack and can be driven to fail on demand.
+
+**Merge IM1 — foundation.** Static only, not deployed.
+
+### Fan-out 2: the two gateways, in parallel
+
+**WP6 — LLM ingress and relay.** The OpenAI-compatible surface, streaming, the body kept byte for
+byte, timeouts.
+*Depends on:* IM1. *Done when:* a streamed response is relayed unmodified and a hung upstream
+times out rather than hanging the gateway.
+
+**WP7 — LLM routing and model allowlist.** The routing library in-process; standard endpoints
+generated from the SDK catalogue; custom endpoints restricted to their declared models.
+*Depends on:* IM1. *Done when:* every provider and deployment pair reachable today is reachable
+through the gateway, including reseller shapes, and a model outside a custom endpoint's list is
+refused.
+
+**WP8 — MCP ingress and proxy.** One URL per server, namespaced identifier, transparent
+pass-through with tool names untouched.
+*Depends on:* IM1. *Done when:* list and call both relay unchanged and a tool outside the
+allowlist is refused.
+
+**WP9 — MCP registry and tool allowlist.** Custom servers as rows, built-in servers defined by
+us, per-server tool allowlists.
+*Depends on:* IM1, WP1. *Done when:* a custom server registers and resolves, and a built-in one
+needs no row.
+
+**WP10 — Endpoint CRUD API.** Routers and models for creating and configuring custom endpoints on
+both gateways. Creation and deletion only — per-endpoint configuration is WP21, in wave 2.
+*Depends on:* IM1, WP1. *Done when:* a custom endpoint can be created and deleted, and a standard
+one cannot be edited.
+
+**Merge IM2 → C1.** Deploy. Acceptance tests above.
+
+---
+
+## Wave 2 — to C2
+
+**WP12 — SDK connection resolution.** `resolve_connection` returns a gateway route: the provider
+and deployment naming the gateway, the base URL, and the token. The SDK keeps every capability it
+has (D4).
+*Depends on:* C1. *Blocks:* WP13, WP14.
+
+**WP13 — Runner and harnesses.** The runner carries a gateway route rather than provider secrets.
+Verify the secret arrays collapse and the redaction set shrinks. This is **not** a
+resolver-side change alone: `ModelCredentialBinding.kind` is `"environment"` and nothing
+else, so a model call cannot carry our credentials in `X-AG-Credentials` (D31) without a wire
+change. The MCP side already has `{kind: "header", name}` and is the precedent to copy.
+*Depends on:* WP12.
+
+**WP14 — Agent v0.** The remaining caller.
+*Depends on:* WP12.
+
+**WP4 — Audit events.** Emission into the existing events domain (D22), with the principal, the
+target, the decision and the outcome. Moved out of wave 1: wave 1 makes the call work, and a
+record of a call that does not happen is worth nothing.
+*Depends on:* C1. *Blocks:* nothing.
+*Done when:* one event per call, queryable through the existing surface.
+
+**WP15 — MCP servers on the wire.** The runner's MCP server configs point at gateway URLs with a
+gateway token rather than upstream secrets.
+*Depends on:* WP12.
+
+**WP23 — Protocol front doors.** `/v1/responses` and `/v1/messages` beside
+`/v1/chat/completions` (D33), each with its own policy-field parse, usage extraction and
+ceiling binding. Everything behind the front door is protocol-blind.
+*Depends on:* C1. *Blocks:* WP24.
+
+**WP24 — The relay-only south port.** D34 forbids body conversion, so the
+`passthrough`/`translated` split becomes one relay with a routing strategy and an
+authentication strategy per deployment, and `TranslatedLLMAdapter` is deleted. Carries
+OD16's per-provider verification, and `provider_key`'s `NOT NULL` with it.
+*Depends on:* WP23 — removing conversion first would make Anthropic, Gemini, Bedrock and
+Vertex unreachable rather than reachable another way.
+
+**Merge IM4 → C2.** Deploy. Acceptance tests above. The fan-out, the worktrees and
+the traps are in [`workstreams/launch-2.md`](workstreams/launch-2.md).
+
+---
+
+## Wave 3 — to C3
+
+**WP16 — Secret kinds.** `oauth_provider` and `oauth_grant`: enum values, settings DTOs, union
+arms, validator branches (D14). Coordinate with the parallel work adding kinds to the same enum.
+*Depends on:* C2. *Blocks:* WP17.
+
+**WP17 — OAuth client.** The official SDK's client provider, with a storage adapter over the
+secrets service; connect callbacks pointed at the dashboard rather than a local browser.
+*Depends on:* WP16.
+
+**WP18 — Consent flow.** Connecting an OAuth server from the dashboard, with scope selection.
+*Depends on:* WP17.
+
+**WP19 — Step-up interaction.** A scope challenge raises an interaction on the existing
+missing-connection path.
+*Depends on:* WP17, WP18, WP25, WP26.
+
+**WP20 — Client registration fallback.** There is no callback-reachability work: the browser
+reaches the redirect in every deployment, because it is the address the user is already on
+(D26). What remains is registration. Prefer the client identity document; fall back to
+registering outbound when the deployment's domain is not publicly resolvable, and make that
+fallback automatic rather than a configuration flag.
+*Depends on:* WP17.
+*Done when:* a deployment on an internal-only domain completes a full authorization without any
+hosted component of ours in the path.
+
+**WP25 — A refusal arrives as a cause.** The gateway's typed refusals survive the trip back to
+the caller: gateway to harness to runner to agent service. The wire field exists
+(`AgentErrorDetail`); what is missing is per-harness proof that a harness preserves the gateway's
+error body, and the agent service surfacing the field at all.
+*Depends on:* C2. *Blocks:* WP19.
+
+**WP26 — An agent can request a gateway connection.** Extend the reserved `request_connection`
+client tool to cover a gateway endpoint on either plane, not only an external integration. D35
+made registration a precondition for use, so an agent needs a way to ask for it.
+*Depends on:* C2. *Blocks:* WP19.
+
+**WP27 — The static field rewrite for resold Anthropic wires (D40).** Bedrock's `InvokeModel` and
+Vertex's `rawPredict` need `anthropic_version` in the body and `model` absent from it. A static
+per-deployment table of literal added/removed fields, nothing computed from the request. Leads with
+a probe: whether a body still carrying `model` is rejected or ignored is undocumented.
+*Depends on:* C2.
+
+**Merge IM5 → C3.** Deploy. Acceptance tests above.
+
+**Wave 3 also carries seven cleanups** unblocked by C2 — CU1, CU2, CU6, CU7, CU10, CU12 and CU13.
+See [`workstreams/launch-3.md`](workstreams/launch-3.md).
+
+---
+
+## After C3
+
+Real gateway work, deliberately not scheduled into the three waves. Nothing above depends on it,
+and none of it can be lost by waiting, because **no checkpoint before C is a release**.
+
+**WP11 — Usage recording, and WP22 — usage charged.** Model tokens and tool calls recorded
+against the principal with the secret origin, and the ledger that prices them. **They ship
+together.** Recording early is normally right because usage cannot be backfilled; that does not
+apply while no real traffic passes. What remains is the cost of guessing which counters, at which
+grain, keyed how — and only the pricing model answers that. A meter built before the price
+produces data nobody uses and a schema to migrate.
+
+**WP21 — Endpoint configuration.** Timeouts, ceilings and extra headers per custom endpoint
+(D21), with a ceiling breach rejecting rather than clamping (D25). Tuning a call path is
+second-order to having one, and it blocks nothing.
+
+---
+
+## Not packages, because the gateways have to exist first
+
+`cleanups.md` is the register: twelve things that become possible only once the gateways run, from
+closing the vault's plaintext read surface to collapsing the runner wire's secret arrays to
+moving the eligible slice of the runner's tool loopback. None of them can be scheduled in front of
+the waves, and none of them is optional — together they are what D1 costs in full.
+
+## Not packages
+
+- The tool catalog. Out of scope.
+- Triggers. A separate subsystem.
+- Credit checks. Postponed to the metering and billing work.
+- Embeddings, the evaluator path, and every other service. Later scope (D15).
+- Retries, fallbacks, aliasing, list caching, stdio servers. Marked out in
+ `scope-checklist.md`.
+- The legacy credits counter. Left alone until the gateway is the sole mechanism (D24).
diff --git a/docs/design/gateways-research/v1/policy.md b/docs/design/gateways-research/v1/policy.md
new file mode 100644
index 0000000000..876d1b0f61
--- /dev/null
+++ b/docs/design/gateways-research/v1/policy.md
@@ -0,0 +1,103 @@
+# Gateways: the policy core
+
+The shared plane both gateways evaluate against. This document exists because the sharing is
+the reason the two gateways are one design — if this turns out not to be shared, they should
+be separate systems.
+
+**Status: skeleton.** The inputs are established; the evaluation and caching are open.
+
+**All six are owned here (D12), and arrive incrementally.** Owned is not scheduled: a concern
+may be unimplemented, but none is designed out, and no other system may route around this one
+to get it. The test for each increment is whether it forecloses a later one.
+
+## Six concerns, two nouns
+
+| Concern | Model plane | Tool plane |
+|---|---|---|
+| Identity | which principal is calling | which principal is calling |
+| Authorization | may they use this model, at this cost | may they use this server, this tool |
+| Governance | model allowlists, spend ceilings | tool allowlists, approval, egress |
+| Compliance | one audit record per call | the same record, different noun |
+| Metering | tokens and cost, per principal and payer | calls, per principal |
+| Routing | provider and deployment selection | which backend owns this target |
+
+Only the rightmost columns differ. The claim this document has to make good on is that the
+left column is one implementation.
+
+## Identity — settled
+
+Every authenticated call already resolves an organization, workspace, project and user
+together, and is rejected outright if any is missing. Both gateways inherit that principal
+unchanged.
+
+Nothing to design. What the gateway adds is that the principal must reach the audit record and
+the meter, not merely the authorization check.
+
+## Authorization — inputs established, composition open
+
+Existing pieces: role-based enforcement, and a two-layer entitlement check that runs a cached
+soft check at ingestion and a hard check behind it.
+
+*To establish:* the exact call the gateway makes into each, whether a model or a tool is a new
+permission subject or an existing one, and how a denial is expressed on each north port —
+these have externally-fixed error shapes and cannot simply return our own exception.
+
+**Keep permissions and entitlements distinct.** They answer different questions and
+conflating them in tests or in code is a known trap.
+
+## Governance — open
+
+Allowlists exist in part: per-server tool policy is already on the runner wire as an
+all-or-include list with names, and approval already exists as a per-tool axis with runner
+machinery behind it.
+
+*To establish:* whether allowlists move to the gateway or stay declared per run and are merely
+enforced there; where spend ceilings are evaluated; and how egress policy composes now that
+one host serves all traffic — an allowlist of one becomes coherent where a list of provider
+endpoints never was.
+
+## Compliance — open
+
+*To establish:* one audit record shape covering both planes. It must carry the principal, the
+secret owner, the payer, the upstream target, the decision and its reason, and the
+outcome. The owner and payer are the two fields that cannot be reconstructed later.
+
+Open: whether audit rides the existing tracing pipeline or is a separate durable record. They
+have different guarantees — tracing is sampled and lossy by design, compliance is not.
+
+## Metering and billing — owned, later
+
+Meters and entitlement layers exist. The gateway is the natural place to record model tokens
+and tool calls, since it is the only point that sees all of both. Under D12 it owns billing
+too, and a ledger or a grant is a **caller** rather than a parallel path.
+
+Two things must be true from the first increment, because neither can be added retroactively:
+
+- **Record real usage from day one**, even while charging a simpler price. The data to correct
+ a pricing model later does not exist unless it was written at the time.
+- **Record `secret_origin` and the owner** with every entry, so a call paid for by a customer's
+ own secret is not billed as ours.
+
+*To establish:* the meter keys, and where pricing lives. Parallel work has settled much of
+this already — see `raw/related-work.md`.
+
+## Routing — open
+
+*To establish:* whether routing is pure derivation from the registry or a policy decision that
+can be overridden. This is the difference between a gateway that enforces and a gateway that
+also decides, and it should be chosen deliberately.
+
+## Decision caching
+
+Fail-closed on policy, with the data plane serving cached decisions through a control-plane
+outage.
+
+*To establish:* what is cached, keyed how, for how long, what invalidates it, and which classes
+of call must never be served from cache. The existing soft-check/hard-check split is the
+precedent to follow rather than invent around.
+
+## The test that matters
+
+If this document ends up being two documents — one per plane — with little in common, then
+`decisions.md` D7, which claims one policy core under two protocol surfaces, is wrong and the
+gateways should be separate systems with separate lifecycles. **Watch for that outcome rather than defending against it.**
diff --git a/docs/design/gateways-research/v1/raw/brief.md b/docs/design/gateways-research/v1/raw/brief.md
new file mode 100644
index 0000000000..3484615ec6
--- /dev/null
+++ b/docs/design/gateways-research/v1/raw/brief.md
@@ -0,0 +1,77 @@
+# Brief
+
+## The question
+
+Every outbound call to a model or a tool — from anything on the platform, with no
+exceptions — transits a gateway we own, so that credentials, policy, and audit live at one
+boundary instead of being scattered across sandboxes, workflow processes, and SDK call
+sites.
+
+Concretely: when an MCP server is declared, the caller talks to **our MCP gateway**, and
+the gateway talks to the server. When anything calls a model, it talks to **our LLM
+gateway**, and the gateway talks to the provider. Provider credentials stop at our boundary
+and never travel outward.
+
+"No exceptions" includes everything custom. A custom provider, a self-hosted model server,
+a cloud reseller, an OpenAI-compatible third party — none of these becomes a direct path.
+The call goes to our gateway and the gateway's adapter calls the custom thing. What is
+custom lives *behind* the gateway; the route *to* the gateway is invariant.
+
+This is a large change and it touches many call sites rather than one. Every place that
+today resolves a secret and calls a provider becomes a place that calls the gateway.
+That is the real scope.
+
+## The inversion
+
+This is ports and adapters, with the control inverted at the secret boundary.
+
+**Today** the platform resolves a real third-party secret and hands it to the caller.
+The caller holds the secret and chooses the route. Trust is extended outward, and the
+mitigations for that (placeholder substitution on remote sandboxes, a per-run redaction
+deny-set) are damage control on a decision already made.
+
+**With a gateway** the caller receives a short-lived, run-scoped token that is worthless
+anywhere except our gateway, and a base URL that points at us. The caller no longer
+*chooses* a provider — it *names* a connection, and the gateway binds that name to a real
+route and a real secret. The caller cannot exceed what the name entitles it to,
+because it never holds anything that would let it.
+
+The tool plane already works this way and has for some time: a tool call is a reference
+resolved server-side, not a secret handed out. The model plane does not. Making the
+model plane behave like the tool plane is most of what the LLM gateway is.
+
+## What a gateway is here
+
+Not a proxy with a cache. Six concerns, applied to both nouns:
+
+| Concern | Model plane | Tool plane |
+|---|---|---|
+| **Identity** | which principal is calling this model | which principal is calling this tool |
+| **Authorization** | may they use this model, at this cost | may they use this server, this tool |
+| **Governance** | model allowlists, spend ceilings, data rules | tool allowlists, approval, egress rules |
+| **Compliance** | one audit record per call, with the principal | same record, same shape, different noun |
+| **Metering** | tokens, cost, per principal | calls, per principal |
+| **Routing** | fallback, aliasing, load-balancing, region | which backend server owns this tool name |
+
+The claim this research tests: those are one implementation with two protocol adapters,
+not two implementations.
+
+## Why now, and why together
+
+Three things make this the moment rather than a later refactor:
+
+- **The wire already anticipates it.** Both consumers are already modelled as a route plus
+ typed credentials; neither needs a new field to point at a gateway instead of a
+ provider. The cost of adopting the gateway on the wire is close to zero.
+- **The secret-hiding work has gone as far as it can.** The current design already
+ distinguishes credentials the sandbox must hold from credentials it can be denied, and
+ the ones it must hold are the ones a gateway removes entirely. Further hardening on the
+ present shape has diminishing returns.
+- **The self-hosted story needs it.** The tool-side research concluded that the honest
+ self-hosted path is to be a gateway rather than to clone a catalog. That conclusion
+ applies unchanged to models, where self-hosters already bring their own keys.
+
+## Non-goals
+
+Being a model catalog, or a tool catalog. Replacing the tracing pipeline. Solving triggers
+— inbound events are a separate subsystem for structural reasons and stay that way.
diff --git a/docs/design/gateways-research/v1/raw/credential-model.md b/docs/design/gateways-research/v1/raw/credential-model.md
new file mode 100644
index 0000000000..0c57bf853a
--- /dev/null
+++ b/docs/design/gateways-research/v1/raw/credential-model.md
@@ -0,0 +1,178 @@
+# Secret model: what the gateway hides, and what it cannot
+
+The goal is a gateway that is as transparent as possible: the caller presents one
+secret — ours — and the gateway works out what the upstream needs and supplies it. This
+document states how far that goal reaches and where it stops.
+
+## The caller's side is uniform, always
+
+Whatever the upstream needs, the caller sends the same thing: a gateway URL, a token minted
+by us, and optionally non-secret headers. The caller never learns whether the server behind
+it uses a static key or OAuth, never holds an upstream secret, and never changes shape
+when an upstream server switches auth scheme.
+
+This holds for both planes. A model route and an MCP route differ in protocol, not in how
+the caller authenticates to us.
+
+One consequence worth naming: the runner wire's per-server `credentials` array collapses to
+a single gateway token. The typed secret-header machinery on the wire stops carrying
+upstream secrets, because there are none to carry.
+
+## Two axes, not one
+
+"API key or OAuth" is the axis people reach for first, and on its own it is wrong. It
+conflates two independent properties:
+
+| | **Static secret** | **OAuth** |
+|---|---|---|
+| **Shared** (org/project owns it) | operator API key — a service the org calls as itself | rare, but real: a shared workspace grant |
+| **Per-user** (each user has their own) | personal access token — common, and often mistaken for "shared" | the standard case: acting as the user in their own account |
+
+A static secret is **not** automatically shared, and OAuth is **not** automatically
+per-user. Personal access tokens are static and per-user; some OAuth grants are
+organizational. If the model has one axis, per-user PATs end up either wrongly shared or
+wrongly forced through an OAuth path they do not need.
+
+So a registry entry carries both: **how** to authenticate (`static` | `oauth`) and **who
+owns** the resulting secret (`shared` | `per_user`). The gateway needs both to pick a
+secret: the first says what to do, the second says whose to look up, keyed against the
+`AuthScope` already present on every call.
+
+**Status of each axis.** The auth-scheme axis already exists in the codebase — see
+`existing-gateway-model.md` — so it is not a proposal. The ownership axis does **not**:
+connections and secrets are project-level today, which means every entry is effectively
+`shared`. User-level credentials are a wanted later addition for user-specific model and MCP
+authentication.
+
+The design consequence is narrow but important: **the secret lookup should take the
+owner as a parameter from the start**, and answer "the project" for now. Retrofitting a
+per-user dimension into a lookup that assumes the project is the expensive version of that
+change. Nothing on the caller side needs to wait, since `AuthScope` already carries the user
+on every call.
+
+## What the gateway handles without anyone noticing
+
+Once a secret exists, everything is invisible to the caller:
+
+- Selecting the right secret for this principal and this upstream.
+- Injecting it in whatever form the upstream wants — header, bearer token, signed request.
+- Refreshing an expired OAuth access token and retrying.
+- Retrying transient upstream failures.
+- Enforcing the tool allowlist and the policy checks before anything leaves.
+- Recording the call against the principal for audit and metering.
+
+This is the steady state, and it is the overwhelming majority of calls. For this part the
+transparency goal is fully achievable.
+
+## What the gateway cannot hide
+
+**Consent needs a human, once.** A three-legged OAuth grant requires a person in a browser
+approving access. The gateway can store the result forever and refresh it indefinitely, but
+it cannot mint the first token by itself, and no amount of internal configuration changes
+that. Transparency on the consent path is not achievable — only *relocatable*.
+
+There are exactly two moments this bites:
+
+1. **First use.** No secret exists for this owner and this server.
+2. **Step-up.** A secret exists, but the specific call needs a permission the owner
+ never granted. The current MCP revision specifies this precisely: the server answers
+ `403` with `insufficient_scope` and the scopes it needs, and the client is expected to
+ re-authorize with the union of old and new scopes. So "already connected" does not
+ guarantee "will not need a human again."
+
+Everything else about OAuth — refresh, expiry, retry, storage, audience binding — the
+gateway absorbs.
+
+### How often is "once"
+
+Consent is per **secret owner**, which the ownership axis already defines: once per
+(user, upstream) for a `per_user` entry, once per (project, upstream) for a `shared` one.
+A `shared` entry means one person consents and the whole project inherits it; a `per_user`
+entry means every member consents for themselves, and an admin cannot do it on their
+behalf.
+
+Two details make the count less tidy than "once":
+
+- **Consent and tokens are counted differently.** Tokens are audience-bound — each is
+ minted for one specific server URI — so storage is keyed per (owner, server). But a
+ single human interaction at an authorization server can yield tokens for several
+ resources it governs. So a vendor running several MCP servers behind one authorization
+ server may cost one consent and several stored tokens.
+- **Step-up adds moments after the first.** The required scopes for a call may be
+ determined dynamically from the request's own arguments, so they cannot always be known
+ in advance.
+
+## Open questions
+
+### Q1. Where does consent happen? — largely settled
+
+**In the dashboard, before a run.** Connecting a server is a management action, not a
+runtime one. A run that reaches an unconnected server fails with something actionable, the
+user connects in the dashboard, and re-runs. This matches how connections already work and
+keeps runs free of browser interactions.
+
+Two consequences to design for rather than decide:
+
+- A `per_user` entry means the dashboard needs a per-user connection view, and a project's
+ agent is not usable by a new team member until that member connects for themselves. An
+ admin cannot pre-connect on their behalf. This is an onboarding step, and it should be
+ visible as one.
+- Runs need a pre-flight check. If the gateway can tell before starting that a required
+ server has no live secret for this user, the run should fail immediately with the
+ list of servers to connect, rather than part-way through when the agent first reaches for
+ a tool.
+
+**What is still open is step-up**, because required scopes can depend on a call's own
+arguments and so cannot always be pre-granted. Three ways to handle it:
+
+1. **Over-request at connect time** — ask for the server's full advertised scope set in the
+ dashboard, so step-up almost never fires. Trades least privilege for uninterrupted runs.
+2. **Fail actionably** — treat a scope challenge like an unconnected server: fail the call,
+ name the missing permission, send the user to the dashboard to re-consent.
+3. **Pause mid-run** — the approval machinery and the protocol's input-required pattern
+ could carry it, at the cost of a different run lifecycle.
+
+The specification recommends least privilege with incremental step-up, which is option 2 or
+3. For an agent platform, option 1 is the pragmatic default and can be reconsidered per
+server. Worth an explicit decision rather than a default.
+
+### Q2. One endpoint or one per server?
+
+Either the gateway exposes a single endpoint whose tool list is the merge of every
+registered server's tools, with names namespaced per server to avoid collisions, or it
+exposes one endpoint per registered server.
+
+Header-based routing makes the merged endpoint cheap to implement — the target rides a
+header, so routing needs no body parsing. But the merged list has to be namespaced, and
+namespacing changes the tool names the model sees, which affects prompts and any
+per-tool permission rules. One endpoint per server avoids renaming entirely at the cost of
+more configuration.
+
+### Q3. Whose secret, when the entry says `per_user`?
+
+Settled in shape by the two axes above, but the product question remains: is per-user the
+default, with shared as the exception, or the reverse? This decides the migration story for
+every connection that exists today.
+
+### Q4. What does a self-hoster behind a firewall do?
+
+To complete an OAuth flow the gateway needs a publicly reachable redirect URI, and to use
+the modern registration mechanism it needs a public HTTPS URL serving its client metadata.
+A self-hosted deployment with no public address cannot do either.
+
+Static-secret servers are unaffected, which means the honest self-hosted story may be
+"static credentials work everywhere; OAuth needs a reachable deployment." That is a
+documentation and packaging decision as much as a design one.
+
+### Q5. What does the caller see when a secret is dead?
+
+A revoked or unrefreshable token has to surface as something a human can act on, not a
+generic upstream error. Related: does a dead secret remove that server's tools from the
+list, or leave them present and failing? Removing them is kinder to the model's context but
+makes the tool list vary by secret health.
+
+## Summary
+
+The gateway is fully transparent on the **data path** and cannot be transparent on the
+**consent path**. Every design choice above is really a choice about where to put the
+consent moment, not whether to have one.
diff --git a/docs/design/gateways-research/v1/raw/early-findings.md b/docs/design/gateways-research/v1/raw/early-findings.md
new file mode 100644
index 0000000000..a31789ac95
--- /dev/null
+++ b/docs/design/gateways-research/v1/raw/early-findings.md
@@ -0,0 +1,170 @@
+# Early findings
+
+What is true in the tree today. Findings marked **(carried)** come from the prior
+tool-gateway research and have not been re-verified here; everything else was read
+directly in this branch's base.
+
+## 1. The headline: both gateways are already expressible on the wire
+
+The runner's `/run` contract models the two outbound consumers as parallel shapes — a
+route, typed credentials, and a policy — and both can already point at a gateway without a
+new field.
+
+**Tool side.** `McpServerConfig.connection` is `{ type: "http", url, headers?,
+credentials? }`, where every secret header is a typed `McpSecret` with
+`usage: "opaque_http"`. The protocol's own commentary states that a gateway MCP server is
+"the same shape too: it is an HTTP MCP server whose URL happens to be ours," and that
+OAuth, when it lands, changes only who mints the token — not the wire.
+
+**Model side.** `ModelConnection` carries `provider`, `deployment`, `endpoint.baseUrl`,
+`secretMode`, and typed `credentials`. `deployment: "custom"` is documented as "an
+OpenAI-compatible third party such as OpenRouter **or a self-hosted gateway**," with the
+route in `endpoint.baseUrl`.
+
+So pointing a run at either gateway is, on the wire, a resolver change: emit our URL and
+our token instead of the provider's URL and the provider's key. **The wire is not the
+work.** This is the single most important finding for sequencing — it means the gateways
+can be adopted per-run and rolled back per-run, without a contract migration, and without
+touching the golden fixtures that pin the contract on both sides.
+
+## 2. What the gateway actually removes
+
+The current design has already pushed secret-hiding as far as the shape allows, and
+the residue is exactly what a gateway deletes.
+
+`ModelSecret.usage` has two values, and the split is about the **consumer**, not the
+provider:
+
+- `opaque_http` — a bearer token only the provider's server reads, over HTTPS, at a known
+ host. On a remote sandbox it is replaced with a placeholder and substituted into the
+ outbound request by the egress proxy, so an agent that dumps its own environment gets
+ nothing useful.
+- `local_use` — a secret consumed by a provider SDK **inside** the sandbox, which signs
+ requests locally rather than transmitting the secret. Cloud-reseller access keys are the
+ reason this exists: signing is local, so outbound substitution cannot work and **the
+ sandbox must hold the real value**. The names allowed to claim it are kept to a short
+ explicit allowlist precisely because this door loses the hiding.
+
+Behind a gateway, signing happens at the gateway. `local_use` has no reason to exist for
+gateway-routed runs — the escape hatch that currently forces real long-lived cloud
+credentials into an agent-controlled sandbox closes. That is a security outcome, not a
+refactor.
+
+The same logic applies to the per-run redaction deny-set, which today is built from every
+secret-bearing value across both consumers. Behind a gateway the run holds one
+short-lived token, so the deny-set collapses to one entry with a lifetime measured in the
+length of a run.
+
+## 3. Where credentials live today
+
+Two stores, two paths, and they are not the same path.
+
+**Vault / secrets** (`api/oss/src/core/secrets/`, exposed under `api/oss/src/apis/fastapi/vault/`)
+holds five kinds: `provider_key`, `custom_provider`, `sso_provider`, `webhook_provider`,
+`custom_secret`. Model providers are enumerated in two flavours — a `StandardProviderKind`
+list of direct providers, and a `CustomProviderKind` list that additionally covers the
+cloud resellers and self-hosted deployments. **This enum pair is the closest thing to an
+existing model-routing table, and it is already the right axis** (`provider` = who issued
+the secret, `deployment` = how it is reached) — the same axis the wire uses.
+
+**Gateway connections** (`api/oss/src/core/gateway/connections/`) holds third-party tool
+authorizations as a local row referencing a provider-side connection id; tokens themselves
+are not stored locally **(carried)**.
+
+The consequence: the model plane and the tool plane already have *different* secret
+stores with *different* scoping models. Unifying the policy plane means reconciling them,
+which is a real decision rather than a detail — see `decisions.md`.
+
+## 4. The ports that already exist
+
+The tool side is a working ports-and-adapters implementation, and its shape is the
+precedent the model side lacks:
+
+| Domain | Path | Port |
+|---|---|---|
+| Catalog | `api/oss/src/core/gateway/catalog/` | browse providers/integrations |
+| Connections | `api/oss/src/core/gateway/connections/` | authorization lifecycle |
+| Tools | `api/oss/src/core/tools/` | list / get / execute |
+| Triggers | `api/oss/src/core/triggers/` | subscription lifecycle |
+
+Each carries `interfaces.py`, a `registry.py`, a `service.py`, and per-provider adapters.
+A first-party provider already exists alongside the third-party one under
+`core/tools/providers/agenta/`, which establishes that the registry is genuinely
+multi-provider rather than a single-adapter abstraction wearing a port.
+
+**There is no equivalent for models.** There is no `core/models/` port, no registry, no
+adapter set — the model path resolves credentials from the vault and hands them out. This
+asymmetry is the substance of the LLM-gateway work: the tool plane has the architecture and
+needs a gateway backend; the model plane needs the architecture first.
+
+## 5. Model calls have at least two distinct callers
+
+These are separate callers that must be designed separately, not one path with variants.
+
+- **Agent runs** resolve a `ModelConnection` and inject it into the sandbox, where the
+ harness reads a provider key from an environment variable because that is what the
+ underlying agent SDKs expect.
+- **Workflows** go through the SDK's own model layer (`sdks/python/agenta/sdk/litellm/`),
+ which resolves secrets via `sdks/python/agenta/sdk/managers/secrets.py` and calls the
+ provider from the workflow process.
+
+They differ in who resolves the secret, where the call originates, and what the
+failure modes are. Both go through the gateway, each behind its own port, and the SDK
+keeps the secret-fetch and secret-injection capabilities it has today — what changes is
+that the adapter behind those capabilities calls the gateway instead of a provider.
+
+This list is not proven exhaustive. Establishing the full set of model call sites is a
+prerequisite for sizing the work, and it is in the verification backlog.
+
+## 6. Identity is already user-scoped, and the gateway inherits it
+
+Every authenticated call into the platform already resolves a four-part identity. The auth
+middleware (`api/oss/src/middlewares/auth.py`) builds an `AuthContext` containing an
+`AuthScope` of `organization_id`, `workspace_id`, `project_id`, and `user_id`. All four are
+required: if any is missing the request is treated as unauthenticated rather than
+half-populated. API keys are no exception — the key row carries the owning user, so
+key-authenticated calls resolve to a user like any other.
+
+**So the principal already exists, it is already user-scoped, and the gateway does not need
+to invent one.** A call arriving at either gateway carries the same `AuthScope` that every
+other call into the platform carries. Audit records, policy inputs, and metering dimensions
+all key off it.
+
+This is distinct from — and should not be confused with — how a *third-party* connection is
+scoped upstream at the provider. Who is calling us is answered by `AuthScope`. Which stored
+secret the gateway then uses on the caller's behalf is a separate binding, and the two
+were previously conflated. They are independent: the caller is always a user, regardless of
+whether the secret the gateway selects is shared across a project.
+
+## 7. What already exists that the gateway should not rebuild
+
+- **Approval / human-in-the-loop.** Tool configs already carry a `needs_approval` axis, and
+ the runner already has the interaction machinery to pause a call for sign-off. Most
+ open-source MCP gateways lack this **(carried)**; it is an asset, not a gap.
+- **Per-tool allowlists.** `McpServerConfig.policy.tools` is already `all | include` with
+ names — a tool-level filter on the wire, which is the multi-tenancy lever a gateway needs.
+- **Egress policy.** Sandbox network policy is already declared and enforced on the remote
+ provider as allow / block / CIDR allowlist. A gateway makes this dramatically more useful:
+ once model and tool traffic both go to one host, an allowlist of *one* becomes a coherent
+ posture rather than an unmanageable list of provider endpoints.
+- **Tracing and metering.** Ingestion and the meter/entitlement layers exist. The gateway
+ should emit into them, not beside them.
+
+## 8. Consequences for the design
+
+1. **For the runner caller specifically, adoption is cheap.** The wire already expresses a
+ gateway route, so that caller changes on the resolver side only — the contract, the
+ golden fixtures, and the harnesses are untouched. This is the exception, not the rule.
+2. **Every other caller is a real change.** Each place that resolves a secret and calls
+ a provider becomes a place that calls the gateway, behind its own port. The count of
+ those call sites, not the wire, is the size of this work.
+3. **The model plane needs the port structure the tool plane already has.** The tool plane
+ has registries, interfaces, and multiple providers; the model plane has none of it. This
+ is architecture work, and it is the larger half.
+4. **Identity is not a blocker.** `AuthScope` already gives both gateways a user-scoped
+ principal on every call. Audit, policy, and metering can be shaped against it now.
+5. **The secret the gateway uses is a separate question from who is calling.** Keeping
+ these apart is what makes per-user attribution cheap and per-user credentials optional.
+6. **The strongest concrete security outcome is narrow:** gateway-routed runs stop putting
+ long-lived cloud credentials inside agent-controlled sandboxes, because signing moves to
+ the gateway.
diff --git a/docs/design/gateways-research/v1/raw/existing-gateway-model.md b/docs/design/gateways-research/v1/raw/existing-gateway-model.md
new file mode 100644
index 0000000000..7da0c0306e
--- /dev/null
+++ b/docs/design/gateways-research/v1/raw/existing-gateway-model.md
@@ -0,0 +1,120 @@
+# The existing gateway model, and what it already settles
+
+The connections/catalog/tools/triggers domains already implement most of the secret
+model this research was deriving from first principles. This document records what is
+there, so the MCP gateway extends it rather than reinventing it.
+
+## The shape
+
+Three layers, with the leaf split by domain:
+
+```
+catalog: providers → integrations (shared by tools and triggers)
+leaves: actions (tools) | events (triggers)
+connections: one authorization of one integration (shared shape)
+```
+
+The catalog port is explicitly shared — both domains browse the same integrations — while
+each domain owns its own leaf adapter. Tools take actions, triggers take events. Everything
+below the leaf is common.
+
+## What already exists and should not be redesigned
+
+### The auth-scheme axis
+
+`ConnectionAuthScheme` is `oauth | api_key`, and it is duplicated verbatim as
+`ToolAuthScheme` and `TriggerAuthScheme`. The distinction this research proposed as new is
+already load-bearing across three domains.
+
+### The connection state machine
+
+`ToolConnectionState` — mirrored exactly by `TriggerDiscoveryConnectionState` — is:
+
+- `ready` — an active and valid connection exists; reuse it
+- `needs_auth` — an OAuth integration with no connection; start the flow
+- `needs_input` — an API-key integration; collect a secret first
+
+This is precisely the consent state machine, already implemented. A gateway does not invent
+it; it reports into it.
+
+### One flow for both auth schemes
+
+The connection payload carries `callback_url`, `redirect_url`, and `auth_scheme`, and the
+create-data comments are explicit that **no secret ever rides the payload** — an API key is
+entered on the provider's hosted redirect page, exactly as an OAuth approval is clicked
+there.
+
+This is the answer to making the gateway uniform across auth schemes, and it is already the
+implemented behaviour: **both schemes are "send the user to a URL, they come back
+connected."** The caller sees one flow; only what happens on that page differs.
+
+### Discovery tells the caller how to connect
+
+Discovery results carry a `ConnectAffordance` naming the endpoint to call, alongside the
+connection state. The caller is told what is missing and where to fix it, rather than
+inferring it.
+
+### Lifecycle verbs are already ports
+
+`ConnectionsGatewayInterface` has `initiate_connection`, `get_connection_status`,
+`refresh_connection`, and `revoke_connection`. Refresh is already a port verb, not something
+to add.
+
+### A first-party provider slot exists
+
+`ConnectionProviderKind` and `ToolProviderKind` are both `composio | agenta`. The
+first-party path is already modelled; it is not a new concept.
+
+## The two-level split, and who holds which level
+
+The persisted connection data carries two provider-side identifiers, and they are different
+things:
+
+- **`auth_config_id`** — the registered OAuth *application* for an integration: the client
+ credentials that identify our software to the upstream provider. One per integration, not
+ per user.
+- **`connected_account_id`** — one user's *grant*: the token resulting from consent. One per
+ connection.
+
+The incumbent provider holds both. We persist only a local row pointing at them, plus
+`is_active` / `is_valid` flags. **We store no tokens at all today.**
+
+That is the concrete gap for a self-hosted gateway. Becoming the provider means holding
+both levels ourselves:
+
+- the `auth_config` level — our own client registration per authorization server, which the
+ current MCP revision makes cheap via Client ID Metadata Documents;
+- the `connected_account` level — **a token store with refresh, which does not exist and is
+ the one genuinely new piece of persistence.**
+
+## Scoping: project-level today, user-level later
+
+Every DAO verb is keyed by `project_id`. `create_connection` also takes a `user_id`, but
+that records authorship rather than scoping the lookup — queries and gets are project-scoped
+only. Secrets are project-level for the same reason.
+
+So the ownership axis in `secret-model.md` describes a **future** extension, not a
+current choice. Today every entry is effectively `shared`. User-level secrets are a wanted
+addition for user-specific model and MCP authentication, and the design should leave room
+for them without requiring them.
+
+What this means concretely:
+
+- The gateway's secret lookup must take the owner as a parameter from the start, even
+ while the only answer is the project. Retrofitting a per-user dimension into a lookup that
+ assumes project is the expensive version of this change.
+- `AuthScope` already carries `user_id` on every call, so the caller side needs nothing new
+ when user-level secrets arrive. Only the storage and lookup change.
+
+## Consequences
+
+1. **The MCP gateway extends this model rather than replacing it.** An MCP server is another
+ integration; its tools are that integration's actions; its authorization is an ordinary
+ connection.
+2. **The auth-scheme uniformity question is already answered** — one hosted redirect flow
+ serves both schemes, with no secret on the payload.
+3. **The one new component is a token store with refresh.** Everything else — states, ports,
+ affordances, lifecycle verbs — exists.
+4. **Three duplicated auth-scheme enums** are a sign the domains want a shared secret
+ core, which is what a unified gateway would provide.
+5. **Take the owner as a parameter now**, answer "project" for the time being.
diff --git a/docs/design/gateways-research/v1/raw/gateway-auth-and-protocol.md b/docs/design/gateways-research/v1/raw/gateway-auth-and-protocol.md
new file mode 100644
index 0000000000..ab0b50dca9
--- /dev/null
+++ b/docs/design/gateways-research/v1/raw/gateway-auth-and-protocol.md
@@ -0,0 +1,236 @@
+# LLM and MCP Gateway: Authentication and Protocol Design
+
+## Purpose
+
+Provide one gateway for coding-agent harnesses such as Codex, Claude Code, and OpenCode, while allowing different upstream authentication methods and preserving native model protocols.
+
+The gateway is responsible for:
+
+- Gateway user identity and tenancy
+- Authorization, policy, rate limits, and budgets
+- Usage tracking, audit events, and observability
+- Selecting approved upstream destinations
+- Protecting upstream credentials
+- MCP access control and tool governance
+
+The gateway is not, initially, a universal model-protocol translator.
+
+## Core principles
+
+1. `X-AG-Credentials` is the gateway's credential signal. Its value is opaque to clients and is validated only by the gateway.
+2. Provider authentication and gateway authentication are independent.
+3. Provider credentials never leave the gateway in gateway-owned API/cloud modes.
+4. In subscription pass-through mode, the user's vendor subscription authentication stays in the harness and is forwarded unchanged.
+5. The gateway never forwards `X-AG-Credentials` to an upstream provider.
+6. Model protocols are forwarded natively whenever possible; do not translate formats merely to route traffic.
+
+## Credentials header
+
+Clients send the gateway identity in a dedicated header:
+
+```http
+X-AG-Credentials:
+```
+
+The header is separate from the provider's own API key, OAuth token, cloud identity, or subscription session.
+
+Gateway processing must:
+
+- Authenticate and authorize ``.
+- Associate the request with a gateway user, tenant, and policy context.
+- Remove the header before the upstream request is made.
+- Redact the header from application logs, traces, errors, recordings, and support exports.
+
+The gateway should support revocation, expiry, rotation, scoping, and device or installation binding according to its own credential design. This document deliberately does not prescribe the credential's shape.
+
+## Model authentication modes
+
+### 1. Gateway-owned upstream authentication
+
+Use this mode for centrally managed API and cloud-provider access.
+
+```text
+Harness
+ X-AG-Credentials:
+ |
+ v
+AG Gateway
+ - authenticate gateway user
+ - apply policy and select route
+ - obtain upstream credential from a vault or workload identity
+ |
+ v
+Provider API or cloud endpoint
+```
+
+The gateway may authenticate upstream using:
+
+- Provider API keys
+- AWS IAM roles and SigV4 for Bedrock
+- Google service accounts or workload identity for Vertex AI
+- Azure managed identity or API credentials
+- A tenant-specific or user-supplied API credential stored in a secure vault
+
+The harness receives no upstream secret. Upstream billing belongs to the account selected by the gateway.
+
+### 2. Subscription pass-through
+
+Use this mode only where a harness has a vendor-supported subscription login and can route requests through the gateway.
+
+```text
+Harness
+ vendor subscription authentication
+ X-AG-Credentials:
+ |
+ v
+AG Gateway
+ - authenticate gateway user
+ - authorize pass-through route
+ - remove X-AG-Credentials
+ - preserve vendor authentication
+ |
+ v
+Vendor subscription service
+```
+
+The provider continues to authenticate and bill the user's subscription. The gateway credential supplies the gateway's own attribution, tenancy, policy, and audit context.
+
+The gateway should not centralize or replay subscription session files as a substitute for per-user vendor authentication. Those sessions are user credentials, may be renewable or device-bound, and do not provide a safe general-purpose service-credential model. A gateway credential cannot itself prove entitlement to a user's vendor subscription.
+
+If it matters that the gateway user and the provider subscription principal are the same person, use a provider-supported identity claim, introspection mechanism, or explicit account-pairing workflow. Do not depend on parsing or retaining opaque subscription tokens.
+
+### 3. Hybrid local-agent mode
+
+Some harnesses may not support custom outbound headers or refreshable gateway credentials. Use a local agent in that case.
+
+```text
+Harness --> local AG agent --> AG Gateway --> provider
+ | |
+ vendor auth gateway identity
+```
+
+The local agent can authenticate to the gateway using a short-lived credential or mTLS and add the gateway identity without changing the harness's native provider login behavior.
+
+## Harness configuration model
+
+Every harness needs two independently configurable concepts:
+
+1. The model provider base URL, pointed at the AG Gateway.
+2. A way to attach `X-AG-Credentials: `.
+
+For subscription pass-through, do not configure a provider API-key override that causes the harness to stop using its subscription login.
+
+Conceptually:
+
+```text
+base URL: https://gateway.example
+gateway identity: X-AG-Credentials:
+provider identity: harness-managed subscription login, or gateway-managed upstream auth
+```
+
+Claude Code supports a custom-header mechanism and a base-URL override. OpenCode supports provider-specific request headers and a base URL. Codex provider configuration supports additional fixed or environment-derived HTTP headers. Validate behavior against the exact harness release before rollout.
+
+## Protocol-preserving proxy
+
+The gateway's initial model plane should be a protocol-aware but body-preserving reverse proxy.
+
+```text
+/v1/responses -> OpenAI Responses upstream
+/v1/chat/completions -> OpenAI Chat Completions upstream
+/v1/messages -> Anthropic Messages upstream
+```
+
+For an approved route, the gateway forwards:
+
+- Request method and path
+- Request body without semantic translation
+- Required provider headers, including capability or beta headers
+- Server-Sent Events (SSE) streams without changing their event format
+- Provider response body, status, and relevant headers
+
+This avoids the fragility of translating one provider's tool-use, reasoning, cache, streaming, and structured-output semantics into another provider's API format.
+
+### Permitted inspection
+
+“Pass-through” does not mean no gateway processing. The gateway must at least inspect or control:
+
+- Destination and route
+- HTTP method and endpoint
+- Gateway identity and authorization result
+- Presence of required provider authentication
+- Status, latency, request/response byte counts, and errors
+- Token usage when supplied in an upstream response or terminal stream event
+
+If policy is model-specific, the gateway must additionally inspect the model field. If the body must remain fully opaque, enforce policy at the endpoint, credential, tenant, or route level instead.
+
+## Routing and policy
+
+On every model request:
+
+1. Authenticate `X-AG-Credentials`.
+2. Determine the gateway user, tenant, project, and applicable policy.
+3. Classify the request by protocol endpoint and approved route.
+4. Verify the required authentication mode:
+ - valid vendor subscription authentication for pass-through; or
+ - gateway-owned upstream authentication for API/cloud routes.
+5. Enforce allowed endpoints, providers, models, rate limits, budgets, and data policy.
+6. Remove gateway-only headers and forward the request.
+7. Stream the response back while collecting permitted telemetry.
+8. Emit an audit event with gateway identity, route, protocol, timing, status, and usage.
+
+The gateway should fail closed when a route is unknown, a required credential is missing, or a protocol feature cannot be preserved.
+
+## Observability and audit
+
+Record gateway metadata separately from provider metadata.
+
+Recommended fields:
+
+- Gateway user and tenant
+- Harness and harness version, where available
+- Route and protocol endpoint
+- Provider and model, where safely available
+- Authentication mode: `gateway-owned`, `subscription-pass-through`, or `byok`
+- Request ID and upstream request ID
+- Start/end time, latency, status, retry count, and stream outcome
+- Token usage and cost only when reliably reported
+- Policy decision and denial reason
+
+Avoid recording prompts, completions, provider authorization values, or `X-AG-Credentials` by default. Make payload capture an explicit, access-controlled, retention-limited feature.
+
+## MCP gateway
+
+The MCP plane is independent of model authentication.
+
+```text
+Harness --> AG MCP Gateway --> approved MCP servers
+```
+
+The MCP gateway should use the same gateway identity model, with `X-AG-Credentials` or the equivalent identity mechanism for the selected MCP transport. It can then:
+
+- Expose only permitted tools by tenant, user, project, or environment
+- Perform OAuth or API-key handling for downstream MCP services
+- Apply per-tool authorization and audit logging
+- Enforce output and data-handling limits
+- Aggregate multiple downstream MCP servers behind one endpoint
+
+Users may use a direct vendor model subscription while still using the AG MCP Gateway for centralized tool governance.
+
+## Explicit non-goals for the first version
+
+- Translating Anthropic Messages into OpenAI Responses, or the reverse
+- Advertising universal feature compatibility across models
+- Treating vendor subscription sessions as centrally managed service credentials
+- Forwarding gateway credentials to providers
+- Storing secrets in harness configuration files or source control
+
+## Recommended rollout order
+
+1. Implement a body-preserving proxy for one provider protocol and one upstream route.
+2. Add `X-AG-Credentials` validation, header stripping, audit events, and rate limits.
+3. Add subscription pass-through only after testing the harness's vendor-auth behavior and required headers.
+4. Add gateway-owned API/cloud routes backed by a vault or workload identities.
+5. Add the remaining native protocol front doors.
+6. Add an MCP gateway with scoped downstream credentials and per-tool policy.
+7. Add a local agent for harnesses that cannot carry a second gateway identity signal.
+
diff --git a/docs/design/gateways-research/v1/raw/mcp-2026-07-28.md b/docs/design/gateways-research/v1/raw/mcp-2026-07-28.md
new file mode 100644
index 0000000000..4684453606
--- /dev/null
+++ b/docs/design/gateways-research/v1/raw/mcp-2026-07-28.md
@@ -0,0 +1,129 @@
+# MCP 2026-07-28: what it changes for a gateway
+
+The current MCP specification revision is **2026-07-28**, published two weeks before this
+note and the largest revision since the protocol launched. It supersedes `2025-11-25`,
+which is what the earlier tool-gateway research was written against. Several of that
+research's assumptions are now stale.
+
+## The protocol went stateless
+
+- Protocol-level sessions and the `MCP-Session-Id` header are **removed** from the
+ Streamable HTTP transport.
+- The `initialize` / `notifications/initialized` handshake is **removed**. Every request
+ carries its protocol version and client capabilities in `_meta`.
+- `server/discover` replaces `initialize` for version and capability negotiation. Servers
+ MUST implement it; clients MAY call it.
+- SSE stream resumability (`Last-Event-ID`, event IDs) is **removed**. A broken stream
+ loses the in-flight request and the client re-issues it with a new request id.
+- Servers needing cross-call state use explicit server-minted handles passed as ordinary
+ tool arguments.
+
+Any request can land on any server instance behind a round-robin load balancer with no
+shared storage.
+
+## Three changes that are explicitly about intermediaries
+
+The specification names gateways as a beneficiary, which is unusual and worth taking
+seriously:
+
+1. **Header-based routing.** `MCP-Method` and `MCP-Name` are required headers on Streamable
+ HTTP POST requests, so a gateway can route and authorize on headers without parsing the
+ JSON body. `MCP-Name` carries the target for `tools/call`, `resources/read`, and
+ `prompts/get`.
+2. **Cacheable list results.** `tools/list`, `prompts/list`, `resources/list`,
+ `resources/read`, and `resources/templates/list` now require `ttlMs` and `cacheScope`,
+ where `cacheScope` is `public` or `private` and explicitly controls whether **shared
+ intermediaries** may cache the response. List endpoints no longer vary per connection.
+3. **Multi Round-Trip Requests (MRTR).** Server-initiated requests — `sampling/createMessage`,
+ `elicitation/create`, `roots/list` — are replaced by the server returning an
+ `InputRequiredResult` that the client answers by **retrying the original request** with
+ `inputResponses`. All results now carry a `resultType` of `complete` or `input_required`.
+
+MRTR is the big one for us. Under the old design a gateway had to broker a bidirectional
+conversation, because the server could call back into the client mid-request. Under MRTR it
+is plain request/response with retries. That turns an MCP gateway from a stateful broker
+into a stateless proxy, which is most of what makes "everything transits the gateway"
+affordable.
+
+## Authorization: optional in general, mandatory in shape when used
+
+The normative line is direct:
+
+> Authorization is **OPTIONAL** for MCP implementations.
+
+With two clarifications that matter:
+
+- HTTP-transport implementations **SHOULD** conform to the authorization spec when they
+ support authorization at all.
+- **STDIO implementations SHOULD NOT** follow it, and should take credentials from the
+ environment instead.
+
+So a server that simply accepts a static bearer token or API key in a header is within
+spec. Whether OAuth is needed is a **per-server** property, not a protocol-wide
+requirement. This is the part that makes a large class of servers cheap to support.
+
+But when a server *is* OAuth-protected, the client obligations are heavy and mostly
+non-negotiable: OAuth 2.1 with PKCE, RFC 9728 Protected Resource Metadata for discovery,
+RFC 8707 resource indicators (`resource` parameter MUST be sent on authorization and token
+requests regardless of AS support), RFC 9207 `iss` validation before redeeming a code,
+step-up flows on `insufficient_scope` with scope-union accumulation, and refresh-token
+custody.
+
+Client registration moved: **Dynamic Client Registration is deprecated** in favour of
+**Client ID Metadata Documents**, where an HTTPS URL serves as the `client_id` and the
+authorization server fetches metadata from it. Pre-registration remains available.
+
+## The constraint that decides gateway shape
+
+Two normative rules together:
+
+> MCP clients **MUST NOT** send tokens to the MCP server other than ones issued by the MCP
+> server's authorization server.
+
+> MCP servers **MUST NOT** accept or transit any other tokens.
+
+A gateway therefore **cannot pass a caller's token through to an upstream server**. It has
+to be two things at once:
+
+- a **resource server** to the caller, validating a token minted for the gateway itself;
+- an independent **OAuth client** to each upstream server, holding its own tokens issued by
+ that server's own authorization server.
+
+This is the two-layer auth split the earlier research described, now enforced by the spec
+rather than merely advisable. It means the token-custody work cannot be avoided by
+proxying, only by restricting ourselves to servers that take static credentials.
+
+## Statelessness and OAuth are orthogonal
+
+Worth stating plainly because it is an easy conflation: going stateless removes **protocol
+session** state. OAuth is **secret lifecycle** state — expiry, refresh, step-up scopes,
+per-caller-per-server tokens. The new revision removes the first and leaves the second
+untouched; refresh tokens and step-up flows are still fully specified.
+
+The gain from statelessness is not less OAuth. It is a much cheaper gateway: no session
+affinity, no bidirectional brokering, header-level routing, and cacheable tool lists.
+
+## Deprecations that touch us
+
+- **Roots, Sampling, and Logging are deprecated** (minimum twelve-month window). The
+ suggested migration for Sampling is to integrate directly with LLM provider APIs — which,
+ in a two-gateway world, means a server that needs a model calls the LLM gateway. The two
+ planes meet exactly here.
+- **HTTP+SSE transport** is reclassified as deprecated; Streamable HTTP is the path.
+- OpenTelemetry trace context propagation is now documented for `_meta`
+ (`traceparent`, `tracestate`, `baggage`), which lines up with the tracing pipeline that
+ already exists.
+
+## Consequences
+
+1. Target the stateless revision. The features that made a gateway expensive — sessions,
+ resumability, server-initiated callbacks — are the ones that were removed.
+2. Auth support splits cleanly by server, not by protocol: static-secret servers are
+ nearly free; OAuth-protected servers need a real client implementation.
+3. Being an OAuth client is unavoidable for the OAuth-protected set, because token
+ pass-through is forbidden.
+4. Client ID Metadata Documents remove the per-provider app registration that previously
+ made breadth expensive on the client side.
+
+*Sources: modelcontextprotocol.io specification 2026-07-28 (changelog, authorization);
+blog.modelcontextprotocol.io 2026-07-28 release post.*
diff --git a/docs/design/gateways-research/v1/raw/model-call-sites.md b/docs/design/gateways-research/v1/raw/model-call-sites.md
new file mode 100644
index 0000000000..9dacf31c57
--- /dev/null
+++ b/docs/design/gateways-research/v1/raw/model-call-sites.md
@@ -0,0 +1,144 @@
+# Model call sites, and whether the routing library runs in-process
+
+Two checks that gate the work packages. Both are closed.
+
+**Revised after a second pass against the code.** The first version of this document got the
+count and one attribution wrong, and drew two conclusions that later decisions reversed. What
+changed is recorded at the bottom.
+
+## Check 1: the call sites
+
+Decision D1 says every model call transits a gateway, so this list is the scope of the model
+work.
+
+**Result: six call sites across three shapes, not four paths. Every provider call comes from the
+SDK or from the harness. The API calls no models.**
+
+All the in-repo provider calls live in one file, `sdks/python/agenta/sdk/engines/running/handlers.py`.
+
+| # | Where | Call | Kind |
+|---|---|---|---|
+| 1 | the LLM-as-judge evaluator | the routing library's async completion, through the shared retry wrapper | chat |
+| 2 | the shared completion path for prompt workflows | the same wrapper, reached from two callers | chat |
+| 3 | the `llm_v0` agent tool-loop | the routing library **directly**, bypassing the wrapper | chat |
+| 4 | a similarity evaluator | the OpenAI client directly, twice per call | **embeddings** |
+| 5 | a second similarity evaluator | the OpenAI client directly, twice per call | **embeddings** |
+| 6 | the harness, inside the sandbox | the harness's own client | chat |
+
+Sites 1 and 2 go through a shared retry-and-mock wrapper in the SDK that lazy-loads the routing
+library and retries on a closed client. Site 3 does not.
+
+A mock path also exists. It makes no outbound call and is not a site.
+
+### There is no router object
+
+The routing library ships a `Router` class. **Nothing in this repo instantiates it.** The only
+matches for a router constructor in the SDK are the web framework's own. "Routing" here means the
+library's model-string provider dispatch — you pass `model="anthropic/claude-…"` and it picks the
+transport. The earlier claim that a call went "through the router" was wrong in a way that
+matters: there is no retry, fallback, or load-balancing object to inherit, so anything the design
+wants from those has to come from somewhere else.
+
+### What is not a call site
+
+- **The API.** Neither tree calls a model provider. There is one import of the routing library,
+ in the tracing tree, for the cost calculator only. The static model catalogue in the SDK uses
+ the same calculator to derive per-model costs. Pricing is the library's entire role outside the
+ completion calls.
+- **The runner.** Its model module picks a model id and checks it against what the harness
+ accepts, failing loudly when the harness cannot set the requested model. The secret goes to
+ the harness and the harness makes the call.
+
+The blast radius is one SDK file plus the harness path.
+
+## The finding about embeddings
+
+Two of the five in-repo sites call **embeddings**, not chat, and each issues two calls — one for
+the output and one for the reference. The design assumed chat throughout.
+
+Both sites also:
+
+- read the OpenAI key straight from the vault list, matching on the inner provider name;
+- hardcode the provider, with no abstraction to swap it;
+- **bypass the provider-settings builder entirely**, hand-rolling the secret lookup.
+
+They are the least abstracted callers in the tree.
+
+**This does not force an embeddings route now.** These two sites are evaluator paths, and the
+current scope is the gateways, agent v0, the runner and the harnesses (D15). The route arrives
+with the evaluator path. What the finding does establish is that the model north port cannot be
+assumed to be chat-shaped forever.
+
+## Check 2: does the routing library run in-process?
+
+**Result: yes. One pattern in the current code must not survive the move.**
+
+The library ships two separate things:
+
+- **The in-process SDK** — a completion call plus a router object that adds retries, fallbacks,
+ load balancing, cost tracking and callbacks. This is the routing and dispatch we want, and as
+ noted above we currently use only the completion call, not the router object.
+- **A proxy server** — a separate deployment adding virtual keys, an admin interface, per-team
+ secret routing and spend tracking.
+
+Calling the library in-process bypasses the proxy completely.
+
+That split is convenient rather than awkward. **The proxy is the part that competes with our
+policy plane** — its virtual keys occupy the same role as our gateway token, and its secret
+routing the same role as our resolution modes. We want the routing, not the policy. This is
+decision D9: embed the commodity, own the policy.
+
+### Per-request credentials work, and one call site does it wrong
+
+The supported in-process pattern passes the key and the base URL as call arguments.
+
+**The provider-settings builder already produces exactly that** — a dictionary splatted into the
+completion call, always carrying the model and conditionally the key or a set of extras. It lives
+in the SDK's secrets manager, not in the folder named after the model library; that folder holds
+an observability callback. Anyone sizing this work from folder names will size the wrong thing.
+
+**There are two copies of the builder.** The same logic exists twice, differing only in which
+execution context it reads secrets from — the older routing context and the newer workflow
+context. The workflow copy is the one both production chat sites actually call. An extraction
+that takes only one leaves a live second implementation behind.
+
+The `llm_v0` handler does neither. It assigns keys to **module-level attributes on the library**,
+one per provider, before it calls.
+
+That is process-wide state. Today each workflow process serves one tenant, so it survives. In a
+shared gateway process it would be a cross-tenant secret leak: one caller's key stays set and
+serves the next caller.
+
+**This pattern must not move to the gateway** — but it is not a prerequisite either. It exists
+*because* nothing hands the handler a resolved connection; proper injection through the gateway is
+what removes it. The handler is also reported unused and likely to be dropped. See `notes.md` for
+why sequencing this ahead of its enabler produces a plan that cannot start.
+
+### One dependency note
+
+The library is declared only in the SDK's own project file, not the API's, though it resolves into
+the API environment today. If routing moves into the API or a gateway service, that service must
+declare it.
+
+## What these two answers unblock
+
+- The model plane is a **library integration, not a service**. No second deployment for routing.
+- The package that converts callers is **small in file count and large in care**: one SDK file
+ holds five of the six paths.
+- The extraction must take **both copies** of the provider-settings builder, plus the completion
+ call, and leave the observability callback where it is.
+- Whatever the router object would have given us — retries, fallbacks, load balancing — is not
+ currently in use and is not inherited by moving.
+
+## What this revision changed
+
+- **The count.** "Four paths" became six sites across three shapes. The chat path has three call
+ sites, not one.
+- **An attribution.** The first chat row named the `llm_v0` handler as the site going through the
+ routing library's completion. It is in fact the one site that bypasses the shared wrapper and
+ sets module-level globals — the opposite of the well-behaved path.
+- **The router object.** Claimed as used; it is not instantiated anywhere.
+- **The embeddings conclusion.** "The north port needs an embeddings route" was scope creep from
+ a correct finding. Deferred with the evaluator path (D15).
+- **The global-key conversion.** Called a prerequisite. It is an outcome of the conversion, not a
+ gate in front of it.
diff --git a/docs/design/gateways-research/v1/raw/related-work.md b/docs/design/gateways-research/v1/raw/related-work.md
new file mode 100644
index 0000000000..d5c3d40d7b
--- /dev/null
+++ b/docs/design/gateways-research/v1/raw/related-work.md
@@ -0,0 +1,110 @@
+# Related work: who else is designing this gateway
+
+Four efforts include an LLM gateway. Three of them were written independently of this one.
+This document maps them, so the design stops colliding and starts converging.
+
+Ownership and scope are now settled — see the last two sections, and D11 and D12. Metering and
+billing are **owned by the gateway and delivered later**, which is not the same as being out of
+scope.
+
+## The four
+
+| Effort | Where | Gateway scope |
+|---|---|---|
+| This design | `gateways-research` | models **and** MCP, every caller, every provider |
+| Credits and the LLM gateway | a separate private repo | models only, the funded path |
+| Activation credits | `docs/activation-credits-proposal` | models only, one model, the trial path |
+| Bring-your-own secrets | `feat/metering-track-d` | secrets and their origin, not a request path |
+
+The first three all specify a request path for model calls. Only this one covers MCP.
+
+## What the credits design settles better than this one
+
+Read `model-call-sites.md` first for the library question; this is the rest.
+
+- **The run token is the cached policy decision.** It names the organization, project, run,
+ permitted model, a token ceiling and a spend cap. This design left that mechanism open.
+
+ **Correction — this entry cited the wrong shape.** It previously described a *signed token
+ carrying its own claims*, verified without a database read. That is the credits work's
+ **proposal A**, which its own report considered and rejected. Its decision (§6.2, item 1) is
+ the opposite: *"Take B"* — an opaque random string whose digest is stored, so the gateway
+ reads a row per call. The reasoning is that statelessness saves a round trip the design never
+ banks, because *"every model call already needs a Postgres transaction in order to place a
+ hold"*, and it is paid for *"in the currency of revocation"* — the row can be revoked the
+ moment a run ends, a user cancels, or an organization is suspended, and it is also the natural
+ home for the per-run cap.
+
+ Whichever wave designs the funded-run token should resolve it against that argument, not
+ against the discarded proposal. Note the two shapes differ on where the constraint lives, not
+ on whether it exists: the permitted model and the ceilings are in a **row** keyed by the token
+ digest, which is not the payload claim set D13 declines to add.
+- **The north port shape.** One endpoint, the body byte for byte, all metadata in the URL or a
+ header. It agrees with the header-based routing the current MCP revision requires, so both
+ planes route the same way.
+- **Where it runs.** Its own process, from the same image and codebase, beside the existing
+ entrypoints — own worker count, own stream timeouts, and a shared codebase so two writes
+ commit in one local transaction. An internal HTTP hop between gateway and API is rejected on
+ the grounds that it adds a network dependency to every stream.
+
+## What the activation-credits design adds
+
+Its floor version names the gateway as **the one piece that cannot be stripped**, and gives the
+reason plainly: the sandbox is user-controlled, so a raw platform secret inside it is
+stealable, and no counter anywhere else fixes that.
+
+That is the same security argument this design makes, reached independently.
+
+## What the BYOS track changes here
+
+This one touches the secret model directly, and it is further along than this design.
+
+**Vocabulary, which this design got wrong.** The established rule: a customer's provider key
+is a **secret**. The word **credentials** is reserved for Agenta's own auth — API keys, secret
+tokens, access tokens. This design used "secret" for upstream provider material
+throughout. The other usage is already in the tree, so this design should move.
+
+**`secret_origin: vault | local`** stamps whether a secret is the customer's or the platform's.
+It is a third axis beside auth scheme and owner, and it is the same fact this design called
+the *payer* in the resolution result. Their name is implemented; adopt it.
+
+**New secret kinds for sandbox providers and the gateway provider key**, in the same encrypted
+table. This design proposed the same move for MCP. The kinds should be designed together
+rather than twice.
+
+**A prerequisite this design missed.** Their task D0 records that the secrets read surface
+returns plaintext material to any caller holding the view permission, and that the agent path
+resolves straight through it and bypasses the gates. Everything here assumes resolution goes
+through the secrets service safely. **It does not today.** See `open-reviews.md` OR14.
+
+## The scope question — settled
+
+Two of the four scope the gateway to the *funded* path, and return a gateway route only when a
+run is funded. This design's D1 says everything transits, always.
+
+**Settled by D12: funded-only is a delivery phase, not the design.** The target stays "all
+calls transit". A funded-first version is a step toward it, not a different destination.
+
+The mechanism was never in conflict — the same run token, the same endpoint, the same
+secret swap. Only the trigger differed.
+
+## Ownership — settled
+
+**This design owns the gateway (D11).** The others are inputs to it and consumers of it.
+
+The credits ledger and the trial grant are **callers**. They decide what a run may spend. They
+do not define the gateway, and neither ships a second request path. Under D12 the gateway owns
+identity and permissions, governance, secrets, and metering and billing — so a billing need is
+met by the gateway growing, never by billing routing around it.
+
+What each effort contributes:
+
+- **This one** — MCP, the secret model, the transit rule, and the concern set.
+- **The credits design** — the hot path: the run token, the endpoint shape, the process
+ placement. Further along and more concrete than what this document had.
+- **Activation credits** — the argument that the gateway is the unstrippable piece, and the
+ trial-path requirements.
+- **BYOS** — the vocabulary, `secret_origin`, the sandbox and gateway secret kinds, and the
+ read-surface prerequisite.
+
+Metering and billing then arrive incrementally, on a gateway that already exists.
diff --git a/docs/design/gateways-research/v1/scope-checklist.md b/docs/design/gateways-research/v1/scope-checklist.md
new file mode 100644
index 0000000000..db0844c509
--- /dev/null
+++ b/docs/design/gateways-research/v1/scope-checklist.md
@@ -0,0 +1,181 @@
+# Scope checklist
+
+Everything each gateway could do, with the wave it lands in. **Both gateways are being built.**
+This decides *when* each capability arrives, not which gateway happens.
+
+**The mark is a wave, never "in or out".** In-and-out was the wrong axis: almost nothing is
+genuinely out, so "out" ended up meaning three different things — not now, not ever, and not
+decided — and the column stopped carrying information.
+
+| Mark | Meaning |
+|---|---|
+| `1` | Wave 1 — both gateways working end to end, on the mocks and our own servers |
+| `2` | Wave 2 — every caller converted |
+| `3` | Wave 3 — OAuth end to end |
+| `later` | Real gateway work, after C3 |
+| `—` | Out of this work; a separate effort owns it |
+
+**A checkpoint is a deploy, not a release.** Nothing between here and C3 carries user
+traffic, so nothing observable happens that could have been recorded and was not. That kills the
+usual argument for building recording early — "it cannot be backfilled" is only true once there
+is something real to miss.
+
+Anything that is not gateway work at all is **not listed**. A row that will never be marked is
+noise on a decision surface.
+
+---
+
+## The floor — no choice here
+
+Without these there is no gateway, only an open proxy. They are not markable.
+
+| Item | Why it is not a choice |
+|---|---|
+| Ingress surface | Something has to receive the call |
+| Token verification | Without it anyone reaches any target |
+| Secret resolution | The gateway has to find the upstream secret |
+| Secret injection | This is the containment property, and the point of the gateway |
+| Forward and return | Including streaming, since every harness streams |
+| Endpoint CRUD (both) | Custom endpoints need creating and configuring; standard ones are generated |
+| Test doubles | A mock LLM endpoint and a mock MCP server; nothing third-party in tests |
+
+**What already exists.** `sign_secret_token` produces an HS256 JWT carrying `user_id`,
+`user_email`, `project_id`, `workspace_id`, `organization_id`, `organization_name` and an
+expiry, currently **15 minutes**. It travels as `Secret `, one of three accepted schemes
+beside `Bearer` and `ApiKey`, and the middleware verifies it by decode alone — no database read.
+It rides `X-AG-Credentials` in preference to `Authorization`, which a pass-through caller needs
+for its own vendor auth (D31).
+The workflow invoke prelude already mints one per run, centralised so batch and detached cannot
+drift on auth.
+
+**Settled:** one gateway-wide token, unchanged. No target claim and no permitted set — the
+gateway authorises per call through the normal permission path. Per-endpoint tokens and a
+permitted set arrive later with user-owned secrets, when the grain goes project, then user, then
+endpoint. Batch minting is an optimisation, not a scope item.
+
+**An endpoint is a server** (D19): an LLM endpoint is a provider serving many models, an MCP
+endpoint is a server serving many tools. **Standard endpoints are generated, not stored** (D20)
+— the provider-to-models catalogue is already a static map in the SDK, so a standard route is
+derivable from the provider name and no slug is needed. Only custom endpoints become rows.
+
+---
+
+## Shared
+
+| Wave | Item | Why there |
+|---|---|---|
+| 1 | Permission check on the target | Otherwise any authenticated user reaches any registered target. Without it wave 1 is an open proxy |
+| later | Entitlement check | **Moved out of wave 1.** Every user has both gateways — there is nothing to gate on. What entitlements will actually express here are *limits*, and a limit is meaningless before anything is measured, so this ships with usage metering and billing rather than ahead of them (D29) |
+| 1 | Body byte-for-byte, **both gateways** | Not an LLM property. Transparency *is* the MCP gateway — same tool names, same schemas, same errors — and on the model side it is what keeps prompt caching working. One constraint, stated once |
+| 1 | Outbound target guard on user-supplied URLs | The gateway becomes the process that connects to an address a tenant typed. Without it, a custom endpoint pointed at the cloud metadata address makes us fetch cloud secrets on a tenant's behalf. Nothing is written — the repo's existing guard is called at registration and at relay (D28) |
+| 2 | Audit record | One event per call into the existing events domain |
+| later | Usage recorded | Ships with charging, below |
+| later | `secret_origin` stamp | One field marking whose key paid. It rides the usage record, so it moves with it |
+| later | Endpoint configuration | Timeouts, ceilings and extra headers per custom endpoint. Making calls work comes first; tuning them is second-order and blocks nothing |
+| later | Usage charged | The credits ledger. Recording and charging ship together |
+
+**Why recording waits for charging.** Recording early is normally right because usage cannot be
+backfilled. That argument does not apply here: no checkpoint before C is a release, so no real
+traffic passes and there is nothing to miss. What remains is the cost of guessing — **which
+counters, at which grain, keyed how** — and the only thing that answers it is knowing what will
+be billed. Building the meter before the price produces data nobody can use and a schema to
+migrate.
+
+---
+
+## LLM gateway
+
+| Wave | Item | Why there |
+|---|---|---|
+| 1 | Model allowlist | Custom providers already declare their models by slug; standard providers expose their whole catalogue |
+
+---
+
+## MCP gateway
+
+| Wave | Item | Why there |
+|---|---|---|
+| 1 | Tool allowlist | The runner wire already carries it; enforcing it at the boundary is what makes the boundary real |
+| 3 | OAuth client | The single biggest item, and the reason wave 3 exists |
+| 3 | Consent flow | Required by OAuth; ships with it |
+| 3 | Step-up scopes | Asking for more permission mid-run. Same wave as the client that raises it |
+| — | List caching | An optimisation. Correctness first, and nothing is slow yet |
+| — | stdio servers | Remote only. Spawning processes is a large operational surface for no current caller |
+
+---
+
+## Not gateway work at all
+
+Removed from the list above rather than marked out, because a row that will never be marked
+clutters a decision surface.
+
+| Item | Where it belongs |
+|---|---|
+| Retry policy | Never discussed and not planned. Callers already retry |
+| Fallbacks and model aliasing | Never discussed and not planned. It would make the gateway decide what the caller asked for |
+| Embeddings route | Belongs to converting the remaining services and callers, alongside the evaluator path — the same bucket as every other service, not a gateway capability |
+
+---
+
+## Reachability: what exists, and why the OAuth callback does not need any of it
+
+**The callback needs nothing built (D26).** The user is already looking at the Agenta interface
+in a browser when they click connect, so the address that got them there is one their browser
+reaches. Cloud has a domain, self-hosted production has a domain, development has the tunnel
+already in the compose files. The tunnel stays development-only.
+
+The three patterns below exist for a different problem — a provider needing to reach us — and are
+recorded so nobody proposes them for this one.
+
+**A provider-side relay keyed by a routing value.** Stripe config carries a `webhook_target`
+falling back to `STRIPE_TARGET` and then to the machine's MAC address, so many developers share
+one registered webhook and each receives only their own events.
+
+**A socket subscription, development only.** `dispatcher_composio.py` describes itself as the
+`stripe listen` equivalent: because Composio has no CLI tunnel, it subscribes to trigger events
+over **Composio's own WebSocket** — `composio.triggers.subscribe()` — and forwards each one to
+the local ingress, HMAC-signed with the same secret the API verifies, so the real signature path
+is exercised rather than bypassed. It runs as a compose service under the `with-tunnel` profile,
+on by default and disabled with `--no-tunnel`, and idles when no API key is set. The registered
+webhook URL is a deliberate dummy on an RFC 2606 reserved host — it passes the provider's
+anti-forgery check and is never delivered to, existing only to mint the subscription secret.
+
+**Optional tunnel containers, development only.** Both compose files define them, gated on an
+authorization token; without one they log that the thing they publish is unavailable and do
+nothing. They are absent from the GitHub and production compose files.
+
+**These belong to the development-ingress work, not to this one.** It renames the store's tunnel
+for what it publishes and adds a second that publishes the ingress, forwarding to Traefik — which
+is what serves the gateways' own routes. This design adds no tunnel of its own and changes none
+of theirs; D26 records why a gateway-specific one would duplicate an existing endpoint, break the
+runner's tunnel selection, and cost an agent session the plan may not have.
+
+### Why none of these carries an OAuth redirect
+
+The socket pattern is **not a relay we built**. It works because the provider's own SDK offers a
+subscribe call. An arbitrary authorization server offers nothing equivalent.
+
+More fundamentally, **an OAuth redirect is a browser navigation, not an event delivery.** The
+user's browser has to land on a URL. A browser redirect cannot travel down a socket the
+deployment opened outward.
+
+The payment-provider pattern is server-to-server routing, and has the same problem.
+
+### And why the callback does not need one anyway
+
+The three patterns above answer "a provider must reach us and cannot." The OAuth callback is not
+that question. Nobody needs to reach us who has not already: the browser being redirected is the
+browser that just loaded our interface.
+
+The only part of an OAuth flow that requires an inbound connection from a stranger is the
+**authorization server fetching a client identity document**, and a deployment on an
+internal-only domain answers that by registering outbound instead (D26).
+
+## Deferred, with the reason
+
+| Item | Why |
+|---|---|
+| Embeddings route | The evaluator path is another service, and the current scope is agent v0, the runner and the harnesses |
+| Static MCP secret kind | Current targets are our own gateway and OAuth-protected servers |
+| User-owned secrets | Not implemented today; the lookup already takes an owner so nothing is foreclosed |
+| Account-wide secrets | Nothing needs them |
diff --git a/docs/design/gateways-research/v1/secrets.md b/docs/design/gateways-research/v1/secrets.md
new file mode 100644
index 0000000000..6d415f4820
--- /dev/null
+++ b/docs/design/gateways-research/v1/secrets.md
@@ -0,0 +1,195 @@
+# Gateways: secrets
+
+Secret kinds, ownership, and resolution. Supersedes the scoping draft in `raw/`; this is the
+version the implementation follows.
+
+**Status: the storage pattern and the kinds are settled. Ownership is designed, not
+scheduled.**
+
+## Vocabulary
+
+The tree already fixes these words, and this document follows it:
+
+- A customer's provider key is a **secret**.
+- **Credentials** means Agenta's own auth — API keys, secret tokens, access tokens.
+- **`secret_origin`** is `vault` when the secret is the customer's and `local` when it is the
+ platform's.
+
+Other documents here still say "secret" for upstream provider material. That is the older
+wording and it should move to this one. `raw/related-work.md` records why.
+
+`secret_origin` answers a different question from the owner axis below. The owner says *which
+stored secret to look up*; the origin says *whose money the call spends*. Parallel work on
+bring-your-own secrets already uses the origin to zero-rate customer-funded usage.
+
+**The read surface is an outcome, not a gate.** The secrets read route returns plaintext to any
+caller holding the view permission, and the agent path resolves straight through it. That cannot
+be fixed first: callers read it today because it is how they obtain a provider key at all. Once
+everything goes through the gateway, nothing needs that route, and only then can it be
+restricted. See `notes.md`.
+
+## The storage pattern: reference, never hold
+
+The gateways store **no secret material**. A domain row carries a `secret_id`, the
+secrets service holds the encrypted value, and the consumer resolves it at use time through
+the vault service, reading the value off the returned DTO.
+
+**Domain responses exclude the secret material. They do not exclude the id.** The webhook
+subscription response carries its `secret_id` and withholds only the value — which it has to,
+because edits in this codebase are a full PUT sourced from the freshly fetched entity, so a
+response that dropped the id would make every edit silently unbind the secret. The id is a
+handle, not a secret; resolving it still requires the vault, the encryption context and the
+caller's scope.
+
+Webhook subscriptions and SSO providers already work exactly this way. Following it settles
+several things that would otherwise be design work:
+
+- **Encryption** — the secrets layer already encrypts at rest; the gateways inherit it.
+- **Key management** — unchanged, and not duplicated.
+- **Rotation and deletion** — one place, not one per consumer.
+- **Scoping** — a property of the secrets service, which is why the ownership work below is a
+ change to *that* service rather than to either gateway.
+
+## New secret kinds
+
+Existing kinds are `provider_key`, `custom_provider`, `sso_provider`, `webhook_provider` and
+`custom_secret`. Adding one touches four places and **no schema at all**, because the payload is
+one encrypted blob: the kind enum, a settings DTO plus its wrapper, the union member list on the
+secret DTO, and a branch in the kind validator.
+
+That validator is a hand-written `model_validator(mode="before")` dispatching on the sibling
+`kind` field, not a Pydantic discriminated union — so a new kind must add its own branch or it
+is rejected outright.
+
+**Never overload an existing kind.** The general-purpose custom secret and custom provider
+kinds exist for other things, and reusing one to avoid adding a kind is a false economy.
+
+Two new kinds, per D14.
+
+### `oauth_provider`
+
+Our client registration with an authorization server. The SSO kind is the precedent in both
+name and shape — it already stores a client id, a client secret, an issuer URL and scopes,
+which is exactly this.
+
+One per authorization server. Long-lived, rarely rotated, owned by the platform or the project.
+
+### `oauth_grant`
+
+A user's tokens: access token, refresh token, expiry, the scopes actually granted, and the
+server the token was minted for. Tokens are audience-bound, so a grant is identified by the
+upstream server rather than by the provider.
+
+One per owner per server. Rewritten on every refresh, owned by a person.
+
+### Why two kinds rather than one with sub-kinds
+
+The sub-kind pattern here discriminates the *same thing across vendors* — a provider key has
+one shape and one lifecycle whether it is OpenAI or Anthropic, and the inner field only names
+the vendor.
+
+These two share no fields, and differ in cardinality, lifetime, rotation frequency and owner. A
+single kind would need a union inside it anyway, and every query for one user's grants would
+filter on an inner field instead of on the kind itself.
+
+### What is deliberately absent
+
+**No kind for a static MCP secret.** Under the current scope (D15) the targets are Agenta's
+own MCP gateway and OAuth-protected servers. A third-party server authenticating with a static
+token would need one; that is deferred, not designed away. Its shape is trivial when it arrives
+— the webhook kind is already just a key — and the header it travels in is routing, so that
+belongs on the server's registry row rather than in the vault.
+
+**No kind for the inbound gateway secret.** It is minted, ephemeral and never stored
+(D13). It is Agenta's own auth, not customer provider material, so it is not a secret at all.
+
+### Coordination
+
+The parallel bring-your-own-secrets work is adding kinds to this same enum for sandbox
+providers and the tool gateway key. Several new kinds are entering one enum from two
+directions; agree naming and shapes in one pass.
+
+## Ownership
+
+**Designed, not scheduled.** Today every secret and every connection is project-scoped, so
+every secret is effectively shared.
+
+A secret is owned by exactly one of:
+
+- **project** — what exists today; everyone in the project uses it.
+- **user** — one member's own secret, keyed by **(project, user)** rather than user
+ alone, because the same person may legitimately use different secrets in different
+ projects, and a deleted project should take its secrets with it.
+
+An account-wide secret — one identity across every project — would be a third owner keyed
+by (organization, user), not a variant of the second. Out of scope until something needs it.
+
+### Why design it before building it
+
+The lookup signature is the expensive part to change later. A lookup that takes an owner and
+currently always answers "the project" costs nothing today and absorbs user-level secrets
+as a storage change. A lookup that assumes the project spreads that assumption to every call
+site.
+
+The caller side needs nothing either way: the principal already carries the user on every
+request.
+
+## Resolution modes
+
+Resolution is not simply "user wins." Three modes, declared per entry, because the
+organization sometimes has a legitimate interest in which secret is used:
+
+| Mode | Behaviour | For |
+|---|---|---|
+| `user_optional` | the user's if present, else the project's | the default |
+| `user_required` | the user's, or fail — never fall back | upstreams holding personal data |
+| `project_only` | always the project's; ignore user secrets | mandated secrets, spend control |
+
+The two non-default modes earn the design. `user_required` stops an agent quietly acting as
+someone else's account when it reaches a personal mailbox. `project_only` stops a user's own
+key being used where the organization pays and wants one billing identity.
+
+This produces a deliberate asymmetry: **model secrets will usually be `project_only`;
+tool and MCP secrets usually `user_optional` or `user_required`.**
+
+## Resolution
+
+One function, called by both planes:
+
+```text
+resolve(principal, key, mode) -> (secret, owner, payer)
+```
+
+1. `project_only` → the project secret; fail if absent.
+2. `user_required` → the (project, user) secret; fail if absent, never fall back.
+3. `user_optional` → the (project, user) secret if present, else the project secret; fail if
+ neither.
+
+Failure is never silent and never a fallback to "no secret." It surfaces as the existing
+needs-input or needs-auth state, naming which owner is missing a secret, so the caller
+learns whether *they* must connect or whether an administrator must.
+
+### Why the result is a triple
+
+Two values must travel with the secret, and both are easy to omit and hard to add later:
+
+- **owner** — audit must distinguish "acted with the project's secret" from "acted with
+ their own," or a compliance review cannot reconstruct whose authority a call carried.
+- **payer** — a call running on a user's own secret bills that user's upstream account,
+ not the organization's. A meter recording only the caller attributes spend to the wrong
+ payer, and the data needed to correct it is not retained.
+
+## Effect on existing data
+
+Existing secrets and connections all become owner = project, which is what they already are.
+A default column value, not a data migration.
+
+## Open
+
+- The product default: is `user_optional` the norm and `project_only` the exception, or the
+ reverse? This decides how much existing configuration is revisited when user secrets
+ ship.
+- Whether an administrator sets the mode per upstream, or whether it is a property of the
+ upstream itself.
+- Whether a user-owned secret is visible to project administrators at all, and what that
+ implies for support and for deletion when a member leaves.
diff --git a/docs/design/gateways-research/v1/workstreams/README.md b/docs/design/gateways-research/v1/workstreams/README.md
new file mode 100644
index 0000000000..fbd3c12cc6
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/README.md
@@ -0,0 +1,125 @@
+# Workstreams
+
+One pair of files per work package: `specs-wp{k}.md` (what to build) and `tasks-wp{k}.md`
+(the ordered checklist). They exist so a package can be handed to someone — or to an agent in
+its own worktree — with no context beyond `v1/`.
+
+`specs-*` states the target. `tasks-*` is a working document: check items off, add what was
+missed. Neither carries history; the design documents in `v1/` remain the source of truth,
+and a spec that disagrees with them is a bug in the spec.
+
+**Status: wave 1 written.** Waves 2 and 3 follow the same shape and are deliberately not
+pre-written, because C1's outcome changes them.
+
+## The base
+
+Everything branches from the **current upstream release branch**, not `main` and not a fork.
+That is where this org integrates: feature PRs squash-merge onto the release branch, and the
+release branch merges into `main` as a true merge commit. The two diverge, so this is a real
+choice rather than a detail.
+
+**Observed at prep time: `release/v0.112.0`.** It advances; re-read the branch name when starting.
+
+**The migration chain is `core_oss`, and its head was `oss000000020`.** So WP1's migration is
+`oss000000021` with `down_revision = "oss000000020"`.
+
+There are four chains under `api/oss/databases/postgres/migrations/`, and picking the wrong one is
+easy: `core` and `tracing` are **parked legacy chains**, both sitting at `park00000000`, and only
+`core_oss` and `tracing_oss` are live. A head read from `core/` is a parked chain's head and is
+wrong. Re-verify before writing the migration — `oss000000020` advances too.
+
+## Working in parallel
+
+Every package runs in its **own git worktree**, branched from the same seed commit, and merges
+back through review. `plan.md` says what needs what; this section says how to start before a
+dependency is finished.
+
+### The seed comes first, and nothing forks before it
+
+The dependencies between these packages are almost entirely **interface** dependencies. So the
+interfaces land first, on the base branch, before any worktree starts:
+
+1. **Seed commit** — every DTO, every domain exception, and every port, declared, with each body
+ raising not-implemented. Transcribed from `entities.md` §4, §5 and §7, not re-derived.
+2. **Every worktree branches from that commit.** A package that depends on another codes against
+ the declaration and never waits.
+3. **The owner of each declaration fills it in** in their own worktree. Nobody edits a file they
+ do not own.
+
+**The one thing that must be right is the secret resolution signature.** It takes the owner as
+a parameter even though the only answer today is the project (D10). Every package that resolves a
+secret inherits it, and retrofitting it later means touching all of them.
+
+If the seed is wrong, nine worktrees inherit the error. It is worth reviewing properly even though
+it does nothing.
+
+## File ownership
+
+**One owner per file.** A package that needs to change another package's file raises it at a
+merge point rather than editing.
+
+| Path | Owner |
+| --- | --- |
+| `core/gateways/{dtos,types}.py` | seed — nobody edits after |
+| `core/gateways/policy/{dtos,types,interfaces}.py` | seed |
+| `core/gateways/policy/resolution.py` | **WP2** |
+| `core/gateways/policy/service.py` | **WP3** |
+| `core/gateways/llms/{dtos,types,interfaces}.py` | seed |
+| `core/gateways/llms/service.py` | **WP7** |
+| `core/gateways/llms/{registry,catalog}.py` | **WP7** |
+| `core/gateways/llms/providers/translated/` | **WP7** |
+| `core/gateways/llms/providers/passthrough/` | **WP6** |
+| `core/gateways/llms/providers/mock/` | **WP5** |
+| `core/gateways/mcps/{dtos,types,interfaces}.py` | seed |
+| `core/gateways/mcps/service.py` | **WP9** |
+| `core/gateways/mcps/registry.py` | **WP9** |
+| `core/gateways/mcps/providers/http/` | **WP8** |
+| `core/gateways/mcps/providers/mock/` | **WP5** |
+| `dbs/postgres/gateways/llms/`, `dbs/postgres/gateways/mcps/` | **WP1** |
+| the migration | **WP1** |
+| `apis/fastapi/gateways/exceptions.py` | **seed** — three packages need the decorator, so no one package can own it. **Complete, not declared**: it maps exceptions the seed itself defines and depends on no package, so a not-implemented body would leave it unowned |
+| `apis/fastapi/gateways/llms/{proxy,utils}.py` | **WP6** |
+| `apis/fastapi/gateways/mcps/{proxy,utils}.py` | **WP8** |
+| `apis/fastapi/gateways/llms/{router,models}.py` | **WP10** |
+| `apis/fastapi/gateways/mcps/{router,models}.py` | **WP10** |
+| `core/access/permissions/types.py` | **WP3** — the six new members, one edit |
+| `api/entrypoints/routers.py` | **shared, serialised at each merge** |
+
+### The two cuts that make this work
+
+**On each plane, transport and domain are different packages.** WP6 and WP8 own the HTTP surface,
+streaming, timeouts and the byte-for-byte relay. WP7 and WP9 own the service, the registry, the
+catalogue and the allowlists. So the plane's `service.py` belongs to the domain package, not the
+ingress one, and the ingress calls it through the declaration the seed froze.
+
+Getting this backwards is the obvious failure: two packages both editing one service file, both
+blocked on each other, both rebasing constantly.
+
+**`api/entrypoints/routers.py` is never owned.** Four packages need a line in it. Each writes its
+line as a diff in its own `tasks-*`, and the merge applies them together as one edit. A worktree
+that edits it directly creates a conflict for the other three.
+
+## Stacked branches
+
+A stack here is linear. A dependency fan-out is expressed through **PR bases**, not graph shape:
+put everything in one line in dependency order and set each PR's base to the branch below it, so
+each PR shows only its own diff. Lanes touching disjoint files can sit anywhere in the line.
+
+Verify the line by diffing each branch against the one below it — the file list must be exactly
+that lane's files — rather than by eyeballing the tree.
+
+## Rules for anyone working a package
+
+1. **Own your paths.** If a task needs a file you do not own, that is a merge-point conversation,
+ not a commit.
+2. **Rebase at merge points only.** Continuous rebasing spends a package's time on other people's
+ churn; a merge point is where that belongs.
+3. **The design documents win.** A spec that disagrees with `entities.md` is a bug in the spec.
+ Report it rather than implementing around it.
+4. **Do not invent names.** Every DTO, column, method and route already exists in `entities.md`.
+ A name that is not there is a hallucination — including a plausible one.
+5. **Stop at the merge point.** A package that runs ahead into the next one's work is what makes
+ parallel work slower than serial.
+6. **Tests that need a running dependency are not unit tests.** Unit tests import freely and need
+ nothing running. Anything needing the database, Redis or the API is integration or acceptance,
+ and is written but not run unless a local deployment exists.
diff --git a/docs/design/gateways-research/v1/workstreams/launch-2.md b/docs/design/gateways-research/v1/workstreams/launch-2.md
new file mode 100644
index 0000000000..d89986a7b5
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/launch-2.md
@@ -0,0 +1,315 @@
+# Wave 2 launch — to C2
+
+Wave 1 built the gateways. Wave 2 makes them the only way out. Nothing new is designed
+here: the packages are the ones `plan.md` names, and this document is what each worktree
+needs to start without reading the whole of `v1/`.
+
+**C2 is "everything except OAuth works."** Agent v0, the runner and the harnesses
+reach models and MCP servers only through the gateways; every call leaves an audit event;
+no provider secret exists inside a sandbox.
+
+---
+
+## What wave 1 left standing, and what changed under it
+
+Read these before planning a package — all four postdate `plan.md`'s wave-2 outline and
+all four change what a caller sends.
+
+- **The namespaces are `builtin` / `standard` / `custom` (D30).** A gateway route is
+ `/gateways/{plane}/{namespace}/...`, and `builtin` carries a provider segment:
+ `builtin/agenta/tools`, `builtin/composio/notion/my-notion`, `standard/openai`,
+ `custom/{slug}`. A caller that hard-codes a namespace list has three words to get right,
+ not four.
+- **The inbound credentials ride `X-AG-Credentials` (D31).** `Authorization` still works
+ and is what every existing caller uses. The dedicated header exists so a harness can keep
+ its own vendor auth in `Authorization`, and it is the header wave 2's callers should send.
+- **The endpoint document is `route` / `models`|`tools` / `settings`.** Only relevant to a
+ caller that creates endpoints — WP14 and the fixtures, not the runner.
+- **The mock upstreams are reachable and dialable.** `builtin/agenta/tools` is wave 1's one
+ reachable builtin MCP target; `custom/{slug}` against `mock-llm-gateway` is the model
+ equivalent. Every wave-2 acceptance test can run without a third-party account.
+
+---
+
+## Still on paper, and where each piece goes
+
+Nothing in this list is built. Each line is either a package below or an owner it does not
+have yet — no third category.
+
+| Decided | State | Lands in |
+| --- | --- | --- |
+| D33 — protocol front doors | not built; one door exists | **WP23** |
+| D34 — no body conversion | **not enforced**; `TranslatedLLMAdapter` still converts | **WP24** |
+| OD16 — which upstreams a relay-only gateway reaches | not verified | **WP24**, first task |
+| `provider_key` `NOT NULL` | unchanged | **WP24**, with the migration |
+| OD14 — the harness matrix | not run | **WP13**, phase 0 |
+| OD17 — which MCP servers a stateless relay reaches | not verified | **WP15**, phase 0 |
+| Mock upstreams echoing headers | not built | **the seed** (D39) |
+
+D31's data-plane rule and D32's pass-through mechanics are built and tested; they are not
+in this table because there is nothing left of them to do.
+
+## The one gap that reshapes this wave
+
+**A model call cannot carry our credentials in a header today.** The runner wire's
+`ModelCredentialBinding.kind` is `"environment"` and nothing else
+(`services/runner/src/protocol.ts`), and the SDK agrees —
+`EnvironmentCredentialBinding.kind` is `Literal["environment"]`
+(`sdks/python/agenta/sdk/agents/connections/models.py`). So a model connection can say
+"put this value in `OPENAI_API_KEY`" and cannot say "put this value in
+`X-AG-Credentials`".
+
+The MCP side already can: `McpCredential.binding` is `{kind: "header", name}`, which is
+exactly the shape needed, and it is the precedent to copy rather than invent.
+
+This is not a blocker and it is not small. It means **WP13 carries a wire change**, not
+only a resolver change as `plan.md` assumed, and the change is symmetric with an existing
+field rather than novel. Two consequences worth deciding before the package starts:
+
+1. Whether the header binding is a new `kind` on `ModelCredentialBinding` (matching
+ `McpCredentialBinding`) or a separate secret-headers channel on `ModelConnection`.
+ The former is smaller and already has a precedent one interface away.
+2. What each harness does with it. A header binding is only useful if the runner can write
+ it into that harness's configuration — Claude Code, OpenCode and Codex each expose a
+ custom-header mechanism, and each needs verifying against the release in use (OD14).
+
+**The wire is already ahead of us on one thing.** `ModelConnection.credentialMode` has
+`"runtime_provided"` — "the harness authenticates with its own login and we inject
+nothing" — which is D32's subscription pass-through, modelled before the decision named it.
+Wave 2 does not build pass-through, but it should not regress the field either.
+
+**One validator will bite in development.** `ResolvedConnection` requires an `https`
+`endpoint.base_url` whenever a resolved secret is `opaque_http`. A local gateway on `http://`
+fails that check. Decide deliberately whether the loopback case is exempted or whether dev
+runs over TLS; do not discover it in an acceptance test.
+
+---
+
+## Before anything starts
+
+Wave 1's seed was a new domain's declarations. Wave 2's is smaller and has the same
+property: **it changes a signature that several worktrees inherit, so it cannot be written
+inside one of them.** One agent writes it on the base branch and everything else waits.
+
+- [x] **Base branch from the current upstream release branch**, as in wave 1. Re-read the
+ branch name; it advances. — `release/v0.112.1` merged into `feat/gateways`.
+- [x] **The four rulings below are settled.** Each changed a shape more than one package
+ depends on, so none could be deferred into a worktree.
+- [x] **Write the gateway-credentials field** (D36) on the runner wire
+ (`services/runner/src/protocol.ts`) and in the SDK
+ (`sdks/python/agenta/sdk/agents/connections/models.py`), with its validator and its
+ materialization. Declarations only; no caller changes.
+- [x] **Exempt loopback from the https requirement** (D37), as an explicit branch in
+ `ResolvedConnection`'s validator with the reason written on it.
+- [x] **Add the header echo to both mock upstreams** (D39): one endpoint returning the
+ headers of the request it received, in `core/gateways/{llms,mcps}/providers/mock/app.py`.
+- [x] **Verify**: the credentials field round-trips SDK → wire → runner and is materialized,
+ with a test that fails if any leg drops it. This is the specific failure the shape
+ invites, so it is the specific test the seed owes.
+- [x] **Commit, and record the SHA.** Every wave-2 worktree branches from it.
+
+**The seed is `643c76bda2` on `feat/gateways`.** What it gives a package, concretely:
+
+- `ResolvedConnection.gateway_credentials` (SDK) and `ModelConnection.gatewayCredentials`
+ (wire), each `{header, value}` with `X-AG-Credentials` as the header default. The value is
+ masked from every dump and repr.
+- `ResolvedConnection.plaintext_headers()` and the runner's `materializeGatewayHeaders()` —
+ the header counterparts of `plaintext_environment()`. A harness-config writer reads exactly
+ one of them for the header it must set.
+- The loopback exemption on **both** sides, applied to the provider secret as well as to the
+ gateway credentials. It is loopback only: a compose-internal hostname over plain http is
+ still refused, so a dev gateway a package reaches by service name needs TLS or a loopback
+ route. Do not widen the check inside a package; it is a shared shape.
+- The value seeds the runner's redaction deny-set, so it cannot be echoed back out of a run.
+- `POST /__echo/v1/chat/completions` on the mock LLM upstream and `POST /__echo` on the mock
+ MCP one, both returning the headers they received. Reachable **through** the gateway by
+ pointing an endpoint's `base_url` at `/__echo`, which is what makes the `X-AG-Credentials`
+ and pass-through assertions acceptance-level rather than unit-level.
+- The shared golden `model_connection.gateway.json`, asserted from both legs
+ (`test_gateway_credentials.py`, `gateway-credentials.test.ts`). A package that changes the
+ field changes the golden and both tests, deliberately.
+
+### D36 — SETTLED: our credentials are their own field, not a widened binding
+
+`ResolvedCredential.binding` stays `EnvironmentCredentialBinding`. The gateway's credentials
+travel as a distinct field on `ResolvedConnection` and on `ModelConnection`, carrying the
+header name and the value.
+
+**Why not widen the union.** `ResolvedConnection.plaintext_environment()` is the single
+materialization point and every consumer calls it (`agents/interfaces.py`). A header-bound
+credential has no environment variable, so widening the union without moving materialization
+gives a value that validates, serializes, crosses the wire and **vanishes at the boundary** —
+the same silent-drop class as a field a model ignores because it does not recognise it.
+
+**Why a separate field is the honest shape.** These are our credentials, not an upstream's
+secret, and the two are not interchangeable — one authenticates the caller into the gateway,
+the other authenticates the gateway to a provider. `credentials` keeps meaning "the
+provider's secrets"; the new field means "how the harness proves it is us". A harness
+configuration writer then reads exactly one place for the header it must set.
+
+**What the seed owes.** The field on both sides, its validator, and a test that fails if any
+leg of SDK → wire → runner drops it. `credentialMode` keeps its three values and its
+meaning; nothing about the provider's secrets changes.
+
+### D37 — SETTLED: loopback is exempt from the https requirement, explicitly
+
+`ResolvedConnection` keeps refusing an `opaque_http` credential over plain http, except when
+the host is a loopback address. Written as an explicit branch with the reason on it, not as a
+relaxed regex: the check exists so a provider secret cannot cross a plaintext hop to a remote
+host, and a loopback hop has no remote to cross to. Development stays on http; nothing about
+a deployed gateway changes.
+
+### D38 — SETTLED: all three front doors, in WP23, together
+
+`/v1/chat/completions`, `/v1/responses` and `/v1/messages`. Not sequenced, because the
+sequencing was only ever about which upstreams to unblock first, and the answer is all of
+them. WP24's per-provider verification (OD16) is therefore scoped against the full door set
+from the start rather than re-scoped per door.
+
+`/v1/models` stays where it is — it answers from the endpoint's allowlist and is not a
+protocol front door.
+
+### D39 — SETTLED: the seed owns the mock upstreams' header echo
+
+Both mock upstreams gain one endpoint that reports the headers of the request it received.
+It is in the seed rather than a package because two packages' acceptance tests read it and
+neither owns WP5's tree, and because it is the only way the credentials rules are pinned end
+to end rather than in unit tests alone.
+
+---
+
+## Fan-out
+
+WP12 gates three packages; WP4 is independent of all of them and can start on day one.
+
+| Worktree | Branch | Package | Owns |
+| --- | --- | --- | --- |
+| `gateways-wp12` | `feat/gateways-wp12` | SDK connection resolution | `sdks/python/agenta/sdk/agents/connections/` |
+| `gateways-wp4` | `feat/gateways-wp4` | Audit events | the gateways' emission into `core/events/` |
+| `gateways-wp13` | `feat/gateways-wp13` | Runner and harnesses | `services/runner/src/`, the wire, the harness configs |
+| `gateways-wp14` | `feat/gateways-wp14` | Agent v0 | the remaining model caller |
+| `gateways-wp15` | `feat/gateways-wp15` | MCP servers on the wire | the runner's MCP server configs |
+| `gateways-wp23` | `feat/gateways-wp23` | Protocol front doors | `apis/fastapi/gateways/llms/proxy.py`, the per-protocol parsers |
+| `gateways-wp24` | `feat/gateways-wp24` | The relay-only south port | `core/gateways/llms/providers/`, `registry.py`, the migration |
+
+Each package has a spec and a task list: [WP4](specs-wp4.md) · [WP12](specs-wp12.md) ·
+[WP13](specs-wp13.md) · [WP14](specs-wp14.md) · [WP15](specs-wp15.md) ·
+[WP23](specs-wp23.md) · [WP24](specs-wp24.md). Read the spec before the tasks, and the tasks
+before touching a file.
+
+**WP12 — SDK connection resolution.** `resolve()` returns a gateway route: provider and
+deployment naming the gateway, `endpoint.base_url` the gateway URL, and our own credentials in place of
+the provider's secret. The SDK keeps every capability it has (D4), so this
+is a change of *what the resolver returns*, not of what it can express — except for the
+header binding above, which lands here and on the wire together.
+*Depends on:* C1. *Blocks:* WP13, WP14, WP15.
+*Done when:* a resolved connection for any provider names the gateway, and no provider key
+appears in its output.
+
+**WP4 — Audit events.** One event per call into the existing events domain (D22), carrying
+the principal, the target, the decision and the outcome. The service already computes all
+four — `GatewayOutcome` carries the status, the secret owner and its origin, and
+`GatewayTarget` carries plane, namespace, name and model. Emission is the missing half.
+*Depends on:* C1. *Blocks:* nothing.
+*Done when:* one event per call, queryable through the existing surface, on both planes and
+on the refusal paths as well as the success ones.
+
+**WP13 — Runner and harnesses.** The runner carries a gateway route rather than provider
+secrets. Verify the two properties that make this worth doing: the per-server secret arrays
+collapse to one set of gateway credentials, and the redaction set shrinks accordingly.
+*Depends on:* WP12.
+*Done when:* a run reaches a model with no provider key anywhere in the sandbox, on both
+the local and the Daytona sandbox.
+
+**WP14 — Agent v0.** The remaining caller.
+*Depends on:* WP12.
+
+**WP23 — Protocol front doors.** `/v1/responses` and `/v1/messages` beside
+`/v1/chat/completions` (D33). Each needs its own minimal body parse for the policy fields
+(the model id, the stream flag), its own usage extraction, and its own ceiling binding —
+Chat Completions names the ceiling `max_tokens`, Responses names it `max_output_tokens`.
+Nothing else in the pipeline changes: resolution, filters, ceilings, secrets and audit are
+all protocol-blind.
+*Depends on:* C1. *Blocks:* WP24.
+*Done when:* a request in each protocol relays byte for byte to an upstream that speaks it,
+with usage recorded and the ceiling enforced.
+
+**WP24 — The relay-only south port.** D34 forbids body conversion, so the
+`passthrough`/`translated` split collapses into one relay with a routing strategy and an
+authentication strategy per deployment. Carries OD16's verification as its first task —
+per provider, does it accept the bytes a front door relays, can its URL be composed from
+route fields, can its auth be applied without touching the body — and moves each provider
+that passes. `TranslatedLLMAdapter` is deleted, not deprecated; litellm stays for cost
+arithmetic and for signing where the scheme is a signature.
+*Depends on:* WP23, because removing conversion before the front doors exist would make
+Anthropic, Gemini, Bedrock and Vertex unreachable rather than reachable-another-way.
+*Also carries:* `provider_key`'s `NOT NULL`, which loses its last justification when
+`select_upstream`'s `direct` branch goes (entities.md §2.4).
+*Done when:* no code path parses a request body except to read the policy fields, and the
+providers OD16 cleared are reachable through the front door matching their shape.
+
+**WP15 — MCP servers on the wire.** The runner's `McpServerConfig.connection.url` points at
+a gateway MCP route and its `credentials` array carries ours. The binding this
+needs already exists, which is why this is the smaller of the two runner packages.
+Carries OD17's verification as its first task — per server, does it answer a plain stateless
+POST, and does it need the SSE leg the gateway refuses — because D8 settled which revision we
+build to and nothing settled what an older upstream does.
+*Depends on:* WP12, and WP13's wire commit.
+
+---
+
+## Merges
+
+**IM3 — after WP12.** Not deployed. It exists so WP13, WP14 and WP15 branch from one
+resolver rather than three copies of an unmerged one.
+
+**WP15 branches from WP13's wire commit, not from IM3.** The two share `protocol.ts`, and a
+shared file edited in parallel is how a stack scrambles.
+
+**IM4 → C2.** Deploy. All seven packages.
+
+WP23 and WP24 are a pair and land in that order. They are in this wave rather than a later
+one because D34 is a constraint on what the relay may do, and a constraint that is written
+down but not enforced is worth nothing — every week it is unenforced is another call site
+that assumes conversion is available.
+
+---
+
+## Acceptance at C2
+
+From `plan.md`, unchanged, plus what wave 1's shape now makes checkable:
+
+- A real agent run completes with **no provider secret anywhere in the sandbox** — asserted
+ by inspecting the sandbox environment, not by inspecting our own resolver.
+- The run's model calls and tool calls appear as **audit events with the right principal**.
+- A run naming a **model it may not use** fails cleanly — the filter refuses before the
+ upstream is dialled, and the failure names the model.
+- A run whose endpoint is **deactivated** fails with the flag named, not with a timeout.
+- `X-AG-Credentials` never reaches an upstream, and a caller's `Authorization` **does**
+ when no secret resolved — pass-through, working. Both want a mock that echoes the headers
+ it received, which WP5's mocks do not do yet; adding that echo is a prerequisite for
+ asserting either at this level rather than only in unit tests.
+- A request in each front door's protocol relays byte for byte, compared as bytes.
+
+---
+
+## Rules
+
+The wave-1 rules in [`launch.md`](launch.md) hold unchanged — one package per worktree,
+plain `git`, no cross-package edits, and the seed files nobody edits after. Three additions
+specific to this wave:
+
+- **No test ever calls a real LLM or a real MCP server.** Every layer — unit, integration,
+ acceptance — dials a mock: WP5's `mock-llm-gateway` and `mock-mcp-gateway`, the in-process
+ `MockLLMAdapter`, or a new mock variation the package adds. A provider a mock cannot yet
+ imitate is a mock to extend, never a live call to make. A live call makes the suite pay,
+ leak and flake on someone else's uptime, and an `xfail` on the provider's quota is a test
+ that reports green while asserting nothing. This applies to fixture hostnames too: point
+ them at a mock, so no future test can turn a placeholder into a dialled one.
+
+- **The wire is shared.** WP13 and WP15 both touch `services/runner/src/protocol.ts`. The
+ binding change belongs to WP13; WP15 consumes it. If they run in parallel, WP15 branches
+ from WP13's wire commit rather than editing the file.
+- **A harness is a fact, not an assumption.** Anything a package needs a harness to do —
+ send a header, keep a subscription login across a base-URL override — is verified against
+ the release in use before the package depends on it (OD14).
diff --git a/docs/design/gateways-research/v1/workstreams/launch-3.md b/docs/design/gateways-research/v1/workstreams/launch-3.md
new file mode 100644
index 0000000000..e6a88bb08c
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/launch-3.md
@@ -0,0 +1,183 @@
+# Wave 3 launch — to C3
+
+Wave 1 built the gateways. Wave 2 made them the only way out. Wave 3 makes the things behind
+them connectable: OAuth, and the two halves of "the agent cannot reach this, ask the user".
+
+**C3 is "a user can connect anything, and an agent can ask them to."** An OAuth-protected MCP
+server can be connected from the dashboard; a scope challenge raises an interaction instead of
+failing; a refusal reaches the agent as a cause it can act on; and an agent that lacks a
+connection can request one the same way it already requests an integration.
+
+---
+
+## What wave 2 left standing
+
+Read these before planning a package. All four are new since `plan.md`'s wave-3 outline.
+
+- **A gateway target must be registered before an agent can use it (D35).** The SDK no longer
+ honours a URL and a secret declared in agent code; it routes by name and the gateway resolves
+ the rest. The CRUD registries on both planes are not administrative convenience — they are the
+ only place a secret can live once a sandbox cannot hold one. Wave 3 exists largely to make that
+ registration reachable for OAuth-protected targets.
+- **`builtin` is Composio-backed on the MCP plane (OD13, closed).** No curated direct-server set
+ ships. This sets WP17's scope: Composio brokers the authorization for `builtin`, so **our own
+ OAuth client is for `custom` endpoints** — the servers a user brings by URL. Two suppliers, two
+ namespaces, and the client is not a fallback for the Composio path.
+- **The relay never converts a body (D34), and the providers that clears are known (OD16,
+ closed).** Anthropic, Gemini, Cohere, DeepInfra, Perplexity, MiniMax, Azure, Bedrock and Vertex
+ all clear. SageMaker and two legacy per-vendor paths are recorded in `out-of-scope.md`.
+- **Project-level secrets are the model (OD2, closed).** User-level secrets are out of scope.
+
+---
+
+## Before anything starts
+
+Wave 3 needs no seed. Its serial spine changes shapes one package at a time, and the two
+independent packages touch different files.
+
+The base was checked rather than assumed, as in both prior waves: the current upstream release
+branch is still `release/v0.112.1`, and its tip is already an ancestor of C2 — nothing upstream has
+landed since wave 2 merged, so there is no re-cut to do.
+
+Wave 3 branches from `feat/gateways`, which is **C3's predecessor** `feat/gateways-c2`
+(`846e8fa15d`, IM4) plus the documentation commits that carry this plan. The code is identical; the
+difference is that a package branched here can read its own wave's design.
+
+---
+
+## The spine — OAuth, serial
+
+Serial because each step needs the shape the one before it defines. Do not fan these out.
+
+**WP16 — Secret kinds.** `oauth_provider` and `oauth_grant`: enum values, settings DTOs, union
+arms, validator branches (D14). Coordinate with the parallel work adding kinds to the same enum.
+*Depends on:* C2. *Blocks:* WP17.
+
+**WP17 — OAuth client.** The official SDK's client provider, with a storage adapter over the
+secrets service rather than its own store, and connect callbacks pointed at the dashboard rather
+than a local browser. **Its target is the `custom` namespace** — a server the user brought by URL.
+`builtin` is Composio-brokered and does not pass through this client.
+*Depends on:* WP16. *Blocks:* WP18, WP19, WP20.
+
+**WP18 — Consent flow.** Connecting an OAuth server from the dashboard, with scope selection.
+*Depends on:* WP17.
+
+**WP20 — Client registration fallback.** There is no callback-reachability work: the browser
+reaches the redirect in every deployment, because it is the address the user is already on (D26).
+What remains is registration. Prefer the client identity document; fall back to registering
+outbound when the deployment's domain is not publicly resolvable, and make that fallback automatic
+rather than a configuration flag.
+*Depends on:* WP17.
+*Done when:* a deployment on an internal-only domain completes a full authorization with no hosted
+component of ours in the path.
+
+**WP19 — Step-up interaction.** A scope challenge raises an interaction on the missing-connection
+path.
+*Depends on:* WP17, WP18, **WP25 and WP26**. The dependency on the last two is the change from
+`plan.md`, and the reason is below.
+
+---
+
+## The two packages wave 2 surfaced
+
+Both come from D35, and both serve the same story as WP19: *the agent cannot reach something and
+needs the user to fix it.* WP19 rides the channel these two build. Building step-up on a channel
+that loses the cause is finishing a road with a gap left in the middle — so these land first.
+
+**WP25 — A refusal arrives as a cause, not a sentence.** The gateway already raises typed domain
+errors for a missing credential, a rejected one, an unregistered target, a disallowed model and a
+deactivated endpoint. What is unproven is that the cause survives the trip back: gateway to
+harness to runner to agent service to caller.
+
+Two known gaps, both found in wave 2 and both flagged rather than fixed:
+- The runner recovers the cause by **parsing the harness's own error text** (`gateway-error.ts`,
+ wired at `engine.ts`'s one choke point). Whether a given harness's SDK preserves the gateway's
+ JSON body in that text is **unverified per harness** — WP13 said so explicitly.
+- The agent service never surfaces `errorDetail` onto its own stream (`adapters/vercel/stream.py`),
+ so even a correctly recovered cause stops before the caller.
+
+The wire shape already exists: `AgentErrorDetail` is `{code, message, retryable, next_step?,
+details?}` on `AgentRunResult`, matching the repo's agent-actionable error envelope. Do not invent
+a second shape.
+*Depends on:* C2. *Blocks:* WP19.
+*Done when:* each of the five refusals above reaches the caller carrying its `code`, proven per
+harness rather than assumed, and a harness that cannot preserve the body is recorded as such
+instead of silently degrading.
+
+**WP26 — An agent can request a gateway connection.** The affordance exists for external
+integrations: the reserved `request_connection` client tool
+(`core/workflows/static_catalog.py`), which takes `{integration, slug?, mode: oauth|api_key}` and
+carries `render: {kind: "connect"}` so the client renders the connect dialog when the call pauses.
+It does not cover a gateway endpoint on either plane.
+
+Extend that tool rather than building a second one: the pause, the render hint and the resume path
+are already built and tested. The new case is an agent naming a model or an MCP server it cannot
+reach and asking the user to connect it — which is exactly what D35 made necessary by requiring
+registration first.
+*Depends on:* C2. *Blocks:* WP19.
+*Done when:* an agent refused for a missing connection can raise a request that lands the user on
+the right registration surface for that plane, and the run resumes on completion.
+
+
+**WP27 — The static field rewrite for resold Anthropic wires (D40).** Bedrock's `InvokeModel` and
+Vertex's `rawPredict` both resell Anthropic's Messages wire with one fixed structural difference:
+`anthropic_version` must be in the body (`bedrock-2023-05-31` / `vertex-2023-10-16`), and `model`
+must not be — it rides the URL. D40 permits a static per-deployment table of literal fields added
+and literal fields removed, with nothing computed from the request.
+
+**Phase 0 is closed, and the package does not shrink.** The open question was whether a body that
+still carries `model` is rejected or merely ignored; if ignored, the removal half was unnecessary.
+Bedrock rejects it — its Anthropic body is validated against a closed schema and answers an unknown
+key with `extraneous key [model] is not permitted`, attested by a client that sent the native body
+verbatim. Vertex has no attestation either way, and removes it anyway because it is the same table
+entry. Both operations ship. The answer is recorded under D40 along with the caveat that it came
+from documentation rather than a live call.
+
+The relay stops being byte-identical for these two deployments only. Name them as the exemption in
+the acceptance test rather than weakening the byte-for-byte assertion everywhere.
+*Depends on:* C2. *Blocks:* nothing.
+*Done when:* a Messages request reaches both deployments and returns a completion, the table is
+literal with a test proving no entry reads the request, and every other deployment still relays
+byte for byte.
+
+---
+
+## The cleanups this wave carries
+
+Six items from `cleanups.md` are unblocked by C2 and finish what wave 2 started. They are
+independent of the spine and of each other unless noted, so they can run alongside.
+
+| Item | What | Note |
+| --- | --- | --- |
+| **CU1** | Close the plaintext secrets read surface — the vault's read routes still return decrypted material to any caller with view permission | Ownership needs agreeing with the parallel bring-your-own-secrets work, not assuming |
+| **CU2** | Remove module-level provider keys from the workflow handler — process-wide state that is a cross-tenant leak in a shared process | May be free: the handler is reported unused and may simply be deleted |
+| **CU6** | Collapse the wire's per-server secret arrays to one gateway token | WP13 already verified they shrink; this finishes the wire's shape |
+| **CU7** | Re-assess the runner's redaction deny-set now that it covers one short-lived token | Follows CU6; do not start it first |
+| **CU10** | Remove the legacy credits counter, which counts access checks rather than usage | Independent |
+| **CU12** | Collapse the four copies of the outbound SSRF guard into one per language | The gateway makes this guard the single control on every outbound call we make for a tenant |
+| **CU13** | Turn the insecure-egress default off wherever a deployment is shared | **Do this first and separately.** One flag, set nowhere, currently disables all four guards |
+
+The other cleanups stay a register: they are blocked on scope decisions (evaluators, the tool and
+trigger domains) rather than on the gateways, and nothing here unblocks them.
+
+---
+
+## Merge
+
+**IM5 → C3.** Deploy. The acceptance criteria above, plus wave 2's, still passing.
+
+---
+
+## Rules
+
+The wave-1 and wave-2 rules hold unchanged — one package per worktree, plain `git`, no
+cross-package edits, and no test ever calls a real LLM, a real MCP server or any live provider.
+Three notes specific to this wave:
+
+- **The spine is serial. Do not fan it out.** WP17 defines the shape WP18, WP19 and WP20 all
+ consume, and three parallel guesses at it cost more than the wait.
+- **A harness is a fact, not an assumption** — the rule OD14 produced in wave 2 applies again in
+ WP25, where the question is what a harness does with an upstream's error body rather than with a
+ header.
+- **CU13 before CU12.** The posture fix does not need the duplication fixed, and bundling them lets
+ the slower half hold the faster one.
diff --git a/docs/design/gateways-research/v1/workstreams/launch.md b/docs/design/gateways-research/v1/workstreams/launch.md
new file mode 100644
index 0000000000..5a92ed3b84
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/launch.md
@@ -0,0 +1,149 @@
+# Launch runbook
+
+How the packages actually get started in parallel. `README.md` says who owns what; this says what
+to run, in what order, and what to check before moving on.
+
+Everything here targets **C1**. Waves 2 and 3 follow the same shape and are deliberately
+not pre-planned in detail, because C1's outcome changes them.
+
+## Before anything starts
+
+**The seed is not parallel work.** One agent writes it, on the base branch, and everything else
+waits. It is small — declarations only — and it is the reason nothing waits afterwards.
+
+- [ ] **Base branch from the current upstream release branch.** Observed at prep time:
+ `release/v0.112.0`. Not `main`, not a fork. Re-read the branch name before starting; it
+ advances.
+- [ ] **Verify the migration head, in the right chain.** WP1's migration belongs to **`core_oss`**,
+ whose head was `oss000000020` at prep time, so WP1 writes `oss000000021`.
+
+ Four chains live under `api/oss/databases/postgres/migrations/`. `core` and `tracing` are
+ **parked legacy chains**, both at `park00000000`; only `core_oss` and `tracing_oss` are
+ live. A head read from `core/` is a parked chain's and is wrong — this document had it
+ wrong once. If the head has moved, WP1's spec is stale on one line and nothing else.
+- [ ] **Carry the design set onto the base**, so every worktree can read
+ `docs/design/gateways-research/v1/` without a second checkout.
+- [ ] **Write the seed**: `core/gateways/{dtos,types}.py`, `core/gateways/policy/{dtos,types,interfaces}.py`,
+ `core/gateways/llms/{dtos,types,interfaces}.py`, `core/gateways/mcps/{dtos,types,interfaces}.py`.
+ Complete declarations, every body `raise NotImplementedError`. **Transcribed from
+ `entities.md` §4, §5 and §7 — not re-derived.**
+
+ Run `ruff format` then `ruff check --fix` in `api/` before committing; pre-commit enforces
+ both (root `AGENTS.md`).
+
+ **The one thing that must be right** is the secret resolution signature: it takes the
+ owner as a parameter even though the only answer today is the project (D10). Nine worktrees
+ inherit it.
+
+ A package that finds a declaration wrong **reports it** rather than editing around it.
+- [ ] **Add the empty gateways block to `api/entrypoints/routers.py`** — imports and registration
+ scaffold only, so that four later packages add lines to a file that already has the domain
+ in it.
+- [ ] **Verify**: the declarations import, and a test instantiating each DTO with representative
+ values passes. Unit level — nothing running.
+- [x] **The four seed-blocking rulings are settled** (`open-designs.md`, R1–R4). Each changed a
+ signature the seed freezes, so none could be deferred into a worktree:
+
+ - **R1** — `apis/fastapi/gateways/exceptions.py` moves into the **seed**. Three packages
+ need `handle_gateway_exceptions()` and they are siblings in the dependency graph, so no
+ one of them could own it.
+ - **R2** — the resolver port gains `available_provider_keys(*, scope) -> Set[str]`;
+ `LLMGatewayService`'s constructor is **unchanged**. Existence of a secret is a
+ secret-layer question, and a vault dependency on the service would give it two
+ secret seams.
+ - **R3** — `GET /v1/models` is backed by `LLMGatewayService.list_models(*, scope,
+ namespace, name) -> List[str]`, per endpoint, answering from the allowlist. No new DTO.
+ - **R4** — `GatewayPolicyService.record()` ships as a no-op returning `None` that never
+ raises. It is WP3's file, not a seed file; what the seed freezes is the call, so wave 2
+ changes a body and never a call site.
+- [ ] **Commit, and record the SHA.** Every worktree branches from exactly this commit.
+
+If the seed is wrong, every worktree inherits the error. Review it properly even though it does
+nothing.
+
+## Fan-out 1 — four packages, launched together
+
+WP5 depends on nothing at all and can start before the seed lands. The other three need only the
+seed's declarations.
+
+| Worktree | Branch | Package | Owns |
+| --- | --- | --- | --- |
+| `gateways-wp1` | `feat/gateways-wp1` | Domain and storage | `dbs/postgres/gateways/`, the migration |
+| `gateways-wp2` | `feat/gateways-wp2` | Secret resolution | `core/gateways/policy/resolution.py` |
+| `gateways-wp3` | `feat/gateways-wp3` | Policy core | `core/gateways/policy/service.py`, the six `Permission` members |
+| `gateways-wp5` | `feat/gateways-wp5` | Test doubles | both `providers/mock/` trees, the compose services |
+
+Each starts by reading `specs-wp{k}.md`, works `tasks-wp{k}.md` top to bottom, stays inside its
+owned paths, and **stops at the merge point** rather than reaching into another package's files to
+finish something.
+
+**WP5 is not scaffolding.** The mocks are deliverables (D23), and they are what makes C1
+testable without a third-party dependency. A package treating them as throwaway produces a
+checkpoint nobody can verify.
+
+## Merge IM1 — foundation
+
+Static only, not deployed. Nothing here serves traffic.
+
+- [ ] Merge WP1 first — the other packages' integration tests need its tables.
+- [ ] Then WP2, WP3, WP5 in any order; their files are disjoint.
+- [ ] Apply the collected `api/entrypoints/routers.py` edits as **one** edit.
+- [ ] Migration applies **and downgrades**. By hand, against a real database — never as a pytest.
+- [ ] Every fan-out 2 worktree branches from the merged base.
+
+## Fan-out 2 — the two planes, in parallel
+
+| Worktree | Branch | Package | Owns |
+| --- | --- | --- | --- |
+| `gateways-wp6` | `feat/gateways-wp6` | LLM ingress and relay | `apis/fastapi/gateways/llms/{proxy,utils}.py`, `providers/passthrough/` |
+| `gateways-wp7` | `feat/gateways-wp7` | LLM routing and allowlist | `core/gateways/llms/{service,registry,catalog}.py`, `providers/translated/` |
+| `gateways-wp8` | `feat/gateways-wp8` | MCP ingress and proxy | `apis/fastapi/gateways/mcps/{proxy,utils}.py`, `providers/http/` |
+| `gateways-wp9` | `feat/gateways-wp9` | MCP registry and allowlist | `core/gateways/mcps/{service,registry}.py` |
+| `gateways-wp10` | `feat/gateways-wp10` | Endpoint CRUD | `apis/fastapi/gateways/{exceptions.py,llms/router.py,llms/models.py,mcps/router.py,mcps/models.py}` |
+
+**The pairing is deliberate.** On each plane the ingress package and the domain package are
+separate, and the plane's `service.py` belongs to the domain package. WP6 calls WP7's service
+through the seed's declaration; WP8 calls WP9's. Neither pair blocks the other, and neither edits
+the other's files.
+
+## Reaching C1
+
+C1 is reached when this runs on the merged base, not when five packages report done.
+
+- [ ] Merge the five, applying the `api/entrypoints/routers.py` lines together as one edit.
+- [ ] Both mocks run in the local stack.
+- [ ] A request with no token is refused.
+- [ ] A request for an endpoint the caller may not use is refused, **before** any upstream call.
+- [ ] A permitted model call reaches the mock with the caller's token replaced by the upstream
+ secret.
+- [ ] A streamed response arrives byte for byte, on **both** planes — tool names, schemas and
+ errors included.
+- [ ] A model outside a custom endpoint's list is refused.
+- [ ] A tool outside a server's allowlist is refused.
+- [ ] A hung upstream times out rather than hanging the gateway.
+- [ ] A custom MCP server URL pointing at a private, loopback or link-local address — including
+ `169.254.169.254` — is refused at registration (WP10) **and** at relay (WP8), and the relay
+ connects to the pinned literal IP rather than re-resolving. **Run this check with
+ `AGENTA_INSECURE_EGRESS_ALLOWED=false`**: it defaults to `true`, so the guard is inert
+ otherwise and the check would pass while proving nothing (D28).
+- [ ] Deploy.
+
+**What is deliberately absent:** no audit record, no usage recorded, no per-endpoint
+configuration, no OAuth, and **no brokered server** — C1's reachable targets are our own
+servers and the mocks (D23), so the Composio-backed adapter is not a wave 1 deliverable (R8).
+C1 proves the call path and only the call path (`scope-checklist.md`).
+
+## Rules for anyone working a package
+
+1. **Own your paths.** If a task needs a file you do not own, that is a merge-point conversation,
+ not a commit.
+2. **Rebase at merge points only.** Continuous rebasing spends a package's time on other people's
+ churn.
+3. **The design documents win.** A spec that disagrees with `entities.md` is a bug in the spec —
+ report it rather than implementing around it.
+4. **Do not invent names.** Every DTO, column, method and route already exists in `entities.md`.
+ A name that is not there is a hallucination, including a plausible one.
+5. **Stop at the merge point.** A package running ahead into the next one's work is what makes
+ parallel work slower than serial.
+6. **Know which tests you may run.** Unit tests need nothing running and can run anywhere.
+ Integration and acceptance tests need a deployment; write them, do not run them without one.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp1.md b/docs/design/gateways-research/v1/workstreams/specs-wp1.md
new file mode 100644
index 0000000000..3bb57cb34d
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp1.md
@@ -0,0 +1,343 @@
+# WP1 — Gateway domain and storage
+
+Delivers the storage stack for custom endpoints on both gateways: the two abstract
+mixins, the two concrete tables, the two DAO implementations (two interfaces —
+`LLMEndpointsDAOInterface`, `MCPEndpointsDAOInterface`), the
+DBE↔DTO mappings, and the one migration that creates both tables. Standard and
+builtin endpoints are generated and store nothing (D20) — there is no row, no DAO method,
+and no namespace parameter anywhere in this package's surface, because every row this
+package persists is a `custom` row by construction (`entities.md` §2.3, §7).
+
+This package does not touch `core/gateways/` at all. The DAO interfaces it implements
+(`LLMEndpointsDAOInterface`, `MCPEndpointsDAOInterface`) live in
+`core/gateways/{llms,mcps}/interfaces.py`, which the seed commit already declares
+verbatim from `entities.md` §7 — WP1 imports and implements them, never edits them.
+
+## What this is NOT
+
+- The namespace merge (`list_endpoints` composing generated + custom rows), `catalog.py`,
+ and everything that reads `provider_key`/`slug` existence to decide whether a builtin
+ endpoint exists — **WP7** (LLM) and **WP9** (MCP), against `core/gateways/{llms,mcps}/service.py`.
+- The south port (`LLMUpstreamInterface`, `MCPUpstreamInterface`, the registries, the
+ `providers/` adapters) — seed declares the interfaces, **WP6/WP7/WP8/WP9** implement
+ registries and adapters. WP1 never imports or references them.
+- `core/gateways/policy/resolution.py` (**WP2**) and `core/gateways/policy/service.py`
+ (**WP3**) — WP1's tables are consumed by both, through the DAO interfaces only.
+- Routers, wire models, and the endpoint CRUD API surface — **WP10**
+ (`apis/fastapi/gateways/{llms,mcps}/{router,models}.py`).
+- The two OAuth secret kinds (`oauth_provider`, `oauth_grant` — WP16) and the OAuth client
+ (WP17) that mint the `oauth_grant` secret an MCP endpoint's `secret_id` points at. WP1
+ builds the endpoint tables only; nothing in this package issues an OAuth token.
+
+## Files
+
+New, and owned by no other package (`workstreams/README.md` file-ownership table):
+
+- `api/oss/src/dbs/postgres/gateways/llms/dbas.py` — `LLMEndpointDBA`
+- `api/oss/src/dbs/postgres/gateways/llms/dbes.py` — `LLMEndpointDBE`
+- `api/oss/src/dbs/postgres/gateways/llms/dao.py` — `LLMEndpointsDAO`
+- `api/oss/src/dbs/postgres/gateways/llms/mappings.py`
+- `api/oss/src/dbs/postgres/gateways/mcps/dbas.py` — `MCPEndpointDBA`
+- `api/oss/src/dbs/postgres/gateways/mcps/dbes.py` — `MCPEndpointDBE`
+- `api/oss/src/dbs/postgres/gateways/mcps/dao.py` — `MCPEndpointsDAO`
+- `api/oss/src/dbs/postgres/gateways/mcps/mappings.py`
+- `api/oss/databases/postgres/migrations/core_oss/versions/oss0000000NN_add_gateway_endpoints.py`
+ — the one migration; creates both tables in one revision.
+
+Edited: none outside the above. WP1 adds two lines to `api/entrypoints/routers.py` as a
+diff applied at the IM1 merge (below) — it does not commit that file directly.
+
+**Verify the migration head before branching.** `workstreams/README.md` records
+`release/v0.112.0` at `park00000000` as the observed head at prep time and warns both
+advance. In this tree, at the time this spec was written, `core_oss`'s actual head is
+`oss000000020_add_session_attachments.py` (`down_revision = "oss000000019"`) —
+`park00000000` belongs to the unrelated `tracing` chain. Run
+`ls api/oss/databases/postgres/migrations/core_oss/versions/ | tail -3` immediately
+before writing the migration file and set `down_revision` to whatever is actually latest;
+do not trust either number above without re-checking.
+
+## Interfaces (reproduce verbatim, seed-owned — do not edit the source files)
+
+From `core/gateways/llms/interfaces.py` (`entities.md` §7):
+
+```python
+class LLMEndpointsDAOInterface(ABC):
+ @abstractmethod
+ async def create_endpoint(
+ self, *, project_id: UUID, user_id: UUID, #
+ endpoint: LLMEndpointCreate,
+ ) -> Optional[LLMEndpoint]:
+ """Insert. Raises EntityCreationConflict on a slug collision."""
+
+ @abstractmethod
+ async def fetch_endpoint(
+ self, *, project_id: UUID, #
+ endpoint_id: UUID,
+ ) -> Optional[LLMEndpoint]: ...
+
+ @abstractmethod
+ async def fetch_endpoint_by_slug(
+ self, *, project_id: UUID, #
+ slug: str,
+ ) -> Optional[LLMEndpoint]:
+ """The data-plane route lookup. Backed by
+ uq_llms_endpoints_project_slug — at most one row by
+ construction. None means the custom namespace has no such name."""
+
+ @abstractmethod
+ async def edit_endpoint(
+ self, *, project_id: UUID, user_id: UUID, #
+ endpoint: LLMEndpointEdit,
+ ) -> Optional[LLMEndpoint]:
+ """Full PUT over data, flags, header, secret_id. provider_key and
+ deployment_kind are absent from LLMEndpointEdit and therefore untouchable."""
+
+ @abstractmethod
+ async def delete_endpoint(
+ self, *, project_id: UUID, #
+ endpoint_id: UUID,
+ ) -> bool: ...
+
+ @abstractmethod
+ async def query_endpoints(
+ self, *, project_id: UUID, #
+ endpoint: Optional[LLMEndpointQuery] = None, #
+ windowing: Optional[Windowing] = None,
+ ) -> List[LLMEndpoint]: ...
+```
+
+From `core/gateways/mcps/interfaces.py` — `MCPEndpointsDAOInterface` has the identical
+six verbs over `mcps_endpoints` (same signatures, `LLMEndpoint*` → `MCPEndpoint*`).
+
+`None` disambiguation (`entities.md` §7, reproduce in the DAO's module docstring):
+
+| method | `None` means | caller does |
+| --- | --- | --- |
+| `fetch_endpoint_by_slug` | no such custom endpoint | proxy 404s in its own shape |
+| `edit_endpoint` | the row does not exist | 404 at the boundary |
+
+## dbas.py — reproduce verbatim from `entities.md` §2
+
+```python
+# dbs/postgres/gateways/llms/dbas.py
+
+class LLMEndpointDBA(
+ ProjectScopeDBA, IdentifierDBA, SlugDBA, LifecycleDBA,
+ HeaderDBA, DataDBA, StatusDBA, FlagsDBA, TagsDBA, MetaDBA,
+):
+ __abstract__ = True
+
+ provider_key = Column(String, nullable=False)
+ deployment_kind = Column(
+ SQLEnum(LLMDeploymentKind, name="llmdeploymentkind_enum"), nullable=False
+ )
+ secret_id = Column(UUID(as_uuid=True), nullable=True)
+ # data: { route, models, settings } — LLMEndpointData
+
+
+# dbs/postgres/gateways/mcps/dbas.py
+
+class MCPEndpointDBA(
+ ProjectScopeDBA, IdentifierDBA, SlugDBA, LifecycleDBA,
+ HeaderDBA, DataDBA, StatusDBA, FlagsDBA, TagsDBA, MetaDBA,
+):
+ __abstract__ = True
+
+ auth_mode = Column(
+ SQLEnum(GatewayAuthScheme, name="gatewayauthscheme_enum"), nullable=False
+ )
+ secret_id = Column(UUID(as_uuid=True), nullable=True)
+ # data: { route, tools, settings, oauth } — MCPEndpointData
+```
+
+`LLMDeploymentKind` and `GatewayAuthScheme` are seed-owned enums
+(`core/gateways/llms/dtos.py`, `core/gateways/dtos.py`) — import, do not redefine.
+
+## dbes.py — reproduce verbatim from `entities.md` §3
+
+```python
+class LLMEndpointDBE(Base, LLMEndpointDBA):
+ __tablename__ = "llms_endpoints"
+ __table_args__ = (
+ PrimaryKeyConstraint("project_id", "id"),
+ ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
+ ForeignKeyConstraint(["secret_id"], ["secrets.id"], ondelete="SET NULL"),
+ UniqueConstraint("project_id", "slug",
+ name="uq_llms_endpoints_project_slug"),
+ Index("ix_llms_endpoints_project_provider",
+ "project_id", "provider_key"),
+ Index("ix_llms_endpoints_flags", "flags", postgresql_using="gin"),
+ )
+
+
+class MCPEndpointDBE(Base, MCPEndpointDBA):
+ __tablename__ = "mcps_endpoints"
+ __table_args__ = (
+ PrimaryKeyConstraint("project_id", "id"),
+ ForeignKeyConstraint(["project_id"], ["projects.id"], ondelete="CASCADE"),
+ ForeignKeyConstraint(["secret_id"], ["secrets.id"], ondelete="SET NULL"),
+ UniqueConstraint("project_id", "slug",
+ name="uq_mcps_endpoints_project_slug"),
+ Index("ix_mcps_endpoints_flags", "flags", postgresql_using="gin"),
+ )
+```
+
+Both endpoint tables take `SET NULL` on `secret_id` — a dead secret must not silently
+delete configuration (D18).
+
+No unique constraint mentions `url` or `secret_id` anywhere. Two endpoints may point at
+one upstream with different tool policies; one secret may back several custom endpoints.
+
+## dao.py
+
+Two files, two classes: `LLMEndpointsDAO` (llms), `MCPEndpointsDAO`
+(mcps). Each opens its own session through `TransactionsEngine`
+(`dbs/postgres/shared/engine.py::get_transactions_engine`) — never receives a shared
+session, matching every DAO in the tree.
+
+**Precedent, read before writing:** `api/oss/src/dbs/postgres/gateway/connections/dao.py`
+(`ConnectionsDAO`) is the closest sibling — one project-scoped table, a slug-uniqueness
+create conflict, flag/data patch updates. Copy its shape:
+
+- `@suppress_exceptions(exclude=[EntityCreationConflict])` on every `create_*` method;
+ catch `IntegrityError`, inspect `str(e.orig)` for the unique-constraint name
+ (`uq_llms_endpoints_project_slug`, `uq_mcps_endpoints_project_slug`), and
+ raise `EntityCreationConflict(entity=..., message=..., conflict={"slug": ...})` — else
+ re-raise.
+- `@suppress_exceptions(default=None)` on `fetch_*`/`edit_*`,
+ `@suppress_exceptions(default=False)` on `delete_*`, `@suppress_exceptions(default=[])`
+ on `query_*` — read failures degrade, creates surface the one exception that matters
+ (`entities.md` §7 house rule).
+- `edit_endpoint` is a full PUT: load the row, overwrite `data`/`flags`/`name`/
+ `description`/`secret_id`/`meta` wholesale from the `*Edit` DTO, `flag_modified` on the
+ JSON columns exactly as `ConnectionsDAO.update_connection` does, `updated_at =
+ datetime.now(timezone.utc)`, `updated_by_id = user_id`. Never a partial merge.
+
+## mappings.py
+
+Three functions per entity (`map_X_create_to_dbe`, `map_X_dbe_to_dto`,
+`map_X_edit_to_dbe`), following
+`dbs/postgres/gateway/connections/mappings.py`'s shape: `data`/`flags` are typed Pydantic
+models on the DTO side (`LLMEndpointData`, `LLMEndpointFlags`, etc.) and dumped with
+`model_dump(mode="json", exclude_none=True)` going in, reconstructed with
+`ModelClass(**dbe.data)` coming out (`dbe.data`/`dbe.flags` are `None`-safe — default to
+`{}` before unpacking so a row created before a field existed does not crash the read
+path).
+
+`map_llm_endpoint_dbe_to_dto` and `map_mcp_endpoint_dbe_to_dto` set
+`namespace=GatewayEndpointNamespace.CUSTOM` unconditionally — every row this package's
+DAOs return is a custom row (§1); the generated-entry stamping for `BUILTIN`/`AGENTA`
+happens in WP7/WP9's service layer, never here.
+
+## Migration
+
+One revision, creating both tables with every constraint and index from §3 above.
+Column types: `String` stays `String`; `SQLEnum(LLMDeploymentKind, ...)` and
+`SQLEnum(GatewayAuthScheme, ...)` become `sa.Enum(..., name="llmdeploymentkind_enum")` /
+`sa.Enum(..., name="gatewayauthscheme_enum")` in the migration (the enum name matters —
+it is the Postgres type name, and it must match what the DBE declares or SQLAlchemy
+creates a second anonymous type on next `create_all`). `JSON(none_as_null=True)` /
+`JSONB(none_as_null=True)` map to `sa.JSON()` / `postgresql.JSONB()`. Follow
+`oss000000020_add_session_attachments.py`'s structure: one `op.create_table(...)` per
+table with inline constraints, then `op.create_index(...)` calls for anything not
+inlineable. `downgrade()` drops indexes then tables in reverse dependency order:
+`mcps_endpoints`, then `llms_endpoints`.
+
+## Contracts this package must honour
+
+- **`project_id` first, keyword-only after `*`**, on every DAO method — no exceptions in
+ this package.
+- **No `namespace` parameter anywhere.** Every DAO method operates on rows, and every row
+ is `custom` (§2.3, D20). A namespace parameter here would silently invite someone to
+ bolt generated-entry logic onto the DAO — that logic belongs in WP7/WP9's service.
+- **No `UserScopeDBA` on either endpoint table.** Custom endpoints are project
+ configuration; `user_id` on endpoint writes is authorship (`created_by_id`/
+ `updated_by_id`) only, never a query key.
+- **No lifecycle enum column on either table.** `ready`/`needs_auth`/
+ `needs_input` are derived at read time by WP7/WP9's service, never stored (§2.6). If a
+ task here seems to need a state column, that is a sign the task belongs to a different
+ package.
+- **`secret_id` FK behavior is `SET NULL` on both endpoint tables** (§2.1 above). Getting
+ this swapped for `CASCADE` is the single easiest way to violate D18 (a dead secret must
+ not delete configuration) without any test catching it locally, because the two behave
+ identically until a secret is actually deleted under a live FK.
+- **Verb naming is `create_/fetch_/edit_/delete_/query_`** — the newer house style
+ (`core/workflows/`), not `ConnectionsDAO`'s `get_/update_`. Copy `ConnectionsDAO`'s
+ *shape* (session handling, suppress_exceptions, flag_modified), not its verb names.
+
+## Tests
+
+**Unit (no services running, run now):**
+
+- Every `map_*_dto_to_dbe*` / `map_*_dbe_to_dto` function round-trips a representative
+ DTO through DBE construction and back without a database — these are pure Python
+ object transforms and need no session. Assert field-for-field equality modulo
+ server-assigned fields.
+- `LLMEndpointData`/`MCPEndpointData` etc. (seed DTOs) serialize via
+ `model_dump(mode="json", exclude_none=True)` to the shape the mapping functions expect
+ — one instantiate-and-dump test per payload type touched by this package's mappings.
+
+**Integration (needs Postgres — write, do not run without a local deployment):**
+`api/oss/tests/pytest/integration/gateways/`
+
+- `test_gateways_llm_endpoints_dao.py`: `create_endpoint` → `fetch_endpoint` round-trips
+ field-for-field; a second `create_endpoint` with the same `(project_id, slug)` raises
+ `EntityCreationConflict`; `edit_endpoint` fully replaces `data`/`flags` (a field omitted
+ from the `LLMEndpointEdit.data` passed in is gone after the edit, not preserved —
+ confirms this is a PUT, not a PATCH); `delete_endpoint` returns `True` once and `False`
+ on a repeat; `query_endpoints` filters by `provider_key`/`deployment_kind`/`slug`.
+- `test_gateways_mcp_endpoints_dao.py`: the same six assertions against
+ `mcps_endpoints`.
+- `test_gateways_migration.py`: `alembic upgrade head` then `alembic downgrade -1`
+ round-trips cleanly against a throwaway database; every constraint and index named in
+ §3 above exists after `upgrade` and is gone after `downgrade`; a second `upgrade` after
+ `downgrade` also completes (idempotent round-trip).
+- FK behavior: deleting a `secrets` row referenced by an endpoint's `secret_id` leaves the
+ endpoint row present with `secret_id = NULL`.
+
+## `api/entrypoints/routers.py` diff (apply at the IM1 merge)
+
+WP1 contributes the two DAO constructions; nothing else in this file is WP1's.
+
+```python
+from oss.src.dbs.postgres.gateways.llms.dao import LLMEndpointsDAO
+from oss.src.dbs.postgres.gateways.mcps.dao import MCPEndpointsDAO
+
+llm_endpoints_dao = LLMEndpointsDAO(engine=_transactions_engine)
+mcp_endpoints_dao = MCPEndpointsDAO(engine=_transactions_engine)
+```
+
+(`entities.md` §9's wiring block; WP2/WP3 add the resolver and policy service lines
+alongside this at the same merge, WP7/WP9 add the two gateway services after that, WP10
+mounts the routers last.)
+
+## Checkpoint
+
+Feeds **IM1 (foundation)**, then **C1** through WP6/WP7/WP8/WP9/WP10, all of
+which depend on this package.
+
+Exit condition, verbatim from `plan.md`: *"a custom endpoint round-trips, and every DAO
+verb takes the owner."*
+
+WP1 is done when: the migration applies and downgrades cleanly against a throwaway
+database; `create_endpoint` → `fetch_endpoint` round-trips on both planes; a slug
+collision on create raises `EntityCreationConflict` and nothing else does; and every DAO
+method in both interfaces takes `project_id` first and `user_id` on writes exactly where
+§7 says it should — verified by grep, not by memory.
+
+## Out of scope
+
+- `core/gateways/{llms,mcps}/{service,registry,catalog}.py` and `providers/` — WP6, WP7,
+ WP8, WP9.
+- `core/gateways/policy/*` — WP2, WP3.
+- `apis/fastapi/gateways/**` — WP6, WP8, WP10.
+- `core/access/permissions/types.py` — WP3.
+- Anything under WP16/WP17 (the two OAuth secret kinds, the OAuth client). Both are
+ independent of this package's tables; WP1 owns no OAuth-related row.
+- User-level secret grants (a `mcps_grants`-style table narrowing an endpoint's
+ `secret_id` per user) — removed from scope, see `../out-of-scope.md`.
+
+## Missing from the design, needs a ruling
+
+None found for this package's own surface — every DTO, column, exception and DAO method
+this spec references exists in `entities.md` with the signature reproduced above.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp10.md b/docs/design/gateways-research/v1/workstreams/specs-wp10.md
new file mode 100644
index 0000000000..b3777b1c65
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp10.md
@@ -0,0 +1,465 @@
+# WP10 — Endpoint CRUD API
+
+Delivers the management CRUD surface for both gateways: `LLMGatewayRouter`,
+`MCPGatewayRouter`, their request/response models, and the one shared
+exception-mapping decorator both these routers and the two data-plane
+proxies (WP6, WP8) use. **Creation and deletion only** — per-endpoint
+configuration (timeouts, ceilings, extra headers, D21) is WP21, scheduled
+after C3, not this package.
+
+## Files
+
+New:
+- `api/oss/src/apis/fastapi/gateways/exceptions.py` — `handle_gateway_exceptions()` (§9).
+- `api/oss/src/apis/fastapi/gateways/llms/router.py` — `LLMGatewayRouter` (§9).
+- `api/oss/src/apis/fastapi/gateways/llms/models.py` — the LLM management wire models (§6).
+- `api/oss/src/apis/fastapi/gateways/mcps/router.py` — `MCPGatewayRouter` (§9).
+- `api/oss/src/apis/fastapi/gateways/mcps/models.py` — the MCP management wire models (§6).
+
+Edited: none. `core/gateways/{llms,mcps}/service.py` are WP7's and WP9's,
+already landed by IM1 (this package depends on IM1 and WP1, per `plan.md`).
+
+## Interfaces
+
+Reproduced verbatim from `entities.md` §6 and §9. Do not rename, do not add
+routes, fields or parameters not listed here.
+
+### Wire models (§6) — the house triple, plus connect
+
+```python
+# apis/fastapi/gateways/llms/models.py
+
+class LLMEndpointCreateRequest(BaseModel):
+ endpoint: LLMEndpointCreate
+
+class LLMEndpointEditRequest(BaseModel):
+ endpoint: LLMEndpointEdit
+
+class LLMEndpointQueryRequest(BaseModel):
+ endpoint: Optional[LLMEndpointQuery] = None
+ windowing: Optional[Windowing] = None
+
+class LLMEndpointResponse(BaseModel):
+ count: int = 0
+ endpoint: Optional[LLMEndpoint] = None
+
+class LLMEndpointsResponse(BaseModel):
+ count: int = 0
+ endpoints: List[LLMEndpoint] = Field(default_factory=list)
+
+
+# apis/fastapi/gateways/mcps/models.py
+
+class MCPEndpointCreateRequest(BaseModel):
+ endpoint: MCPEndpointCreate
+
+class MCPEndpointEditRequest(BaseModel):
+ endpoint: MCPEndpointEdit
+
+class MCPEndpointQueryRequest(BaseModel):
+ endpoint: Optional[MCPEndpointQuery] = None
+ windowing: Optional[Windowing] = None
+
+class MCPEndpointResponse(BaseModel):
+ count: int = 0
+ endpoint: Optional[MCPEndpoint] = None
+
+class MCPEndpointsResponse(BaseModel):
+ count: int = 0
+ endpoints: List[MCPEndpoint] = Field(default_factory=list)
+
+class MCPConnectRequest(BaseModel):
+ """Begin the consent flow on one endpoint (WP18). Scopes are SELECTED, not
+ inherited from everything the server advertises (D17)."""
+ scopes: List[str] = Field(default_factory=list)
+
+class MCPConnectResponse(BaseModel):
+ count: int = 0
+ redirect_url: Optional[str] = None
+```
+
+This is the same house triple `triggers/models.py` and `tools/models.py`
+ship (read and confirmed — `TriggerSubscriptionCreateRequest` wraps
+`subscription: TriggerSubscriptionCreate`, `TriggerSubscriptionResponse` is
+`count: int = 0` plus `subscription: Optional[...]`, `TriggerSubscriptionsResponse`
+is `count` plus `List[...]`). Use `Field(default_factory=list)` for list
+defaults, not bare `[]`, matching `triggers/models.py`'s convention (not
+`tools/models.py`'s, which uses bare `[]` — pick the newer, safer one since
+a bare mutable default is a latent bug even though Pydantic normally copies
+it; `triggers/models.py` is the more recently written file of the two).
+
+**`MCPConnectRequest`/`MCPConnectResponse` are declared in `models.py` but
+their route is not wired by this package** — see "Out of scope" below.
+
+### `handle_gateway_exceptions()` (§9) — **seed-owned, read only**
+
+**R1 moved this file to the seed.** It is already on the branch when this package
+starts; import the decorator, do not write it. It is reproduced here because this
+package's routers are its principal consumer and the mapping is what their behaviour
+is specified against.
+
+```python
+# apis/fastapi/gateways/exceptions.py
+
+def handle_gateway_exceptions():
+ """Mapping, exactly as entities.md §9 states it:
+ - *NotFoundError -> 404
+ - PolicyDeniedError / EntitlementDeniedError -> 403
+ - *NotAllowedError -> 403
+ - CeilingExceededError -> 400, body naming the ceiling,
+ the requested and the allowed
+ values (D25)
+ - MCPAuthRequiredError -> 409, carrying the
+ GatewayConnectionRequirement
+ (an interaction, not a
+ failure — D17)
+ - *UpstreamError -> 424, or 502 when the upstream
+ answered >= 500 (the 424/502
+ split tools and triggers
+ already use)
+ """
+```
+
+Modeled on `apis/fastapi/tools/router.py::handle_adapter_exceptions()` and
+`apis/fastapi/triggers/router.py::handle_adapter_exceptions()` — both read
+and confirmed structurally identical (`@wraps`-decorated closure catching
+domain exceptions, re-raising as `HTTPException`, splitting 424 vs 502 on
+whether `cause.response.status_code >= 500`). `entities.md` explicitly
+says this decorator is "written once ... not duplicated per router" —
+those two domains each duplicate their own copy verbatim; the gateways
+domain does not repeat that mistake, and this file is the one place it
+lives (§9). Both the CRUD routers (this package) and the two data-plane
+proxies (WP6, WP8) import it from there — three consumers, which is
+precisely why R1 put it in the seed rather than in any one package.
+
+### The SSRF gate at registration (D28) — this package owns the save-time half
+
+A `custom` MCP endpoint's URL arrives in a create or edit request body typed by a user,
+and the gateway will later connect to it. WP8 guards the relay; this package guards the
+save, and both are needed: a gate only at relay time accepts and stores a plainly-bad URL
+and fails later at a confusing moment, while a gate only at save time leaves the window in
+which a hostname's DNS answer changes after the row lands.
+
+**Write no new guard, and use the no-DNS variant here:**
+
+```python
+from oss.src.core.webhooks.utils import validate_url_format_and_literal_ip
+```
+
+It checks scheme, host, absence of embedded credentials, and blocks a literal IP in a
+private / loopback / link-local / reserved / multicast / unspecified range — **without a
+DNS lookup**. The docstring states why the resolving variant is wrong at save time: it
+would reject a hostname that happens to be momentarily unresolvable. The precedent to copy
+is exact — `api/oss/src/core/secrets/dtos.py:140` gates `custom_provider.url` this way, on
+the same kind of user-typed upstream URL, and re-raises with the field name in the message.
+
+Applies to `custom` MCP endpoint create and edit only. `agenta` and `builtin` URLs are not
+user-supplied — `agenta` is ours and `builtin` is the broker's — and neither is stored as a
+row this router writes. The LLM plane's `custom` endpoints get the same gate if and when
+they carry a base URL field; check the DTO before adding the call, and do not add it
+speculatively.
+
+A rejection is a 400 through the domain-exception path (`api/AGENTS.md`), never a leaked
+`ValueError`. The message says which field was rejected and why.
+
+**The flag makes the guard inert by default.** `AGENTA_INSECURE_EGRESS_ALLOWED` defaults to
+`true` (`api/oss/src/utils/env.py`), so a unit test that does not set it `false` passes
+while proving nothing. Set it explicitly in the test.
+
+### `LLMGatewayRouter` (§9), in full
+
+```python
+class LLMGatewayRouter:
+ def __init__(self, *, llm_gateway_service: LLMGatewayService):
+ self.service = llm_gateway_service
+ self.router = APIRouter()
+
+ self.router.add_api_route(
+ "/endpoints/", self.create_endpoint, methods=["POST"],
+ operation_id="create_llm_endpoint",
+ response_model=LLMEndpointResponse,
+ response_model_exclude_none=True,
+ )
+ self.router.add_api_route(
+ "/endpoints/", self.list_endpoints, methods=["GET"],
+ operation_id="list_llm_endpoints",
+ response_model=LLMEndpointsResponse,
+ response_model_exclude_none=True,
+ )
+ # GET /endpoints/ is the merged listing — generated + custom (§8);
+ # POST /endpoints/query filters rows only, because generated endpoints
+ # have nothing to filter on but the provider, which GET already shows.
+ self.router.add_api_route(
+ "/endpoints/query", self.query_endpoints, methods=["POST"],
+ operation_id="query_llm_endpoints",
+ response_model=LLMEndpointsResponse,
+ response_model_exclude_none=True,
+ )
+ self.router.add_api_route(
+ "/endpoints/{endpoint_id}", self.fetch_endpoint, methods=["GET"],
+ operation_id="fetch_llm_endpoint",
+ response_model=LLMEndpointResponse,
+ response_model_exclude_none=True,
+ )
+ self.router.add_api_route(
+ "/endpoints/{endpoint_id}", self.edit_endpoint, methods=["PUT"],
+ operation_id="edit_llm_endpoint",
+ response_model=LLMEndpointResponse,
+ response_model_exclude_none=True,
+ )
+ self.router.add_api_route(
+ "/endpoints/{endpoint_id}", self.delete_endpoint, methods=["DELETE"],
+ operation_id="delete_llm_endpoint",
+ )
+```
+
+One handler in full, the house body (§9) — decorators, scope, permission,
+service, envelope:
+
+```python
+@intercept_exceptions()
+@handle_gateway_exceptions()
+async def create_endpoint(
+ self,
+ request: Request,
+ *,
+ body: LLMEndpointCreateRequest,
+) -> LLMEndpointResponse:
+ scope = get_auth_scope()
+ await self._check(scope, Permission.EDIT_LLM_ENDPOINTS)
+
+ endpoint = await self.service.create_endpoint(
+ project_id=scope.project_id,
+ user_id=scope.user_id,
+ #
+ endpoint=body.endpoint,
+ )
+
+ return LLMEndpointResponse(count=1 if endpoint else 0, endpoint=endpoint)
+```
+
+The other four LLM handlers (`list_endpoints`, `query_endpoints`,
+`fetch_endpoint`, `edit_endpoint`, `delete_endpoint`) follow the same
+shape: `get_auth_scope()`, one `self._check(scope, Permission.*)` call
+(`VIEW_LLM_ENDPOINTS` for reads, `EDIT_LLM_ENDPOINTS` for writes), one
+service call, one envelope. `fetch_endpoint`/`edit_endpoint`/`delete_endpoint`
+404 (raise the domain `LLMEndpointNotFoundError`, mapped by
+`handle_gateway_exceptions`) when the service returns `None`/`False` — the
+service already returns `None` for "no such row" per §7's disambiguation
+table (`edit_endpoint` → "the row does not exist" → "404 at the boundary").
+
+### `MCPGatewayRouter` — same seven shapes (§9)
+
+```python
+# --- MCP management (MCPGatewayRouter) — same shapes ---
+# POST/GET /endpoints/ create_mcp_endpoint / list_mcp_endpoints
+# POST /endpoints/query query_mcp_endpoints
+# GET/PUT /endpoints/{endpoint_id} fetch_mcp_endpoint / edit_mcp_endpoint
+# DELETE /endpoints/{endpoint_id} delete_mcp_endpoint
+# POST /endpoints/{endpoint_id}/connect connect_mcp_endpoint (WP18)
+# GET /connect/callback mcp_connect_callback (WP18)
+```
+
+**This package wires every route in that table except the two tagged
+`(WP18)`.** `entities.md` tags `connect_mcp_endpoint` and
+`mcp_connect_callback` explicitly with the package that owns them — this
+package declares neither route, matching "Out of scope" below.
+
+Permission checks: `VIEW_MCP_ENDPOINTS` for `list_endpoints`,
+`query_endpoints`, `fetch_endpoint`; `EDIT_MCP_ENDPOINTS`
+for `create_endpoint`, `edit_endpoint`, `delete_endpoint`.
+
+### The permission-check helper — factored, following `triggers/router.py`
+
+`entities.md`'s own `create_endpoint` example (§9, reproduced above) already
+writes `await self._check(scope, Permission.EDIT_LLM_ENDPOINTS)` — a
+one-line factored call, not an inlined `check_action_access(...)` /
+`if not has_permission: raise FORBIDDEN_EXCEPTION` block repeated per
+handler. Two existing precedents disagree on this, and this package must
+pick one:
+
+- `apis/fastapi/tools/router.py` **inlines** the check in every handler
+ (read and confirmed: `list_providers`, `get_provider`,
+ `create_connection`, `call_tool` etc. each repeat the same four-line
+ `has_permission = await check_action_access(...); if not has_permission:
+ raise FORBIDDEN_EXCEPTION` block, reading `request.state.user_id` /
+ `request.state.project_id` as strings).
+- `apis/fastapi/triggers/router.py` **factors** it into
+ `async def _check(self, request: Request, permission) -> None`, called
+ as `await self._check(request, Permission.EDIT_TRIGGERS)` — used
+ throughout the subscriptions, schedules and deliveries sections (its
+ catalog and connections sections still inline the check, an
+ inconsistency within that same file, not a second convention to copy).
+
+**This package uses the factored form**, adapted to take `scope: AuthScope`
+rather than `request: Request` — because `entities.md`'s own worked example
+already writes the call this way, and because `AuthScope` (not
+`request.state`) is the explicit house rule for new gateway code (§9: "the
+existing gateway, tools and triggers routers read `request.state.project_id`
+/ `request.state.user_id` as raw strings and re-wrap them in `UUID(...)`
+per call site; the design's principal claims (D2) rest on `AuthScope`").
+The helper:
+
+```python
+async def _check(self, scope: AuthScope, permission: Permission) -> None:
+ has_permission = await check_action_access(
+ user_uid=str(scope.user_id),
+ project_id=str(scope.project_id),
+ permission=permission,
+ )
+ if not has_permission:
+ raise FORBIDDEN_EXCEPTION
+```
+
+One `_check` per router class (`LLMGatewayRouter._check`,
+`MCPGatewayRouter._check`), not shared across the two — matching
+`TriggersRouter._check`'s scope (one router, one helper), and keeping each
+router's file self-contained per `workstreams/README.md`'s one-owner-per-file
+rule.
+
+## Contracts this package must honour
+
+- **Collection routes keep their trailing slash** (§9, `api/AGENTS.md`).
+ `/endpoints/`, not `/endpoints`.
+- **Every route sets `operation_id` and `response_model_exclude_none=True`**
+ (§9) — matches the house convention `api/AGENTS.md` states generally
+ ("Request/response conventions: ... Set explicit `operation_id` on
+ routes").
+- **`AuthScope` over `request.state`**, unconditionally, in every new
+ handler (§9, D2).
+- **Edits are full PUTs.** `LLMEndpointEdit`/`MCPEndpointEdit` require
+ `data`/`flags` (no partial patch semantics) — the channels-design rule
+ `entities.md` §4.3/§4.4 already encodes into the DTOs themselves; this
+ package does not add partial-update logic on top.
+- **A standard/builtin endpoint is structurally unreachable through this
+ router.** Generated entries carry no `id` (§8: "no id and no lifecycle —
+ it is not a row"); the path parameter `{endpoint_id}` is a UUID. There is
+ no way to construct a `PUT /endpoints/{endpoint_id}` request that
+ addresses a builtin entry — this is what "a standard one cannot be
+ edited" (the stated done test) means concretely: not a permission check
+ that blocks it, but the absence of an address.
+- **`SecretSafeRoute` (`apis/fastapi/vault/router.py`) is deliberately NOT
+ applied to these routers.** Read and confirmed: it exists because the
+ vault's create/update payloads carry raw secret material, and a
+ validation error otherwise echoes the submitted value back
+ (`RequestValidationError`'s `input` field). The gateway CRUD payloads
+ never carry secret material directly — `secret_id` is a UUID pointer
+ (§4.4: "the id is a pointer; reading the material it points at still
+ takes `VIEW_SECRET` through the vault") and `MCPEndpointData.headers` is
+ explicitly documented as "non-secret routing headers only" (§4.4). A
+ validation error on these routes has nothing sensitive to leak, so the
+ extra route class is not warranted here — noted as a deliberate
+ omission, not an oversight.
+
+## Settled at kickoff — was "needs a ruling"
+
+- **`apis/fastapi/gateways/exceptions.py` → the seed (R1).** Three packages need
+ `handle_gateway_exceptions()` — this one and both proxies (WP6, WP8) — and
+ `plan.md`'s dependency graph listed the three as siblings depending only on IM1,
+ so no one of them could own it without inventing a dependency. It is now written
+ once in the seed, before any worktree forks, and all three import it.
+- **The SSRF gate at registration → this package (R7, D28).** Section above.
+
+## Test layer
+
+- Wire model instantiation (the C0-style check the channels template
+ applies) — **unit**. Every model in `models.py` constructs with
+ representative values.
+- `handle_gateway_exceptions()`'s mapping — **unit**. For each domain
+ exception in the table, raise it from a dummy decorated function and
+ assert the resulting `HTTPException`'s status code and body shape (the
+ `CeilingExceededError` case additionally asserts the body names the
+ ceiling, requested and allowed values; the `MCPAuthRequiredError` case
+ asserts the body carries the `GatewayConnectionRequirement`).
+- Router wiring (which handler each route reaches, the `_check` calls) —
+ **unit**, via `TestClient` against a bare `APIRouter` mounted with a mock
+ `LLMGatewayService`/`MCPGatewayService` and a mockd `get_auth_scope()` /
+ `check_action_access()`. Assert: each route's operation_id, method and
+ path match the table above; a denied `_check` short-circuits before the
+ service is called (assert the mock service's call count is zero); a
+ `None` return from `fetch_endpoint`/`edit_endpoint` maps to 404;
+ `delete_endpoint` on a `False` return maps to 404.
+- The "standard endpoint cannot be edited" claim — **unit** is enough to
+ prove the structural part (there is no way to type a builtin entry's
+ identity into `{endpoint_id}: UUID`), but the full round trip (create a
+ custom endpoint, confirm the builtin catalogue entries are absent from
+ `/endpoints/{id}` addressability) is **integration**, needing WP1's real
+ DAO and WP9's real `list_endpoints` merge behind a real Postgres.
+- CRUD round trip (create → fetch → edit → delete, and query filtering) —
+ **integration**, needs Postgres (WP1's tables) and the real
+ `LLMGatewayService`/`MCPGatewayService` (WP7/WP9).
+
+## Executable done test
+
+Plan.md's stated done condition, verbatim: *"a custom endpoint can be
+created and deleted, and a standard one cannot be edited."* Concretely:
+
+```text
+POST /gateways/mcps/endpoints/ {endpoint: {slug: "acme-notion", auth_mode: "none", data: {url: "https://..."}}}
+ -> 200, endpoint.id is a UUID
+
+DELETE /gateways/mcps/endpoints/{that id}
+ -> 204 (or 200 per the delete shape already in use elsewhere), row gone
+
+GET /gateways/mcps/endpoints/{a builtin entry's synthetic identity, if one
+ could even be constructed — it cannot, because builtin entries carry no
+ id}
+ -> there is no request that reaches a builtin entry through this router;
+ PUT against any UUID not present in mcps_endpoints returns 404
+```
+
+## Out of scope
+
+- `POST /endpoints/{endpoint_id}/connect` (`connect_mcp_endpoint`) and
+ `GET /connect/callback` (`mcp_connect_callback`) — explicitly tagged
+ `(WP18)` in `entities.md` §9. Do not wire these routes; they arrive with
+ wave 3's consent flow.
+- Per-endpoint configuration (timeouts, ceilings, extra headers) — **WP21**,
+ after C3 (D21, `plan.md`).
+- The data-plane proxies (`apis/fastapi/gateways/{llms,mcps}/proxy.py`) and
+ their `utils.py` — **WP6** (LLM), **WP8** (MCP).
+- `core/gateways/{llms,mcps}/service.py` and everything behind it (target
+ resolution, secret resolution, the namespace merges) — **WP7**
+ (LLM), **WP9** (MCP).
+- `core/access/permissions/types.py`'s six new members — **WP3**, already
+ landed by IM1.
+
+## Checkpoint
+
+Feeds **C1**, together with WP6, WP7, WP8, WP9 at the IM2 merge.
+
+## `api/entrypoints/routers.py` diff
+
+This file is never owned by a package. WP10 contributes the two CRUD
+router constructions and mounts, applied together with WP6's and WP8's
+proxy mounts (and WP7's/WP9's service-construction fragments, which these
+constructors depend on) at the IM2 merge:
+
+```diff
++from oss.src.apis.fastapi.gateways.llms.router import LLMGatewayRouter
++from oss.src.apis.fastapi.gateways.mcps.router import MCPGatewayRouter
++
++llm_gateway_router = LLMGatewayRouter(llm_gateway_service=llm_gateway_service)
++mcp_gateway_router = MCPGatewayRouter(mcp_gateway_service=mcp_gateway_service)
+```
+
+```diff
++app.include_router(
++ router=llm_gateway_router.router,
++ prefix="/gateways/llms",
++ tags=["Gateway: LLM"],
++)
++app.include_router(
++ router=mcp_gateway_router.router,
++ prefix="/gateways/mcps",
++ tags=["Gateway: MCP"],
++)
+```
+
+(`llm_gateway_service` and `mcp_gateway_service` are the shared instances
+WP7 and WP9 construct — this fragment attaches to theirs, exactly the
+pattern `workstreams/README.md` describes: "the plane's `service.py`
+belongs to the domain package ... the ingress calls it through the
+declaration the seed froze." The local variable names are wiring
+convenience; the class names, constructor keyword, `prefix` and `tags`
+values are load-bearing, taken verbatim from §9.)
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp12.md b/docs/design/gateways-research/v1/workstreams/specs-wp12.md
new file mode 100644
index 0000000000..23607913d8
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp12.md
@@ -0,0 +1,85 @@
+# WP12 — SDK connection resolution
+
+**Owns:** `sdks/python/agenta/sdk/agents/connections/`, plus `platform/resolve.py::resolve_connection`.
+**Depends on:** C1, and wave 2's seed (the gateway-credentials field, D36).
+**Blocks:** WP13, WP14, WP15.
+
+Make the resolver return a gateway route. Everything downstream — the runner, agent v0, the
+MCP server configs — reads what this package produces, which is why it gates three packages
+and why nothing here may be guessed at.
+
+---
+
+## What changes, in one sentence
+
+`resolve_connection` today answers "which provider, which key"; it must answer "the gateway,
+and our credentials for it" — without losing any capability it has (D4).
+
+## The shape
+
+`ResolvedConnection` (`connections/models.py`) keeps every field. What changes is what fills
+them:
+
+| field | before | after |
+| --- | --- | --- |
+| `provider` | the upstream's family | unchanged — the gateway routes on it |
+| `deployment` | `direct` / `custom` / `bedrock` / ... | unchanged |
+| `endpoint.base_url` | the provider's URL | **the gateway's route** for this target |
+| `credentials` | the provider's secret | **empty** — the gateway holds it |
+| `credential_mode` | `env` | `none`, since we inject no provider secret |
+| the D36 field | — | our credentials and the header they ride |
+| `environment` | regions, project ids | unchanged; still non-secret |
+
+**The gateway route is `{gateway_base}/gateways/llms/{namespace}/{name}`**, with the
+namespace and name from D30's grammar: `standard/{provider_key}` for a generated endpoint,
+`custom/{slug}` for a row. The protocol path the caller appends (`/v1/chat/completions`) is
+the harness's own and is not part of the base URL — the same split the endpoint document
+already makes (entities.md §2.4).
+
+**`credential_mode` becomes `none`, not `env`.** Its meaning is "where does the *provider's*
+credential come from", and the answer is now "nowhere, the gateway has it". Our own
+credentials are not a provider credential and do not travel in `credentials` — that is D36.
+
+## Contracts this package must honour
+
+- **No provider secret in the output.** The single assertion that makes this package worth
+ doing: for every provider and every deployment, a resolved connection carries no upstream
+ key. Assert it structurally — a dump of the model contains nothing matching a resolved
+ secret — rather than field by field.
+- **Every capability survives** (D4). The resolver still answers for every provider,
+ deployment and modality it answers for today. A provider it cannot route through the
+ gateway must fail loudly, not silently degrade to a direct connection.
+- **`plaintext_environment()` stays complete.** It returns the environment; the D36 field
+ materializes separately and both are called at the boundary. A consumer that calls only
+ one must not silently lose the other — the seed's validator is what enforces this, and
+ this package must not work around it.
+- **The https requirement holds except on loopback** (D37, settled in the seed).
+- **Masking survives.** `ResolvedCredential.value` is masked from `repr`, `str` and
+ `model_dump`; the D36 field carries a secret too and inherits the same treatment.
+
+## Which upstreams are reachable
+
+D34 forbids body conversion, so a target is routable only if a front door speaks its
+protocol. WP23 ships all three doors, so at C2 the answer is: everything with a
+door. **Until WP23 merges, this package can only be tested against Chat Completions
+targets** — which is the mock, the OpenAI-shaped providers and OpenAI-compatible custom
+endpoints. Do not add a fallback for the rest; a provider with no door is an error.
+
+## Tests
+
+- **Unit, no network.** A resolved connection for each (provider, deployment) pair the
+ resolver supports: base URL is the gateway's, `credentials` is empty, `credential_mode` is
+ `none`, and the D36 field carries our credentials.
+- **Unit, structural.** `model_dump_json()` of a resolved connection contains no upstream
+ secret, for every pair.
+- **Unit.** A target with no front door raises, naming the target and the protocol.
+- **Unit.** Loopback base URLs pass the validator; a non-loopback http base URL still fails.
+- The existing connection tests keep passing unchanged, or the change is deliberate and
+ named in the task list.
+
+## Out of scope
+
+- The wire and the runner (WP13), the MCP server configs (WP15), agent v0 (WP14).
+- The gateway's own behaviour. This package produces a route and credentials; what the
+ gateway does with them is wave 1's, already built.
+- Any decision about which provider is reachable — that is OD16, verified in WP24.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp13.md b/docs/design/gateways-research/v1/workstreams/specs-wp13.md
new file mode 100644
index 0000000000..8b97bf5d09
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp13.md
@@ -0,0 +1,75 @@
+# WP13 — Runner and harnesses
+
+**Owns:** `services/runner/src/`, the harness configuration writers, and the wire's consumer
+side. **Depends on:** WP12. **Blocks:** C2.
+
+The runner carries a gateway route and our credentials instead of provider secrets. Two
+properties make it worth doing, and both are checkable: the per-consumer secret arrays
+collapse, and the redaction set shrinks with them.
+
+---
+
+## What arrives from WP12
+
+`ModelConnection` (`services/runner/src/protocol.ts:566`) with:
+
+- `endpoint.baseUrl` — the gateway route
+- `credentialMode: "none"` — no provider secret to inject
+- `credentials: []` — empty
+- the gateway-credentials field from the seed (D36), carrying the header name and value
+
+The wire's field-by-field meaning is documented above the interface and is the authority; the
+runner re-validates rather than trusting, as it does today, and does not invent fields.
+
+## The harness configuration problem
+
+A gateway credential is only useful if the harness sends it. Each harness exposes a different
+mechanism, and **each must be verified against the release actually in use** (OD14) before
+this package depends on it:
+
+- Claude Code — a custom-header mechanism plus a base-URL override.
+- OpenCode — provider-specific request headers plus a base URL.
+- Codex — additional fixed or environment-derived HTTP headers.
+
+`services/runner/src/engines/sandbox_agent/` holds the writers (`pi-model-config.ts`,
+`codex-assets.ts`, `environment.ts`). This package writes the header into each harness's own
+configuration; it does not add a proxy in front of the harness.
+
+**If a harness cannot carry the header on its current release, that is a finding, not a
+workaround.** The fallback is the local-agent shape (OD14), which is a separate package and
+is only worth building for a harness that is both wanted and incapable.
+
+## What must shrink
+
+- **`daytona-secret-plan.ts`'s allowlist.** `local_use` exists for secrets a provider SDK
+ signs with inside the sandbox, and the list is deliberately short. With the gateway holding
+ provider secrets, entries should leave it. Any entry that stays needs a reason.
+- **The redaction set.** Fewer secrets in the sandbox means fewer strings to redact. If it
+ does not shrink, the secrets did not actually leave.
+
+## Contracts
+
+- **No provider secret reaches the sandbox.** Asserted by inspecting the sandbox environment
+ after a run, on both the local and the Daytona sandbox — not by inspecting our resolver,
+ which is WP12's own test and proves nothing about delivery.
+- **`credentialMode` keeps its three values.** `runtime_provided` stays meaningful: it is a
+ harness authenticating with its own vendor login, which is D32's pass-through and is not
+ this package's to remove.
+- **The wire is shared with WP15.** The gateway-credentials field belongs to the seed. If
+ this package finds it wrong, it reports rather than editing — WP15 inherits the same shape.
+
+## Tests
+
+- Unit: a `ModelConnection` with `credentialMode: "none"` and the gateway field produces a
+ harness configuration carrying the header, per harness.
+- Unit: no provider secret appears in any harness configuration produced from a gateway
+ connection.
+- Unit: the Daytona secret plan produced from a gateway connection is empty or justified
+ entry by entry.
+- Acceptance: a run completes against the gateway with no provider secret in the sandbox
+ environment, local and Daytona.
+
+## Out of scope
+
+- The MCP server configs (WP15), even though they are on the same wire.
+- The local-agent fallback, which OD14 decides the need for.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp14.md b/docs/design/gateways-research/v1/workstreams/specs-wp14.md
new file mode 100644
index 0000000000..cadcace49f
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp14.md
@@ -0,0 +1,48 @@
+# WP14 — Agent v0
+
+**Owns:** agent v0's model call path. **Depends on:** WP12. **Blocks:** C2.
+
+The remaining caller. Smallest of the wave-2 packages, and the one that proves the resolver
+change is general rather than runner-shaped.
+
+---
+
+## What changes
+
+Agent v0 resolves a connection the same way the runner does, so this package is mostly the
+consequence of WP12 rather than work of its own. What it must establish:
+
+- The agent's model calls go through the gateway route, with our credentials.
+- No provider secret is read, held or logged anywhere in the agent's path.
+- The agent's own protocol matches a front door (WP23). If it speaks Chat Completions it is
+ served today; if it speaks anything else, it needs the door and the dependency is real.
+
+`services/oss/src/agent/secrets.py` is where the current secret handling lives and is the
+first thing to read — what it does today is the list of what must stop happening.
+
+## Contracts
+
+- **The agent stops reading provider secrets at all.** Not "reads them and does not use
+ them" — the code path goes away, or the secret is still one deployment mistake from being
+ used.
+- **Failures are legible.** A gateway refusal (model not allowed, endpoint deactivated,
+ ceiling exceeded) reaches the agent as the platform's error envelope and surfaces with its
+ code, not as a generic upstream error. `api/AGENTS.md`'s agent-actionable-errors rule
+ applies: a payload the caller must change is `retryable: false` with a `next_step`.
+- **No fallback.** If the gateway is unreachable the agent fails; it does not call a provider
+ directly. A silent bypass is worse than an outage because nothing records it.
+
+## Tests
+
+- Unit: the agent's resolved connection carries the gateway route and no provider secret.
+- Unit: a gateway refusal surfaces with its code and its `next_step`, not flattened into a
+ generic failure.
+- Unit: no code path in the agent reads a provider secret.
+- Acceptance: an agent run completes through the gateway, and its calls appear as audit
+ events with the right principal.
+
+## Out of scope
+
+- The runner and the harnesses (WP13), which resolve the same way but deliver differently.
+- Evaluators and the playground, whose model call sites are deferred with the evaluator path
+ (D15).
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp15.md b/docs/design/gateways-research/v1/workstreams/specs-wp15.md
new file mode 100644
index 0000000000..5d6f4667a4
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp15.md
@@ -0,0 +1,79 @@
+# WP15 — MCP servers on the wire
+
+**Owns:** the runner's MCP server configuration path. **Depends on:** WP12, and WP13's wire
+commit. **Blocks:** C2.
+
+The smaller of the two runner packages, because the binding it needs already exists.
+
+---
+
+## Phase 0 — which servers a stateless relay reaches (OD17)
+
+Before any wiring, answer OD17 for the servers this package is tested against and for the
+handful we expect to route first. Per server, from its own documentation or a probe against
+it: does it answer a plain stateless `POST` with no session minted, and does it need the SSE
+leg for ordinary calls? The gateway refuses `GET` and `DELETE`, so a server that needs the
+stream is not reachable and no amount of wiring here changes that.
+
+**If this phase finds that most servers we care about are still on a session revision, stop
+and report.** The answer is a decision about whether to detect and refuse a revision clearly
+or to carry session state — reversing D8 — and neither belongs in this package.
+
+**Record the answers in `open-designs.md` OD17 and close it**, the way WP24 closes OD16. A
+server that passes is a fact; a server assumed to pass is what this phase exists to prevent.
+
+## What changes
+
+`McpServerConfig` (`services/runner/src/protocol.ts:314`) already has the right shape:
+
+```ts
+connection: { type: "http", url: string, headers?, credentials?: McpCredential[] }
+policy: { tools: McpToolPolicy, permission? }
+```
+
+`McpCredential.binding` is `{ kind: "header", name }` — the header binding the model side
+lacked, which is why this package is small.
+
+After this package, per server:
+
+- `connection.url` is the gateway's MCP route: `{gateway_base}/gateways/mcps/{namespace}/...`
+ following D30's grammar — `builtin/{provider}/{integration}/{connection}`,
+ `builtin/agenta/{slug}`, or `custom/{slug}`.
+- `connection.credentials` carries **our** credentials in the gateway header, not the
+ upstream server's token.
+- `policy.tools` is unchanged. The gateway enforces its own filter at the boundary; the
+ runner keeping its own is defence in depth, not duplication to remove.
+
+## The one thing worth stating twice
+
+**The gateway's tool filter and the runner's tool policy are different enforcement points
+and both stay.** The gateway refuses a tool the endpoint's filter disallows, before the
+upstream is dialled. The runner refuses a tool the run's policy disallows, before the call
+leaves the sandbox. Removing either because "the other one covers it" removes a boundary.
+
+Their documents are no longer the same shape — the gateway's filter is
+`{allowlist, denylist}` and the wire's is `{mode, names}` — and that is fine. They are
+enforced in different places by different owners; the wire is not a copy of the endpoint.
+
+## Contracts
+
+- **No upstream server token reaches the sandbox.** Same assertion as WP13, on the tool side:
+ inspect the sandbox, not the resolver.
+- **Tool names are untouched.** D16's transparency: same names, same schemas, same errors.
+ A gateway-routed server is an HTTP MCP server whose URL happens to be ours.
+- **The wire's shape is WP13's.** If this package needs a wire change, it reports it — the
+ file is shared and editing it in parallel is how a stack scrambles.
+
+## Tests
+
+- Unit: a gateway-routed MCP server config carries the gateway URL and our credentials, and
+ no upstream token.
+- Unit: `policy.tools` survives unchanged through the resolution path.
+- Acceptance: a run's tool calls reach a server through the gateway, with no server token in
+ the sandbox, and appear as audit events.
+
+## Out of scope
+
+- OAuth-protected servers, which are wave 3 (WP16–WP20). Wave 1's reachable set is
+ unauthenticated servers and the mocks (D23), and that is what this package is tested
+ against.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp16.md b/docs/design/gateways-research/v1/workstreams/specs-wp16.md
new file mode 100644
index 0000000000..b15a31833e
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp16.md
@@ -0,0 +1,79 @@
+# WP16 — Secret kinds
+
+**Owns:** the secret-kind enum, the per-kind settings DTOs, the union member list on the secret
+DTO, and the kind validator's dispatch branch — wherever those four live (OSS, and EE if
+mirrored).
+**Depends on:** C2. **Blocks:** WP17.
+
+D14 adds two secret kinds for OAuth. This package adds them to the existing kind machinery and
+nothing else — no client, no storage adapter, no route. WP17 consumes what this package adds.
+
+---
+
+## The two kinds
+
+**`oauth_provider`** — our client registration with an authorization server: client id, client
+secret, issuer URL, scopes. The existing `sso_provider` kind is the precedent in both name and
+shape. One per authorization server. Long-lived, rarely rotated, owned by the platform or the
+project.
+
+**`oauth_grant`** — a user's tokens: access token, refresh token, expiry, the scopes actually
+granted, and the server the token was minted for. Identified by the upstream server rather than
+by the provider, because tokens are audience-bound. One per owner per server. Rewritten on every
+refresh, owned by a person.
+
+**Two kinds, not sub-kinds of one (D14).** The sub-kind pattern in this codebase discriminates
+the same thing across vendors — a provider key has one shape and one lifecycle whether it is
+OpenAI or Anthropic. `oauth_provider` and `oauth_grant` share no fields and differ in
+cardinality, lifetime, rotation frequency and owner. A single kind would need a union inside it,
+and every query for a user's grants would filter on an inner field instead of on the kind.
+
+## The four touch points
+
+Per `secrets.md`, adding a kind touches four places and no schema, because the payload is one
+encrypted blob:
+
+1. **The kind enum** — append `oauth_provider` and `oauth_grant`.
+2. **A settings DTO per kind, plus its wrapper** — `OAuthProviderSecretSettings` (client id,
+ client secret, issuer URL, scopes) and `OAuthGrantSecretSettings` (access token, refresh
+ token, expiry, granted scopes, server identifier).
+3. **The union member list on the secret DTO** — add both wrapped settings types.
+4. **The kind validator's branch** — the dispatcher is a hand-written
+ `model_validator(mode="before")` keyed on the sibling `kind` field, not a Pydantic
+ discriminated union. Each new kind needs its own branch or it is rejected outright.
+
+## Coordinate, don't renumber
+
+Parallel work is adding kinds to this same enum for sandbox providers and the tool gateway key.
+**Append only.** Do not renumber, reorder or reformat existing members — a diff that touches an
+existing line invites a merge conflict the other branch does not need.
+
+## Contracts
+
+- Never overload an existing kind. `custom_secret` and `custom_provider` exist for other things;
+ reusing one to dodge adding a kind is a false economy (D14).
+- No kind for a static MCP secret and no kind for the inbound gateway credential — both stay out
+ of scope per `secrets.md`; this package does not add them.
+- This package builds no client, no storage adapter, no CRUD route and no resolution logic. It
+ makes the two kinds valid to construct and validate. WP17 is where they get used.
+- If the SDK mirrors the kind enum, keep it in sync in the same commit; a drifted mirror fails
+ silently the first time either side adds a kind the other does not know about.
+
+## Tests
+
+- Unit: `OAuthProviderSecretSettings` accepts a valid payload (client id, client secret, issuer
+ URL, scopes) and rejects a payload missing a required field.
+- Unit: `OAuthGrantSecretSettings` accepts a valid payload (access token, refresh token, expiry,
+ granted scopes, server identifier) and rejects one missing a required field.
+- Unit: the kind validator accepts `kind: "oauth_provider"` paired with `OAuthProviderSecretSettings`
+ and rejects it paired with any other kind's settings, and the same for `oauth_grant`.
+- Unit: the enum still contains every pre-existing member, unchanged and in the same order — a
+ regression guard against the "append only" rule above.
+
+## Out of scope
+
+- The OAuth client itself, the storage adapter, connect callbacks (WP17).
+- The consent flow and scope selection (WP18).
+- Any resolution, ownership or resolution-mode logic (`secrets.md`'s Ownership and Resolution
+ sections) — that work is designed but not scheduled, and this package does not schedule it.
+- A static MCP secret kind — deferred per D14.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp17.md b/docs/design/gateways-research/v1/workstreams/specs-wp17.md
new file mode 100644
index 0000000000..694eea1e81
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp17.md
@@ -0,0 +1,351 @@
+# WP17 — OAuth client
+
+**Owns:** `core/gateways/mcps/oauth/` — the storage adapter, the discovery/registration/token
+client, and the two-phase connect service. Nothing outside that package.
+**Depends on:** WP16. **Blocks:** WP18, WP19, WP20.
+
+This is the spine. WP18 (consent flow), WP19 (step-up) and WP20 (registration fallback) all
+call into what this package exposes rather than building their own OAuth plumbing. Read this
+whole document before touching those packages — the seams below are the contract.
+
+**Target: the `custom` namespace only.** A `custom` MCP endpoint is a row the user typed a URL
+into (`MCPEndpointCreate.data.route.base_url`); when its `auth_mode` is `oauth`, this package is
+how it gets a token. `builtin` is Composio-brokered (D30) and never reaches this client — there
+is no fallback path from one to the other, and this package imports nothing from
+`core/gateway/connections/` (the Composio broker domain; note the singular/plural split between
+`core/gateway/` and `core/gateways/` is an existing, deliberate domain boundary — entities.md §1).
+
+---
+
+## What already exists (WP16 + the wave-1/2 seed) and what this package adds
+
+Already in the tree before this package:
+
+- `SecretKind.OAUTH_PROVIDER` / `SecretKind.OAUTH_GRANT` (`core/secrets/enums.py`), their DTOs
+ (`OAuthProviderDTO{provider: OAuthProviderSettingsDTO{client_id, client_secret, issuer_url,
+ scopes, extra}}`, `OAuthGrantDTO{grant: OAuthGrantSettingsDTO{server, access_token,
+ refresh_token?, expires_at?, scopes}}`) and the kind validator's two branches
+ (`core/secrets/dtos.py`).
+- `MCPEndpoint.secret_id` (one nullable FK to a `secrets` row) and `MCPEndpointData.oauth:
+ Optional[MCPOAuthData]` — discovery metadata cached on the row (`resource`,
+ `authorization_server`, `scopes_offered`), explicitly "written by the OAuth checkpoint (WP17)"
+ (`core/gateways/mcps/dtos.py`).
+- `MCPGatewayService._resolve_auth`'s `OAUTH` branch, which already resolves `endpoint.secret_id`
+ via `SecretsResolverInterface.resolve(ref=BoundSecretRef(...), mode=PROJECT_ONLY)` and builds
+ `MCPDirectAuth(secret=...)` (`core/gateways/mcps/service.py`). This package does not touch that
+ method — it only makes sure a `secret_id` naming a live `oauth_grant` exists to resolve.
+- `HttpMCPAdapter._authorization_header`, which already reads `auth.secret.secret.data.grant
+ .access_token` defensively via `getattr` (`core/gateways/mcps/providers/http/adapter.py`) —
+ built before `OAuthGrantSettingsDTO` existed, needs no change now that it does.
+- `MCPAuthRequiredError` / `MCPScopeInsufficientError` (`core/gateways/mcps/types.py`) and
+ `GatewayConnectionRequirement` / `GatewayConnectAffordance` (`core/gateways/dtos.py`) — typed,
+ mapped to HTTP 409 in `apis/fastapi/gateways/exceptions.py` and
+ `apis/fastapi/gateways/mcps/proxy.py`, but nothing raises them yet.
+- The router seam: `apis/fastapi/gateways/mcps/router.py`'s docstring and entities.md §9 both
+ name `POST /endpoints/{endpoint_id}/connect` and `GET /connect/callback` as **(WP18)**, not
+ wired in WP9's router. "The callback writes the oauth_grant secret and PUTs `secret_id` through
+ `edit_endpoint` — the same door every other field uses." **This package builds no route.** It
+ builds the two calls WP18's route handlers will make.
+
+What this package adds, and nothing else:
+
+1. `SecretsTokenStorage` — a storage adapter over `VaultService`, structurally satisfying the
+ official MCP Python SDK's `TokenStorage` protocol (`mcp.client.auth.oauth2.TokenStorage`).
+2. `MCPOAuthClient` — discovery (protected-resource metadata → authorization-server metadata),
+ dynamic client registration, PKCE, and authorization-code token exchange, built from the SDK's
+ own DTOs (`mcp.shared.auth`) rather than its `OAuthClientProvider` (see "Why not
+ `OAuthClientProvider`" below).
+3. `MCPOAuthConnectService` — the two-phase `begin()` / `complete()` orchestration WP18's two
+ routes call, plus `discover()` for the scope-selection screen.
+4. The state-token shape that survives the round trip through the user's browser and the
+ authorization server, and the exact callback URL.
+5. `mcp` added to `api/pyproject.toml` as a pinned dependency (it was not one before this
+ package — verified against `pyproject.toml`/`uv.lock`).
+
+---
+
+## Why not `OAuthClientProvider`
+
+The SDK ships one client class, `mcp.client.auth.oauth2.OAuthClientProvider(httpx.Auth)`. It is
+built for a CLI: `async_auth_flow()` is a single generator that, on a 401, runs discovery,
+registration, redirect and token exchange **inline in one coroutine**, blocking on
+`redirect_handler` (open a local browser) and `callback_handler` (await a local HTTP listener)
+before it can `yield` the retried request. That shape cannot survive a web deployment: the
+"redirect" and the "callback" are two different HTTP requests, arbitrarily far apart in time (the
+user is on the authorization server's own pages in between), so nothing here can hold one
+coroutine open across them — there is no request handler alive to resume.
+
+So this package does not instantiate `OAuthClientProvider`. It reuses the SDK's public, stateless
+**pieces** — the Pydantic DTOs in `mcp.shared.auth` (`OAuthClientMetadata`,
+`OAuthClientInformationFull`, `OAuthToken`, `OAuthMetadata`, `ProtectedResourceMetadata`) and
+`PKCEParameters` from `mcp.client.auth.oauth2` — and drives discovery, registration and token
+exchange itself, split across the two calls a web flow actually has. This is still "the official
+SDK's client provider" in the sense the launch doc means: the wire types, the RFC-shaped
+validation and the PKCE generation are the SDK's, not reinvented. What is reinvented is the
+control flow, because the SDK's is CLI-shaped and D26 already ruled out anything CLI-shaped.
+
+`TokenStorage` is a `Protocol` (structural typing) — `SecretsTokenStorage` implements its four
+methods without subclassing anything from the SDK.
+
+---
+
+## The storage adapter
+
+```python
+class SecretsTokenStorage: # structurally satisfies mcp.client.auth.oauth2.TokenStorage
+ def __init__(self, *, vault_service: VaultService, project_id: UUID, server_url: str): ...
+
+ async def get_tokens(self) -> Optional[OAuthToken]: ...
+ async def set_tokens(self, tokens: OAuthToken) -> None: ...
+ async def get_client_info(self) -> Optional[OAuthClientInformationFull]: ...
+ async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: ...
+```
+
+**Scope: one instance per `(project_id, server_url)`.** `OAuthClientProvider` is constructed once
+per MCP server in the SDK's own model (`server_url` is a constructor argument, not a per-call
+one), and this package keeps that shape: `MCPOAuthConnectService` builds one `SecretsTokenStorage`
+per connect attempt, scoped to the endpoint's `data.route.base_url`.
+
+**Ownership: project, not person, for this wave — corrects WP16's spec prose.** WP16's own spec
+doc describes `oauth_grant` as "owned by a person"; entities.md, the design's source of record,
+is explicit that this is wrong for what ships now: "not gaining an owner column — every gateway
+secret is project-owned, full stop" (entities.md §1, the `secrets` table row), and
+`SecretsResolver._resolve_bound_secret` already degrades every `SecretMode` to the same
+project-only fetch because there is no owner column to filter on. `_resolve_auth`'s own comment
+calls this "one consent per server". This package follows entities.md: **one `oauth_grant` per
+project per server**, matching the single `MCPEndpoint.secret_id` column exactly (no per-user
+fan-out, because there is nowhere to record a user on a secret row today). A future user-owned-
+secrets wave is the place `SecretOwnerKind.USER` gets a real lookup; this package does not
+simulate it by encoding a user id into a slug.
+
+**No new DAO surface.** `SecretsDAOInterface` has five methods and none of them is "find by kind
++ field" (only `get_by_id`, `get_by_slug`, `list`). This package does not add one. It follows the
+exact precedent `SecretsResolver._match_provider_secret` already set for `ProviderKeyRef`: call
+`VaultService.list_secrets(project_id=...)`, filter in Python for the right `kind` and the right
+identifying field, and get-or-create against the result — `update_secret` on a match,
+`create_secret` when there is none. The `secrets.slug` column carries no unique constraint
+(`dbs/postgres/secrets/dbas.py`), so idempotency is this scan, not a database guarantee — same as
+every other kind in this domain today.
+
+**Keys.**
+- `oauth_provider` (client registration) is looked up by `data.provider.issuer_url == `. Before discovery completes there is no issuer
+ to key on, so the first connect attempt for a server always registers fresh; a second `custom`
+ endpoint on the same authorization server reuses the row once discovery has run for it too. No
+ attempt is made to key provisionally on `server_url` and migrate the row later — that is an
+ optimization, not a correctness requirement (worst case: one client registration per server
+ instead of one per authorization server, which is exactly today's default before this package
+ existed).
+- `oauth_grant` (tokens) is looked up by `data.grant.server == ` — matching
+ WP16's own framing ("identified by the upstream server rather than by the provider, because
+ tokens are audience-bound") and the single `secret_id` column on the endpoint row.
+- Both kinds get a deterministic slug via the existing `get_slug_from_name_and_id` helper
+ (`utils/helpers.py`), named from the server/issuer host and a stable UUID derived from the
+ URL — readable in the CRUD UI, never parsed back.
+
+**`get_tokens()` / `get_client_info()` return `None` on no match** (not an exception) — that is
+what the `TokenStorage` protocol signature promises and what lets this package's `begin()` decide
+"register" vs. "reuse" and "authorize" vs. "already have a live grant" without a control-flow
+exception for the ordinary case.
+
+---
+
+## The two-phase connect service
+
+```python
+class MCPOAuthConnectService:
+ def __init__(self, *, vault_service: VaultService, client: MCPOAuthClient): ...
+
+ async def discover(self, *, server_url: str) -> MCPOAuthDiscovery:
+ """Unauthenticated probe -> protected-resource metadata -> authorization-server
+ metadata. The probe's own response drives where protected-resource metadata is
+ read from: a 401 whose WWW-Authenticate header carries `resource_metadata`
+ (RFC 9728) names it directly; only when there is no 401, no header, or no
+ `resource_metadata` parameter does this fall back to the well-known URIs
+ (OD21 — matches mcp.client.auth.oauth2.OAuthClientProvider's own ordering,
+ read for ordering only, not called into). Feeds MCPEndpointData.oauth
+ (resource, authorization_server, scopes_offered) and the scope checklist
+ WP18's consent screen renders. No secret involved — this is discovery,
+ callable before there is anything to connect (entities.md §2.3: "fetched at
+ configuration time with no secret at all")."""
+
+ async def begin(
+ self, *, project_id: UUID, user_id: UUID, server_url: str, scopes: List[str]
+ ) -> MCPOAuthAuthorizationStart:
+ """Ensures client registration (reuse via storage, else dynamic registration
+ RFC 7591), generates PKCE + state, returns {authorization_url, state}. WP18's
+ POST /endpoints/{endpoint_id}/connect wraps this: it resolves endpoint_id ->
+ server_url first, then calls this with the caller's scope."""
+
+ async def complete(
+ self, *, code: str, state: str
+ ) -> MCPOAuthCompletion:
+ """Decodes+verifies state, re-discovers the token endpoint, exchanges code+
+ verifier for tokens, writes the oauth_grant secret via VaultService. Returns
+ {project_id, server_url, secret_id} for WP18's GET /connect/callback to PUT
+ onto the endpoint's secret_id through edit_endpoint — this package never calls
+ edit_endpoint itself, staying inside its own package boundary."""
+```
+
+`MCPOAuthClient` (the SDK-DTO-driven HTTP piece both methods above call into) takes an injectable
+`httpx.BaseTransport`, matching `HttpMCPAdapter`'s and `ComposioConnectionsAdapter`'s existing
+seam for tests.
+
+### The callback URL, precisely
+
+**Fixed redirect URI, registered once, disambiguated by `state` — not by a per-flow URL.**
+
+```
+redirect_uri (sent to the authorization server, and what gets registered/matches on file):
+ {AGENTA_API_URL}/gateways/mcps/connect/callback
+
+the browser lands on, after the authorization server redirects it:
+ {AGENTA_API_URL}/gateways/mcps/connect/callback?code=&state=
+```
+
+This deliberately differs from the precedent in `core/gateway/connections/service.py`
+(`callback_url = f"{env.agenta.api_url}{_CALLBACK_PATH}?state={state}"`), which bakes the state
+into the registered callback URL. That works for Composio, whose broker does not enforce exact
+`redirect_uri` matching the way RFC 6749 authorization servers commonly do. A generic `custom`
+MCP server's authorization server is exactly the kind of RFC 6749 implementation that can reject
+a redirect URI carrying an unregistered query string. `state` is the mechanism the spec provides
+for this — an opaque value that round-trips as its own top-level parameter — so this package uses
+it as intended rather than overloading the URL. The full path matches the route entities.md §9
+already reserved for WP18: `GET /connect/callback`, mounted under the plane's `/gateways/mcps`
+prefix (`api/entrypoints/routers.py`'s mount table).
+
+### The state token
+
+An HMAC-SHA256-signed, base64url payload — the same shape as
+`core/gateway/connections/utils.py::make_oauth_state`/`decode_oauth_state`, reimplemented locally
+in `core/gateways/mcps/oauth/state.py` rather than imported, to keep this package independent of
+the Composio/connections domain (see "target: `custom` only" above). Signed with
+`env.agenta.crypt_key`, 1-hour TTL, carrying:
+
+```json
+{
+ "project_id": "...", "user_id": "...",
+ "server_url": "https://mcp.acme.io/",
+ "code_verifier": "<43-128 char PKCE verifier>",
+ "scopes": ["read", "write"],
+ "nonce": "...", "ts": 1234567890
+}
+```
+
+`code_verifier` travels in the signed state rather than in server-side session storage: there is
+no server-side session to put it in (no sticky worker assumption — any replica can serve the
+callback), and the state's own HMAC is exactly the tamper protection PKCE's verifier needs in
+transit. This is a deliberate, stated choice, not an oversight — flag it for review if a
+deployment's threat model wants the verifier off the wire entirely.
+
+---
+
+## Contracts
+
+- **No code path in this package parses or reaches a `builtin` target.** `SecretsTokenStorage`,
+ `MCPOAuthClient` and `MCPOAuthConnectService` take a `server_url`, never a `provider`/
+ `integration` pair — the builtin two-segment address space (D30) cannot even be expressed as an
+ argument here.
+- **This package writes secrets; it never writes an `MCPEndpoint` row.** `complete()` returns a
+ `secret_id`; wiring it onto `endpoint.secret_id` is `edit_endpoint`, called by WP18's router,
+ never by this package. Keeps the "one door" rule entities.md states for that column intact.
+- **`get_tokens`/`get_client_info` never raise for "not found".** `None` is the correct answer and
+ the caller (this package's own `begin()`) branches on it; only a genuine adapter failure (vault
+ unreachable, decrypt failure) raises.
+- **Discovery makes no assumption about the target being reachable from here at connect time
+ beyond an ordinary outbound HTTP call** — the SSRF guard already gating `custom` endpoint URLs
+ at registration (`_guard_custom_endpoint_url`, D28) is not re-applied inside this package,
+ because discovery only runs against a URL the CRUD router already validated when the row was
+ created or edited. `MCPOAuthClient` still goes through the same resolving-IP connect helper
+ (`core/webhooks/utils.py`) used by `HttpMCPAdapter`, for defense in depth against a URL that was
+ valid at registration and repoints since.
+- **Every state token is single-use in effect, not by a stored nonce ledger.** `complete()`
+ consumes the signed state and immediately performs the token exchange; a replayed state still
+ passes signature/TTL checks but the second token exchange either fails at the authorization
+ server (auth codes are single-use per RFC 6749 §4.1.2) or, in the mock authorization server this
+ package's tests use, is asserted against directly. A stored-nonce replay guard is not built —
+ flagged as a gap for the mock's threat model, not assumed away.
+
+---
+
+## What WP18, WP19 and WP20 each consume
+
+**WP18 — Consent flow.** Calls `discover()` to render the scope checklist, `begin()` from
+`POST /endpoints/{endpoint_id}/connect` (resolving `endpoint_id` to `server_url` itself — this
+package takes a bare URL, never an endpoint id, keeping it ignorant of the `mcps_endpoints`
+table), and `complete()` from `GET /connect/callback`, then calls `edit_endpoint` with the
+returned `secret_id`. WP18 also owns turning a discovery/registration failure into whatever the
+dashboard shows — this package's exceptions (below) are typed for that, not swallowed here.
+
+**WP19 — Step-up interaction.** `MCPScopeInsufficientError` (already declared,
+`core/gateways/mcps/types.py`) is raised by the relay path, not by this package — WP19's job is
+wiring a 403/`invalid_scope` response from the upstream into that exception. What WP19 consumes
+from WP17 is `begin()` with a **narrower** `scopes` argument than "everything the server offers":
+step-up re-runs `begin()` for the specific missing scope(s) against the same `server_url`, and
+`SecretsTokenStorage` finding an existing `oauth_grant` for that server means `complete()`'s token
+write is an `update_secret` (rotate in place) rather than a fresh `create_secret` — the storage
+adapter does not need to know "this is a step-up" as a distinct case; it is the same get-or-create
+path with a different requested-scope list.
+
+**WP20 — Client registration fallback.** Consumes `MCPOAuthClient`'s registration call as the
+seam to swap: today it always does RFC 7591 dynamic client registration outbound (`POST` to the
+authorization server's `registration_endpoint`, discovered or defaulted to `/register`) — the
+"older mechanism" D26 names as the one with no inbound direction. WP20's job (the "prefer the
+document, fall back to registering outbound" rule) is entirely about the *other* mechanism — a
+client-identifier-as-HTTPS-URL that the authorization server fetches from us — which this package
+does not implement at all, by design: D26 says that mechanism is the one that can fail on an
+internal-only domain, and WP17 ships only the always-safe outbound path. WP20 adds the preferred
+mechanism in front of it, keeping this package's registration call as the fallback branch
+unchanged.
+
+---
+
+## Tests
+
+Unit only, no live network, no real authorization server, no real MCP server — an
+`httpx.MockTransport` standing in for the authorization server's well-known endpoints,
+registration endpoint and token endpoint, and a fake `SecretsDAOInterface` (in-memory dict)
+backing a real `VaultService` for the storage-adapter tests, matching
+`test_gateways_http_mcp_adapter.py`'s and `test_provider_probe.py`'s existing pattern.
+
+- `SecretsTokenStorage`: `get_tokens`/`get_client_info` return `None` on first call for a fresh
+ `(project_id, server_url)`; `set_client_info` then `get_client_info` round-trips; `set_tokens`
+ then `get_tokens` round-trips; a second `set_tokens` call updates the same row (`update_secret`
+ called, not a second `create_secret` — assert via the fake DAO's call log); two different
+ `server_url`s under the same project never collide.
+- `state.py`: sign/verify round-trip carries `server_url`, `code_verifier`, `scopes`; a tampered
+ byte is rejected; an expired token is rejected — same three cases as
+ `test_oauth_state_identity.py`'s existing precedent for the sibling domain.
+- `MCPOAuthClient.discover()`: a mock AS answers protected-resource metadata then
+ authorization-server metadata; the parsed result carries `authorization_server` and
+ `scopes_supported`; a 404 at every well-known URL raises a typed discovery error; a
+ server whose protected-resource metadata lives only at an unguessable path, named by a
+ 401's `WWW-Authenticate: resource_metadata=...` header (RFC 9728), is discovered via that
+ header — the case OD21 closed, verified to fail against the well-known-only ordering
+ before the fix and pass after; a 401 with no header, a header with no
+ `resource_metadata`, and a header URL that itself 404s all fall back to the well-known
+ chain rather than failing outright.
+- `MCPOAuthClient` registration: no stored `client_info` -> a registration POST fires and the
+ response is stored; a stored `client_info` -> no registration POST fires.
+- `MCPOAuthConnectService.begin()`: returns an `authorization_url` containing the fixed
+ `redirect_uri`, a `code_challenge`, and the requested `scope`; the returned `state` decodes to
+ the right `project_id`/`server_url`/`code_verifier`.
+- `MCPOAuthConnectService.complete()`: valid code+state against the mock token endpoint writes an
+ `oauth_grant` secret and returns its id; a tampered or expired state raises before any HTTP call
+ is made; a token-endpoint error response raises a typed exception rather than propagating an
+ `httpx` exception.
+- Guard: no test in this package's suite ever constructs `mcp.client.auth.oauth2.OAuthClientProvider`
+ — a regression test asserting that class is unused would be redundant with "count the
+ imports", so this is enforced by review rather than a grep guard (unlike WP24's
+ `json.loads`-guard precedent, there is no single string this package could regress into).
+
+## Out of scope
+
+- The dashboard consent UI and its two routes (`POST /endpoints/{endpoint_id}/connect`,
+ `GET /connect/callback`) — WP18 builds them against `MCPOAuthConnectService`.
+- The step-up interaction itself (raising `MCPScopeInsufficientError` from a live 403) — WP19.
+- The client-identity-document registration mechanism and the internal-only-domain fallback
+ ordering — WP20. This package always uses outbound dynamic registration.
+- Any `builtin`/Composio path — out of this package's target namespace entirely, not merely
+ unimplemented.
+- A stored-nonce replay ledger for the state token — flagged above, not built.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp18.md b/docs/design/gateways-research/v1/workstreams/specs-wp18.md
new file mode 100644
index 0000000000..8b67e98d02
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp18.md
@@ -0,0 +1,243 @@
+# WP18 — Consent flow
+
+**Owns:** the two routes entities.md §9 reserved for this package on
+`MCPGatewayRouter` (`POST /endpoints/{endpoint_id}/connect`, `GET /connect/callback`),
+and the dashboard surface that drives them. Consumes WP17's `MCPOAuthConnectService`
+(`discover()` / `begin()` / `complete()`) without modification.
+
+**Target: the `custom` namespace only**, same as WP17. `builtin` is Composio-brokered
+(D30) and never reaches this flow — the router rejects a connect attempt on any
+endpoint whose `namespace` is not `custom` or whose `auth_mode` is not `oauth`.
+
+**Inherited constraint (OD21, landed on `feat/gateways-wp17` after this branch's
+branch point — not this package's code to fix).** WP17's `discover()` finds an
+authorization server by guessing well-known URIs only; it does not read the
+`WWW-Authenticate` header of a 401 the way RFC 9728 additionally allows, so a server
+that publishes its protected-resource metadata somewhere else cannot be discovered
+today. This package does not build header-first discovery. What it does: a discovery
+failure surfaces to the dashboard as "we could not discover this server's OAuth
+configuration at ``" (the `MCPOAuthDiscoveryError` message, passed through
+verbatim) rather than a generic "connect failed" — so a user hitting this gap is told
+what actually happened instead of guessing.
+
+---
+
+## The two routes
+
+### `POST /gateways/mcps/endpoints/{endpoint_id}/connect`
+
+One route, two steps, disambiguated by whether `scopes` is present — because
+`begin()` needs a scope list the user has not chosen yet the first time this route is
+called, and entities.md §9 reserves exactly one route here, not two.
+
+**Step 1 — discover (no `scopes` in the body, or `scopes: null`).**
+
+```
+POST /endpoints/{endpoint_id}/connect
+{}
+```
+
+Resolves `endpoint_id` → the endpoint row → `server_url =
+endpoint.data.route.base_url`, calls `MCPOAuthConnectService.discover(server_url)`,
+caches the result onto `endpoint.data.oauth` via `edit_endpoint` (the same door every
+other field uses — WP17's own rule, extended to this field), and returns the scope
+checklist:
+
+```json
+{"count": 1, "scopes_offered": ["read", "write", "admin"]}
+```
+
+No `redirect_url` in this response — nothing has started yet. This is the call the
+dashboard's scope-selection dialog makes when it opens, to render the checkboxes.
+
+**Step 2 — begin (`scopes` present, a list — empty is a legal choice: "connect with no
+scopes").**
+
+```
+POST /endpoints/{endpoint_id}/connect
+{"scopes": ["read", "write"]}
+```
+
+Calls `MCPOAuthConnectService.begin(project_id, user_id, server_url, scopes)` and
+returns the authorization URL to send the browser to:
+
+```json
+{"count": 1, "redirect_url": "https://auth.acme.io/authorize?..."}
+```
+
+Both steps require `EDIT_MCP_ENDPOINTS` (this mutates the endpoint's cached OAuth
+metadata and, in step 2, kicks off a grant). Both 404 when the endpoint does not exist,
+and 400 when it exists but is not a `custom` `oauth` endpoint — a `none`/`api_key`
+target has nothing for this route to do, and `builtin` cannot appear here (D30).
+
+### `GET /gateways/mcps/connect/callback`
+
+The fixed redirect URI WP17's `callback_redirect_uri()` builds and registers with every
+authorization server (`{AGENTA_API_URL}/gateways/mcps/connect/callback`), disambiguated
+by `state`, never by a per-flow query string (WP17's "The callback URL, precisely").
+**Unauthenticated** — the browser lands here straight from the authorization server, not
+from an Agenta API call, so there is no `AuthScope` to read. Every fact this handler
+needs — `project_id`, `user_id`, `server_url` — comes out of the signed `state` the
+authorization server echoes back untouched, the same shape
+`apis/fastapi/tools/router.py::callback_connection` already uses for the Composio
+callback (this package's design leans on that precedent directly).
+
+Query params: `code`, `state` on success; `error`/`error_description` when the user
+denies consent or the authorization server refuses (RFC 6749 §4.1.2.1).
+
+On success:
+
+1. `MCPOAuthConnectService.complete(code, state)` → `{project_id, server_url,
+ secret_id}` (WP17's contract — it writes the `oauth_grant` secret and stops there;
+ it deliberately never touches an `MCPEndpoint` row and takes no `endpoint_id`,
+ "keeping it ignorant of the `mcps_endpoints` table").
+2. This package decodes the same `state` a second time (`oauth/state.py::decode_state`,
+ already validated once inside `complete()` — the second decode is read-only, to
+ recover `user_id`, which `MCPOAuthCompletion` does not carry and `edit_endpoint`
+ requires for its audit column) to get `user_id`.
+3. Resolves `endpoint_id`: lists this project's `custom` endpoints
+ (`MCPGatewayService.query_endpoints`, not the builtin-merged `list_endpoints`) and
+ matches `data.route.base_url == server_url` — the same list-and-filter idiom WP17's
+ `SecretsTokenStorage` uses for the vault, because there is no second way to look up
+ "the endpoint for this server" and nothing in this design adds a DAO method for it.
+4. `edit_endpoint(secret_id=secret_id)` — the one door.
+5. Answers a small self-contained HTML page (`text/html`), not JSON: a browser lands
+ here directly, not a JSON client. Modelled on the Composio callback's own card
+ (`apis/fastapi/tools/router.py::_oauth_card`), trimmed to what this flow needs: a
+ success/failure message, and a `postMessage({type: "mcp:oauth:connected", ...},
+ agentaOrigin)` to `window.opener` so a dashboard that opened this in a popup can
+ react without polling — the same signal shape as `tools:oauth:complete`, one string
+ different, so the frontend idiom this package reuses (see below) transfers exactly.
+
+On failure (bad/expired state, no client registration on file, discovery or token
+exchange failure, or `error` present in the query) the same card renders with
+`success: false` and the underlying exception's own message — never a generic string —
+per the OD21 note above.
+
+---
+
+## Scope selection
+
+Selection happens client-side, between the two `POST /connect` calls, not on the
+server. The dashboard:
+
+1. Opens a connect dialog for an OAuth `custom` endpoint whose `secret_id` is not yet
+ set (or is set but the endpoint is in `NEEDS_AUTH` — same dialog, same two calls).
+2. Calls step 1 (empty body) to get `scopes_offered`.
+3. Renders one checkbox per offered scope (all pre-checked — "offer the set and let
+ them choose", D17 — unchecking is the deliberate action, not the default).
+4. On confirm, calls step 2 with the checked subset and opens `redirect_url` in a popup
+ window (falling back to a same-tab redirect when the popup is blocked — the existing
+ `ConnectDrawer.tsx` pattern for the Composio tool-catalog flow, reused verbatim
+ rather than reinvented).
+5. Listens for `postMessage({type: "mcp:oauth:connected"})` from the popup (falling back
+ to polling `popup.closed`, same as `ConnectDrawer.tsx`), then refetches the endpoint
+ list so the row shows connected.
+
+An empty selection is allowed through to step 2 unchanged — the dashboard does not
+force at least one scope, because a server that only needs identity (no scoped access)
+is a legitimate case and the gateway does not know better than the user what they need.
+
+---
+
+## The dashboard surface — genuinely new, named for WP26 to repoint at
+
+No page anywhere in the app registered a `custom` MCP server by URL before this
+package (verified: no reference to `MCPEndpoint`, `mcp_gateway`, or `gateways/mcps`
+existed under `web/` on this branch). This package adds one:
+
+- **Page:** `web/oss/src/components/pages/settings/MCPEndpoints/MCPEndpoints.tsx` —
+ list of the project's `custom` MCP endpoints (`GET /endpoints/`, filtered to
+ `namespace: custom` client-side — the same merged list the CRUD `GET` already
+ returns), a "Register server" action opening a create/edit drawer
+ (`MCPEndpointDrawer.tsx`, same folder: slug, name, `base_url`, `auth_mode`), and, on
+ a row whose `auth_mode` is `oauth` and `secret_id` is unset, a "Connect" action.
+- **Connect dialog:** `MCPConnectDialog.tsx` (same folder) — the two-step flow above.
+- **API calls:** `api.ts` (same folder), raw `axios` against
+ `oss/src/lib/api/assets/axiosConfig.ts`'s shared instance. **Deliberate, not an
+ oversight:** the Fern-generated client
+ (`web/packages/agenta-api-client/src/generated`) carries no MCP-gateway types at
+ all yet — this whole domain (WP6–WP20's routes) has not been through a Fern
+ regeneration pass on this branch. `agenta-package-practices`'s "never raw axios for
+ a new endpoint" rule assumes the generated client has the endpoint to call; here it
+ does not, so the fallback it names for exactly that gap applies. Swapping `api.ts`
+ for generated client calls is mechanical once that pass runs, and does not change
+ this package's route contracts or dialog logic.
+
+This is app-layer code, not a package: **placement follows the stated heuristic** in
+`agenta-package-practices` ("used by 2+ features, or could be?" → package; otherwise
+app layer). Nothing else in the app registers a custom MCP server today, so this stays
+in `web/oss/src/components/pages/settings/MCPEndpoints/` until a second consumer
+appears — mirroring where `Tools/`, `Webhooks/`, `Triggers/` already live, not
+`@agenta/entity-ui`'s `gatewayTool/` drawers (which earned package placement by having
+three real mount points: the playground, the playground's tool panel, and the Tools
+settings page).
+
+**What WP26 should repoint at.** WP26's `request_connection` tool extension found "no
+dashboard surface exists anywhere in the app for registering a custom MCP server by
+URL" and pointed its MCP landing affordance at the existing Composio tool-catalog
+drawer (`@agenta/entity-ui`'s `gatewayTool/drawers/CatalogDrawer.tsx`, opened via the
+`toolCatalogDrawerOpenAtom` atom) as a stopgap — the wrong catalog for a `custom`
+target, since that drawer browses Composio-brokered `builtin` integrations, not
+user-typed URLs. The real surface now exists:
+
+- **Route:** the settings page above, mounted wherever the settings nav registers
+ sibling pages (`Tools`, `Webhooks`, `Triggers`) — same nav entry pattern.
+- **Component to open for "register a server and connect it":**
+ `MCPEndpointDrawer` (create) chained into `MCPConnectDialog` (connect) — both
+ exported from `MCPEndpoints.tsx`'s folder. There is no shared atom to pop them open
+ from arbitrary call sites yet (this package's only consumer is the settings page
+ itself); WP26 either navigates to the settings page directly, or a follow-up promotes
+ these two components to `@agenta/entity-ui` with an open-state atom once WP26 is the
+ second consumer — the same promotion criterion `gatewayTool/` already met. That
+ promotion is WP26's call to make, not built speculatively here.
+
+---
+
+## What this package does not do
+
+- Does not modify `core/gateways/mcps/oauth/*` (WP17's files) — `MCPOAuthCompletion`
+ stays `{project_id, server_url, secret_id}`; the missing `user_id` is recovered by a
+ second, read-only `decode_state` call in this package's own router, not by widening
+ WP17's return type.
+- Does not add a `MCPEndpointsDAOInterface` method to look up an endpoint by
+ `base_url` — the callback's list-and-filter is the same "no new DAO method, scan in
+ Python" precedent `SecretsTokenStorage` already set (specs-wp17.md's "Keys").
+- Does not build header-first (`WWW-Authenticate`) discovery — OD21, explicitly out of
+ scope, inherited as a named limitation instead.
+- Does not touch the step-up interaction (WP19) or the client-registration fallback
+ (WP20) — both consume this package's routes/service unchanged.
+- Does not regenerate the Fern-generated web API client — `api.ts`'s raw-axios calls
+ are the stated, temporary stand-in until that pass runs.
+
+## Tests
+
+**Backend, unit only** — `httpx.MockTransport` standing in for the authorization
+server (WP17's own precedent, reused), a hand-written mock `MCPOAuthConnectService`
+and a hand-written mock `MCPGatewayService` for the router tests (matching
+`test_gateways_mcp_router.py`'s existing `MockMCPGatewayService` pattern) — no real
+network, no real MCP server, no real authorization server, no database.
+
+- Router, step 1 (discover): reaches `discover()`, writes `data.oauth` via
+ `edit_endpoint`, returns `scopes_offered`, no `redirect_url`.
+- Router, step 2 (begin): reaches `begin()` with the posted `scopes`, returns
+ `redirect_url` from `authorization_url`, no discovery-caching call this time.
+- Router: 404 on an unknown `endpoint_id`; 400 on a `none`/`api_key` or non-`custom`
+ endpoint; 403 when `EDIT_MCP_ENDPOINTS` is denied, before the service is touched.
+- Router: a `MCPOAuthDiscoveryError` from step 1 maps to the exception's own message
+ in the error body, not a generic string (the OD21-inherited-limitation contract).
+- Callback: valid code+state completes, resolves the right endpoint by `base_url`,
+ calls `edit_endpoint(secret_id=...)`, renders the success card with the
+ `mcp:oauth:connected` postMessage payload.
+- Callback: no endpoint matches the completed `server_url` → failure card, no
+ `edit_endpoint` call (nothing to PUT onto).
+- Callback: `error` query param present → failure card, `complete()` never called.
+- Callback: tampered/expired `state` → failure card carrying `MCPOAuthStateInvalidError`'s
+ message, `complete()` raises before any HTTP call (WP17's own guarantee, exercised
+ from this package's boundary).
+
+**Frontend, vitest.** `api.ts`'s axios calls (mock axios, assert method/path/body per
+step); `MCPConnectDialog`'s two-step state machine (discover → render checkboxes →
+begin → popup opens with the returned `redirect_url`) with `window.open` mocked;
+the `postMessage` listener resolving the dialog on `mcp:oauth:connected`, ignoring a
+message from an untrusted origin (mirrors `ConnectDrawer.tsx`'s own origin check).
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp19.md b/docs/design/gateways-research/v1/workstreams/specs-wp19.md
new file mode 100644
index 0000000000..92273d0e87
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp19.md
@@ -0,0 +1,194 @@
+# WP19 — Step-up interaction
+
+**Owns:** the scope-challenge detection in `core/gateways/mcps/service.py::relay`, the
+connect affordance `apis/fastapi/gateways/mcps/proxy.py` attaches to a step-up refusal, and
+the repoint of WP26's `useGatewayConnectFlow` MCP branch onto WP18's real connect surface.
+**Depends on:** WP17, WP18, WP25, WP26. **Blocks:** nothing (last package of wave 3).
+
+D17: "at step-up the gateway raises an interaction... the same situation as a tool needing
+a connection that does not exist yet, and that path already exists." This package makes
+that literally true — nothing before it ever raised a scope-challenge exception, and the
+interaction it raises reuses WP18's connect route and WP26's `request_connection` tool
+rather than building a third mechanism.
+
+---
+
+## Three inherited constraints, and how this package is shaped around them
+
+1. **Codex keeps only `error.message`.** WP25 built a code marker (`⟦agenta_code:⟧`)
+ that survives inside `message` on every harness; a marker-only recovery never carries
+ `retryable`/`next_step`/`details`. This package's own new cause, `scope_insufficient`, is
+ NOT added to the runner's `NEXT_STEPS` table (`services/runner/src/gateway-error.ts`) —
+ there is nothing generic to say ("grant more access" is the whole of it), and the MCP
+ plane never reaches the body path anyway (constraint 2), so a `NEXT_STEPS` entry would be
+ dead code for this cause specifically.
+2. **The MCP plane's marker is the only recovery channel, for every harness, always** — not
+ a fallback. `error.data.cause` sits under a numeric JSON-RPC `error.code`, which the
+ runner's body scan (`typeof body.code === "string"`) never matches. Consequence: the
+ `connect` affordance this package attaches to the JSON-RPC error's `error.data` (see
+ below) is real and tested at the boundary, but no harness's SDK ever hands it to the
+ runner — only `code` survives, always. The design does not pretend otherwise: nothing
+ downstream (the client-tool widget) reads `scopes` or `connect` off a recovered
+ `AgentErrorDetail`, because they are never there to read.
+3. **Discovery can fail on a server we cannot reach (OD21).** WP17 discovers an
+ authorization server by guessing well-known URIs only. WP18 already surfaces a discovery
+ failure as `MCPOAuthDiscoveryError`'s own message, verbatim, at
+ `POST /endpoints/{id}/connect` step 1 — never a generic "connect failed". Step-up reuses
+ that exact route unmodified, so it inherits the distinction for free; this package adds no
+ new discovery-failure handling and no new test for it (WP18's
+ `test_connect_discovery_failure_surfaces_its_own_message` already covers the only code
+ path step-up runs through).
+
+## The step-up prompt degrades to a generic form by construction, not by branching
+
+Constraint 2 means there is no "full envelope" shape for a step-up refusal to ever reach the
+agent runtime in — every MCP-plane recovery is code-only. So "the step-up prompt" cannot be
+built as something that reads `next_step`/`details` and falls back when they're absent (there
+is nothing to read either way). Instead: the surface the agent asks the user through
+(`request_connection`'s `target: {plane: "mcp", name: }`, WP26) never carried
+scope-specific fields to begin with, and this package does not add any. The dialog it opens
+(`MCPConnectDialog`, WP18) always re-runs `discover()` and re-renders the FULL current scope
+checklist — it does not need to know which scopes were missing, because the interaction is
+"pick what to grant", not "grant exactly X". A model that sees only `code: scope_insufficient`
+has everything it needs to call `request_connection`; a model that somehow saw more (it never
+will, on this plane) would still get the same dialog. Generic by construction beats generic by
+an `if (!details)` branch with nothing on the other side of it.
+
+This is proven two ways:
+- **What the gateway itself constructs** (`_map_gateway_exception`'s `scope_insufficient`
+ branch, `apis/fastapi/gateways/mcps/proxy.py`): the JSON-RPC error's `data.connect`, when
+ `endpoint_id` is known, is `{endpoint: "/gateways/mcps/endpoints/{id}/connect", body: {}}`
+ — pointing at WP18's discover step, never at a computed scope list. Tested directly.
+- **What actually reaches a harness** (`services/runner/tests/unit/gateway-error-harness-formats.test.ts`):
+ `scope_insufficient` added to `MCP_REFUSALS`, run through both existing MCP fixtures (full
+ JSON-RPC body embedded verbatim, and Codex's stripped-to-`message` shape) — both recover
+ `code` only, `next_step`/`details` asserted absent, same as the four pre-existing MCP
+ causes. No new runner source change; this proves the existing generic mechanism already
+ covers the new cause without modification.
+
+## Backend: detecting the challenge
+
+`MCPGatewayService.relay` (`core/gateways/mcps/service.py`), after the upstream call returns
+(step 5, before step 6's recording/filtering): for a `custom` OAuth endpoint whose result is
+`403`, `_parse_scope_challenge(result.headers)` reads `WWW-Authenticate` for an RFC 6750
+`Bearer error="insufficient_scope"[, scope="..."]` challenge.
+
+- `None` (no challenge, or a different `error=`) → the result passes through untouched (D16:
+ the upstream's own protocol-level answer, e.g. a plain `invalid_token` rejection, is not a
+ gateway-authored refusal).
+- A challenge with no `scope` param → `[]`. WP18's dialog re-discovers the offered set either
+ way, so an upstream that names no specific scope still gets the same reopened checklist,
+ not a dead end.
+- A challenge with `scope="a b"` → `["a", "b"]`, carried on `MCPScopeInsufficientError` for
+ whoever reads the JSON-RPC body directly (constraint 2: nobody downstream of the marker
+ does, but the data is honestly there for a caller that can).
+
+Only `auth_mode == OAUTH` on `custom` is checked — a `none`-scheme endpoint has nothing to
+step up (nothing was ever granted), so its 403s (however shaped) are never reinterpreted.
+The outcome is recorded via `policy.record` before the exception leaves, matching the
+existing `MCPUpstreamError` precedent one branch up.
+
+`MCPScopeInsufficientError` (declared by the seed, unraised until this package) gained one
+optional field, `endpoint_id`, so the boundary can build a connect affordance without
+widening WP17's own construction of it (`target="t", scopes=["a"]` stays valid — proven by
+the untouched seed test).
+
+## Backend: the connect affordance
+
+`apis/fastapi/gateways/mcps/proxy.py::_map_gateway_exception`'s `scope_insufficient` branch
+now attaches `data.connect = {endpoint, body: {}}` when `endpoint_id` is present — the exact
+route WP18 built (`POST /endpoints/{id}/connect`), pointed at its discover step (empty body),
+never at a guessed scope list. This is additive: the existing `data.scopes`/`data.target`
+fields, the 409 status, and the code marker on `message` are unchanged.
+
+## Frontend: the repoint (secondary task, folded in because it IS the interaction path)
+
+WP26 pointed `useGatewayConnectFlow`'s `plane: "mcp"` branch at the Composio tool-catalog
+drawer as a stopgap, because no `custom` registration surface existed yet. WP18 built one
+(`MCPEndpointDrawer`, `MCPConnectDialog`, `web/oss/src/components/pages/settings/MCPEndpoints/`).
+This is the first branch where both exist, so the repoint lands here rather than being
+deferred again.
+
+**What changed** (`useGatewayConnectFlow.ts`, `GatewayConnectToolWidget.tsx`):
+- `resolveCustomMcpEndpoint(endpoints, target)` — a `target.name` that matches a registered
+ `custom` endpoint's slug resolves to that `MCPEndpoint`; otherwise `null`.
+- `runConnect`: `plane: "mcp"` with a resolved `custom` endpoint opens `MCPConnectDialog` for
+ it. No match (a `builtin`/Composio target — still a legitimate case per specs-wp26.md, not
+ the stopgap) falls back to the shared catalog drawer, unchanged from WP26.
+- Settle semantics for the `custom` path are now REAL, not optimistic: `onSuccess` (the
+ dialog's own postMessage-verified completion, `mcp:oauth:connected`) settles
+ `{connected: true}`; closing without success (discovery failure or an in-dialog cancel)
+ settles `{connected: false, reason: "cancelled"}`. An explicit "Not now" before the dialog
+ opens still settles `{connected: false, reason: "declined"}` — distinct from "cancelled",
+ so a user who saw the dialog and backed out reads differently from one who never engaged.
+- The `builtin` fallback path is untouched: still optimistic-on-close, still documented as
+ such in the module doc, because the shared globally-mounted catalog drawer still has no
+ per-call completion signal to read. The stopgap is dropped only where a real signal now
+ exists (`custom`), not universally — dropping it for `builtin` too would require a signal
+ that does not exist yet, which is a different package's job (see specs-wp26.md's own "not
+ built speculatively here").
+
+**Why this is the correct home for step-up's frontend half.** D17 says step-up reuses the
+existing missing-connection interaction, and WP26's `request_connection` tool IS that
+interaction for a gateway target. Nothing about step-up needs a distinct widget: the same
+"Connect {name}" chip, opened for the same slug, drives the same dialog — which happens to
+re-offer a wider scope set than last time because that's what `discover()` returns after a
+prior grant exists. No new render kind, no new client tool, no new wire field.
+
+## Grant rotation, not duplication
+
+Already proven at WP17's own layer:
+`test_gateways_mcp_oauth_service.py::test_step_up_reuses_the_same_grant_row_rather_than_creating_a_second_one`
+calls `begin()`/`complete()` twice for the same `server_url` with a widening scope list and
+asserts one `oauth_grant` row, `update_secret` not a second `create_secret`. This package
+does not re-derive that test — the connect affordance above points at the exact same
+`begin()`/`complete()` pair, so the proof already carries over.
+
+## Discovery failure vs. decline vs. code-only refusal — three distinct reads
+
+- **Decline** (`reason: "declined"`): the user never opened the dialog. Chip: "Connection not
+ completed" territory, distinguishable in the settled output.
+- **Discovery failure**: the dialog opens, `MCPConnectDialog` shows
+ `MCPOAuthDiscoveryError`'s own message inline (unchanged WP18 behavior — this package
+ reuses the component verbatim), and closing after it settles `{reason: "cancelled"}`. The
+ specific wording was already shown to the user before the settle; the settled reason does
+ not need to repeat it (mirrors WP18's own choice not to thread it further).
+- **Code-only step-up refusal reaching the agent**: no wording is ever invented — the model
+ gets `code: scope_insufficient` and nothing else, and the widget it can open never claims
+ to know more than "this needs a connection".
+
+## Tests
+
+- `api/oss/tests/pytest/unit/gateways/test_gateways_mcp_service.py`: scope challenge with a
+ `scope=` param raises `MCPScopeInsufficientError` carrying the parsed list and
+ `endpoint_id`; without `scope=` raises with `[]`; a 403 with a different `error=` (or none)
+ passes through untouched (D16); a `none`-scheme endpoint's 403 is never reinterpreted.
+- `api/oss/tests/pytest/unit/gateways/test_gateways_mcp_proxy.py`: `scope_insufficient` with
+ no `endpoint_id` carries no `connect` key (WP17's construction stays valid); with
+ `endpoint_id` carries `data.connect = {endpoint, body: {}}`.
+- `services/runner/tests/unit/gateway-error-harness-formats.test.ts`: `scope_insufficient`
+ added to the MCP fixture table, both shapes (full JSON-RPC body, Codex-stripped) — proves
+ the existing generic marker mechanism covers this new cause with no runner source change.
+- `web/oss/src/components/AgentChatSlice/components/clientTools/useGatewayConnectFlow.test.ts`:
+ `resolveCustomMcpEndpoint` — matches a `custom` endpoint by slug; `null` for a namespace
+ mismatch, an llm-plane target, or no match at all (the `builtin` fallback case).
+- Grant rotation: not re-derived — see above; referenced, not duplicated.
+- Discovery failure distinctness: not re-derived — `test_connect_discovery_failure_surfaces_its_own_message`
+ (`test_gateways_mcp_router.py`, WP18) already covers the only code path step-up runs
+ through.
+
+## Out of scope
+
+- Header-first (`WWW-Authenticate`) authorization-server *discovery* (OD21) — different
+ mechanism from the scope-challenge header this package reads; still not built.
+- `MCPAuthRequiredError` (a token that is entirely absent or rejected, not merely
+ under-scoped) — still declared, still unraised; the "no grant at all" case is already
+ caught pre-flight by `SecretNotFoundError` in `_resolve_auth` before any upstream call, so
+ there is no live path to this exception yet, and wiring one is not this package's stated
+ scope ("a scope challenge from an MCP server").
+- A new `AgentErrorDetail` consumer in the web app (a generic "run failed, here's why" panel)
+ — nothing in the codebase builds this today for any cause, gateway or otherwise; WP19 does
+ not introduce the first one under cover of step-up. The channel WP25 built and the tool
+ WP26 built are sufficient for the interaction this package needs.
+- Any LLM-plane equivalent — models have no OAuth scope concept in this design; step-up is
+ MCP-only, matching WP17/WP18's own `custom`-namespace scope.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp2.md b/docs/design/gateways-research/v1/workstreams/specs-wp2.md
new file mode 100644
index 0000000000..27a00a803d
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp2.md
@@ -0,0 +1,376 @@
+# WP2 — Secret resolution
+
+Delivers `SecretsResolver`, the one class both gateways call to turn a `SecretRef`
+into a `(secret, owner, payer)` triple. Pure logic wrapped around one existing service —
+`VaultService` — so nothing here talks to Postgres directly. Owns
+`core/gateways/policy/resolution.py` only.
+
+This is the signature `plan.md` calls out as the one thing the seed had to get right:
+`resolve()` takes the owner from the outset (D10) even though today only the project
+answers. WP2 fills in behavior; it does not touch the signature — that is frozen by the
+seed commit in `core/gateways/policy/interfaces.py`.
+
+## What this is NOT
+
+- **Not the vault.** `VaultService` (`core/secrets/services.py`) already exists; WP2
+ composes it, never reimplements encryption or storage.
+- **Not the permission or entitlement check.** `authorize()` on
+ `GatewayPolicyService` is WP3's; WP2's `resolve()` is called only *after* WP3 (or a
+ caller mimicking it) has already decided the call is allowed. `resolve()` never checks
+ a permission and never raises `PolicyDeniedError`.
+- **Not the brokered (`builtin`, MCP) path.** A Composio-backed MCP endpoint's secret
+ lives at the broker and never enters the vault — `MCPBrokeredAuth` carries the
+ `gateway_connections` row directly, never `ResolvedSecret`. `resolve()` is never
+ called for that namespace; routing around it with a third `SecretRef` arm was
+ rejected in `entities.md` §7.2 and must not be reintroduced here.
+- **Not the OAuth client.** An OAuth MCP endpoint's secret is a `BoundSecretRef` like any
+ other — `resolve()` only reads it; it never mints, refreshes, or exchanges a token. That
+ is WP17.
+- **Not the two new secret kinds.** `oauth_provider`/`oauth_grant` (WP16) do not exist yet
+ when this package lands (wave 1, before C2). Once WP16+WP17 land, an
+ `oauth_grant` secret is just another vault row a `BoundSecretRef` can point at —
+ `resolve()` needs no change to reach it. Nothing in WP2 blocks on WP16.
+
+## Files
+
+New:
+- `api/oss/src/core/gateways/policy/resolution.py` — `SecretsResolver`, implementing
+ `SecretsResolverInterface` (seed-owned, `core/gateways/policy/interfaces.py` —
+ imported, never edited).
+
+Edited: none. WP2 adds one construction line to `api/entrypoints/routers.py` at the IM1
+merge (below); it does not commit that file.
+
+## Interface (reproduce verbatim, seed-owned)
+
+From `core/gateways/policy/interfaces.py` (`entities.md` §7.2):
+
+```python
+class SecretsResolverInterface(ABC):
+ """One lookup, called by both planes. Mockable (D23): the mock resolver
+ answers from a dict and never touches the vault."""
+
+ @abstractmethod
+ async def resolve(
+ self,
+ *,
+ scope: AuthScope,
+ #
+ ref: SecretRef,
+ mode: SecretMode,
+ ) -> ResolvedSecret:
+ """Resolve one secret for one call.
+
+ The mode logic, in full (secrets.md):
+ PROJECT_ONLY -> the project secret; SecretNotFoundError(PROJECT) if absent.
+ USER_REQUIRED -> the (project, user) secret; SecretNotFoundError(USER)
+ if absent — NEVER falls back.
+ USER_OPTIONAL -> the (project, user) secret if present, else the
+ project's; SecretNotFoundError(USER) naming the
+ narrower owner if neither exists.
+
+ Until user-owned secrets ship, the user arm of every mode finds nothing
+ and the modes degrade to project lookup or failure — behaviourally
+ today's world, with the signature already right.
+
+ By ref arm:
+ ProviderKeyRef -> scan the project's provider_key / custom_provider
+ secrets for the provider, as the SDK's settings
+ builder does today (models.md).
+ BoundSecretRef -> VaultService.get_secret_by_id, scoped to the project.
+ Also how an OAuth MCP endpoint resolves its secret:
+ the caller passes BoundSecretRef(secret_id=
+ endpoint.secret_id) at mode=PROJECT_ONLY.
+
+ Raises, never returns None: no path silently yields "no secret",
+ and the exceptions carry which owner is missing so the boundary can
+ build the connect affordance."""
+ ...
+
+ @abstractmethod
+ async def available_provider_keys(self, *, scope: AuthScope) -> Set[str]:
+ """Provider keys with a resolvable project-owned secret. Names only,
+ never a value — an existence test that must not read a secret."""
+ ...
+```
+
+**The second method is R2's ruling, added at kickoff.** D20 makes a generated `builtin`
+endpoint exist for a project exactly when a provider key exists for it, so
+`LLMGatewayService.list_endpoints` (WP7) needs to ask that question — and it has no vault
+dependency, by design. Handing the service a `VaultService` would give it two secret
+seams and defeat the port; calling `resolve()` once per provider and catching
+`SecretNotFoundError` is control flow by exception plus eleven vault reads per list.
+Existence of a secret is a secret-layer question, so it belongs on the secret
+port.
+
+Implement it over the same scan `ProviderKeyRef` uses — the project's `provider_key` and
+`custom_provider` secrets — returning the set of provider names found. It returns names,
+never secret values, and it never raises for "none found": the empty set is the correct
+answer, unlike `resolve()`, which raises because a caller asking to resolve has already
+committed to needing one.
+
+## DTOs used (reproduce verbatim, seed-owned — `core/gateways/policy/dtos.py`)
+
+```python
+class SecretMode(str, Enum):
+ USER_OPTIONAL = "user_optional"
+ USER_REQUIRED = "user_required"
+ PROJECT_ONLY = "project_only"
+
+class SecretOwnerKind(str, Enum):
+ PROJECT = "project"
+ USER = "user"
+
+class SecretOwner(BaseModel):
+ kind: SecretOwnerKind
+ user_id: Optional[UUID] = None # set exactly when kind is USER
+
+class SecretOrigin(str, Enum):
+ VAULT = "vault"
+ LOCAL = "local"
+
+class ProviderKeyRef(BaseModel):
+ provider_key: str
+
+class BoundSecretRef(BaseModel):
+ secret_id: UUID
+
+SecretRef = Union[ProviderKeyRef, BoundSecretRef]
+
+class ResolvedSecret(BaseModel):
+ secret: SecretResponseDTO # decrypted, from VaultService
+ owner: SecretOwner
+ origin: SecretOrigin
+```
+
+`SecretResponseDTO` is `core/secrets/dtos.py`'s existing response type — WP2 imports it,
+never redefines it. `origin` is currently always `SecretOrigin.VAULT` for every path
+`resolve()` can reach in this scope: nothing in wave 1 has a `LOCAL`-origin secret to
+return (that distinction belongs to the parallel bring-your-own-secrets work, `secrets.md`
+§"secret_origin"). Set it to `VAULT` unconditionally; do not invent a `LOCAL` branch.
+
+## Exceptions used (reproduce verbatim, seed-owned — `core/gateways/policy/types.py`)
+
+```python
+class SecretNotFoundError(GatewaysError):
+ def __init__(self, *, mode: SecretMode, missing: SecretOwnerKind, target: str): ...
+
+class SecretInvalidError(GatewaysError):
+ def __init__(self, *, target: str, detail: Optional[str] = None): ...
+```
+
+`target` is a caller-supplied string identifying what was being resolved for — WP2 does
+not have a `GatewayTarget` in `resolve()`'s signature, so it builds this string itself
+from the `SecretRef` it was given (e.g. `f"provider:{ref.provider_key}"`,
+`f"secret:{ref.secret_id}"`). This is not named anywhere
+in `entities.md` beyond "target" as a parameter name on the exception constructors — pick
+a stable, greppable format per ref arm and keep it consistent across both.
+
+## Implementation, by ref arm
+
+### `BoundSecretRef` — custom endpoints, and OAuth MCP endpoints
+
+The simple case. `mode` still governs owner selection even though a bound secret has no
+owner axis of its own today — `VaultService.get_secret_by_id` takes only
+`project_id`/`organization_id`, so in this scope the mode parameter is honored for
+consistency (the signature promise) rather than because it changes behavior yet:
+
+```python
+secret = await self.vault_service.get_secret_by_id(
+ ref.secret_id, project_id=scope.project_id,
+)
+if secret is None:
+ raise SecretNotFoundError(
+ mode=mode, missing=SecretOwnerKind.PROJECT, target=f"secret:{ref.secret_id}",
+ )
+return ResolvedSecret(
+ secret=secret,
+ owner=SecretOwner(kind=SecretOwnerKind.PROJECT),
+ origin=SecretOrigin.VAULT,
+)
+```
+
+Note `get_secret_by_id`'s real signature is `get_secret_by_id(self, secret_id: UUID,
+project_id: UUID | None = None, organization_id: UUID | None = None)` —
+`secret_id` is positional in `VaultService`, not keyword-only. Call it positionally; do
+not assume `VaultService`'s own convention matches the gateways domain's keyword-only
+house rule, because it predates it.
+
+An OAuth MCP endpoint resolves through this same branch: WP9 builds
+`BoundSecretRef(secret_id=endpoint.secret_id)` and calls `resolve()` at
+`mode=SecretMode.PROJECT_ONLY` — the endpoint's `secret_id` column is the project-level
+answer, so there is nothing endpoint- or OAuth-specific for this package to know.
+
+**Every call into `VaultService` must be wrapped in `set_data_encryption_key`
+(`core/secrets/context.py`)** — the underlying DAO raises `ValueError` without it
+(`get_data_encryption_key()`'s explicit check). `VaultService`'s own public methods
+already open this context internally (see `services.py::get_secret_by_id`), so
+`SecretsResolver` does **not** need to open it a second time around
+`vault_service.get_secret_by_id(...)` — confirm this against `core/secrets/services.py`
+before assuming otherwise; wrapping twice is harmless (the context manager nests) but
+redundant, and *not* wrapping when calling `secrets_dao` directly (WP2 must not do this —
+it only calls through `VaultService`) would raise.
+
+### `ProviderKeyRef` — standard LLM endpoints
+
+No column indexes "the provider's key" — this is a scan, matching what the SDK's
+provider-settings builder already does client-side. **Precedent to study before writing
+this branch:** `sdks/python/agenta/sdk/agents/platform/connections.py` —
+`_provider_key_candidate()` (a `provider_key`-kind secret is identified by
+`data.kind == provider` with the key itself at `settings.key`, i.e. `SecretDTO(kind=
+PROVIDER_KEY, data=StandardProviderDTO(kind=, provider=StandardProviderSettingsDTO(key=...)))`)
+and `_custom_provider_candidate()` (a `custom_provider`-kind secret matches when its
+`data.kind` — a `CustomProviderKind` — equals the target provider). WP2 replicates the
+*matching* rule these two functions encode, over `VaultService.list_secrets`, not the
+whole candidate-selection/priority machinery in that file (which also handles model
+allowlists, endpoints and env vars — out of scope for a secret lookup):
+
+```python
+secrets = await self.vault_service.list_secrets(project_id=scope.project_id)
+match = next(
+ (s for s in secrets if s.kind == SecretKind.PROVIDER_KEY
+ and s.data.kind == ref.provider_key), None,
+) or next(
+ (s for s in secrets if s.kind == SecretKind.CUSTOM_PROVIDER
+ and s.data.kind == ref.provider_key), None,
+)
+if match is None:
+ raise SecretNotFoundError(
+ mode=mode, missing=SecretOwnerKind.PROJECT,
+ target=f"provider:{ref.provider_key}",
+ )
+```
+
+`provider_key`-kind secrets take priority over `custom_provider`-kind matches when both
+exist for the same provider — this mirrors `_catalog`'s ordering in the cited module
+(provider_key candidates are the "standard" match; custom_provider is the fallback for a
+reseller or self-hosted deployment claiming the same provider family). If two secrets of
+the *same* kind match the same provider, behavior is undefined upstream too (the SDK
+picks by list order); do not invent a tie-break rule beyond "first match" — flag it if a
+reviewer wants one, do not silently add priority logic not present in the cited
+precedent.
+
+## The mode table, written out in full (do not abbreviate in code)
+
+For **every** ref arm, the same three-way branch on `mode` (`secrets.md`, `entities.md`
+§7.2):
+
+| `mode` | behavior | on failure |
+| --- | --- | --- |
+| `PROJECT_ONLY` | look up the project-owned secret only; never consult `scope.user_id` | `SecretNotFoundError(mode=PROJECT_ONLY, missing=PROJECT, target=...)` |
+| `USER_REQUIRED` | look up `(project, scope.user_id)` only; **never** fall back to the project's | `SecretNotFoundError(mode=USER_REQUIRED, missing=USER, target=...)` |
+| `USER_OPTIONAL` | look up `(project, scope.user_id)`; if absent, look up the project's | `SecretNotFoundError(mode=USER_OPTIONAL, missing=USER, target=...)` — names the **narrower** owner even though the project lookup was also tried |
+
+For `BoundSecretRef` and `ProviderKeyRef` there is no `(project, user)`
+lookup to perform yet — no owner column exists on a bound-secret or provider-key lookup
+until user-owned secrets ship (`../out-of-scope.md`). So for both ref arms all three modes
+currently degrade to the same project-only lookup **behaviorally**, but the branch must
+still be written for all three modes explicitly (not collapsed into a single code path)
+so the day a user-owned vault row exists, only the per-arm lookup changes and the mode
+dispatch does not move. This is D10's entire point, applied at the one seam that will
+actually change: write the `if mode == SecretMode.USER_REQUIRED: ...` branches now
+even though today they read from a table with no user-owned rows.
+
+## Contracts this package must honour
+
+- **Never returns `None`.** Every failure path raises `SecretNotFoundError` or
+ `SecretInvalidError`; a bare `return None` or silently constructing a
+ `ResolvedSecret` with an empty secret is the exact failure `secrets.md` names as
+ disallowed ("failure is never silent and never a fallback to 'no secret'").
+- **`USER_REQUIRED` never falls back**, on any ref arm. A implementation that tries the
+ project secret "just in case" after a `USER_REQUIRED` miss is a silent privilege
+ escalation risk (an agent could act as the organization when it should have failed) —
+ this is the one rule in this package most worth a dedicated test per ref arm.
+- **The exceptions name which owner is missing**, not just that resolution failed —
+ `missing=SecretOwnerKind.USER` vs `.PROJECT` is what lets the boundary (later
+ packages) build `needs_auth` for "you must connect" versus an administrator-facing
+ message for "the project has no key." Getting this backwards silently degrades the UX
+ without failing any test that only checks "an exception was raised."
+- **`builtin`-namespace MCP targets never call `resolve()`.** If a future caller passes a
+ `BoundSecretRef` for a builtin (brokered) endpoint, that is a caller bug (§4.4, D27) —
+ `resolve()` has no endpoint-namespace context in any ref arm and is not expected to
+ detect this; the namespace check is the caller's responsibility (WP9's service), not
+ this package's.
+- **Constructor takes `vault_service` by keyword**, matching the
+ entrypoint wiring in `entities.md` §9: `SecretsResolver(vault_service=vault_service)`.
+ This exact call is the only place this constructor's shape is written down in the
+ design; treat it as authoritative.
+
+## Tests
+
+**Unit (no services running, run now).** This is the point of the spec's framing —
+`resolve()` is pure orchestration over one port, trivially mockable, so every case
+below runs with a dict-backed mock `VaultService`, no Postgres, no encryption key:
+
+`api/oss/tests/pytest/unit/gateways/test_gateways_resolution.py`
+
+- `BoundSecretRef`, secret exists → `ResolvedSecret` with
+ `owner.kind == PROJECT`, `origin == VAULT`.
+- `BoundSecretRef`, secret does not exist (any mode) → `SecretNotFoundError` with
+ `missing == PROJECT`.
+- `ProviderKeyRef`, a `provider_key`-kind secret matches → resolves it.
+- `ProviderKeyRef`, no `provider_key`-kind match but a `custom_provider`-kind match
+ exists → resolves the `custom_provider` one (fallback order).
+- `ProviderKeyRef`, both kinds match the same provider → resolves the `provider_key`-kind
+ one (priority order).
+- `ProviderKeyRef`, no match of either kind → `SecretNotFoundError` with
+ `missing == PROJECT`.
+- `BoundSecretRef` at each of the three `SecretMode` values, secret exists → resolves it
+ with `owner.kind == PROJECT` in every case (no ref arm has a live per-user secret in
+ this scope, §"mode table" above) — the test that catches a mode dispatch that was
+ collapsed into a single code path instead of written out per §"mode table".
+- Every `SecretNotFoundError` raised across the cases above carries a `target` string
+ that is non-empty and reproducible from the input `ref` (assert the format is stable,
+ not just present).
+
+**Integration:** none required for this package specifically — `VaultService` is fully
+mock in the unit suite above, and there is no direct database or Redis touch anywhere in
+`resolution.py`. If a reviewer wants one end-to-end sanity check against a real
+`VaultService`, it belongs in a cross-package integration suite, not in this package's
+own test file.
+
+## `api/entrypoints/routers.py` diff (apply at the IM1 merge)
+
+```python
+from oss.src.core.gateways.policy.resolution import SecretsResolver
+
+secret_resolver = SecretsResolver(vault_service=vault_service)
+```
+
+(`entities.md` §9 wiring block.)
+
+## Checkpoint
+
+Feeds **IM1**, then **C1** through WP6 and WP8 (both call `resolve()` on the
+relay path).
+
+Exit condition, verbatim from `plan.md`: *"each resolution mode behaves as specified and
+no path silently returns no secret."*
+
+WP2 is done when: every case in the Tests section above passes; grep over
+`resolution.py` confirms every `return` statement either returns a `ResolvedSecret`
+or is unreachable, and every early-exit path is a `raise`; and a `USER_REQUIRED` lookup
+with only a project-owned secret present raises rather than resolving, verified by
+code-path inspection on both ref arms (behaviorally inert today, but present, since
+neither arm has a live per-user secret in this scope).
+
+## Out of scope
+
+- `core/gateways/policy/service.py` (`GatewayPolicyService.authorize`, `.record`) — WP3.
+- The OAuth client and token refresh that mint the `oauth_grant` secret an MCP endpoint's
+ `secret_id` eventually points at — WP17.
+- `VaultService` itself and the two new secret kinds — WP16 adds the kinds; `services.py`
+ is pre-existing and not touched.
+- A third `SecretRef` arm narrowing resolution to a per-user row — removed from scope,
+ see `../out-of-scope.md`.
+
+## Missing from the design, needs a ruling
+
+- **The tie-break when two secrets of the same kind match the same provider under
+ `ProviderKeyRef`.** `entities.md` and the cited SDK precedent both leave this
+ undefined (the precedent resolves it by list order via `VaultService.list_secrets`'s
+ return order, which is not documented as stable). Not blocking — "first match in
+ return order" is a reasonable default and matches existing client-side behavior — but
+ it is not written down anywhere as a deliberate choice, and a future admin UI that lets
+ a project hold two `provider_key` secrets for one provider would need this resolved
+ properly rather than inherited by accident.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp20.md b/docs/design/gateways-research/v1/workstreams/specs-wp20.md
new file mode 100644
index 0000000000..bb6bc3954f
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp20.md
@@ -0,0 +1,272 @@
+# WP20 — Client registration fallback
+
+**Owns:** `core/gateways/mcps/oauth/registration.py`, the client-identity-document route
+(`apis/fastapi/gateways/mcps/oauth_router.py`), and the strategy branch inside
+`MCPOAuthConnectService._resolve_client_info` (specs-wp17.md). Nothing outside that.
+
+**Depends on:** WP17. There is no callback-reachability work in this package — D26
+already settled that the browser reaches the redirect in every deployment, because it is
+the address the user is already on. What is left is registration: WP17 always registers
+outbound (RFC 7591); this package adds the client-identity-document mechanism in front of
+it and decides automatically which one runs, per connect attempt, with no configuration
+flag.
+
+**Done when:** a deployment on an internal-only domain completes a full authorization
+with no hosted component of ours in the path. It already does — WP17's outbound path was
+always internal-only-safe; this package's job is to prefer the newer mechanism on a
+deployment that can actually use it, without breaking the one that can't.
+
+---
+
+## The two mechanisms, and why they cannot be tried the same way
+
+**Outbound (WP17, unchanged).** We `POST` our own metadata to the authorization server's
+`registration_endpoint` and get a `client_id`/`client_secret` back, synchronously, in a
+response we read. If it fails, we see the failure — a non-2xx response or a connection
+error — and `MCPOAuthRegistrationError` already carries that (specs-wp17.md).
+
+**The client identity document.** Our `client_id` is an HTTPS URL on our own domain
+(`{AGENTA_API_URL}/gateways/mcps/oauth/client-metadata.json`); the authorization server
+fetches it — not from us, and not synchronously to any request we make. It fetches it
+while rendering the consent screen, after we have already redirected the user's browser
+away from us. There is no response body of that fetch for us to read: if it fails, the
+authorization server aborts the flow **on its own error page**, and the browser never
+reaches our callback at all — not even with an error. We are not part of that failure. We
+cannot be, structurally: the redirect_uri the authorization server would use to tell us
+anything is itself a field *inside* the document it just failed to fetch.
+
+**This is why "attempt and fall back" does not work as a runtime decision.** There is no
+attempt whose outcome we can observe. By the time a failure would exist, we have already
+handed the user to another party's page and have nothing left to retry. A per-request
+try/except around the document mechanism has no exception to catch.
+
+**This is also why introspection is unreliable, not merely inconvenient.** The one thing
+that decides which mechanism is safe is whether *the authorization server*, reaching out
+from the public internet, can fetch our identity document. Any check we run happens on
+our own network, resolving our own name through our own path — which is exactly the
+question split-horizon DNS answers differently depending on who's asking. A server can
+always reach itself. That tells us nothing about whether anyone else can.
+
+## The detector
+
+Given both of the above are ruled out as *reliable* signals, the design accepts a
+signal that is directionally reliable rather than a signal that is always right, and
+biases it toward the failure mode that is recoverable.
+
+**The rule:** resolve `AGENTA_API_URL`'s hostname via DNS. Attempt the document only when
+the scheme is `https` and **every** resolved address classifies as public — not private,
+loopback, link-local, reserved, multicast, or unspecified (the same six-way
+classification `core/webhooks/utils.py`'s SSRF guard already uses for the opposite
+question, D28; not imported from there, because that guard's job is rejecting a URL a
+tenant handed us, and this one's is classifying our own configured domain — different
+domains, same primitive, reimplemented locally rather than reached into as a private
+helper). Any ambiguity — resolution fails, times out, answers empty, or answers with even
+one address that isn't public — answers "not resolvable."
+
+```python
+# core/gateways/mcps/oauth/registration.py
+def is_publicly_resolvable(api_url: str, *, resolve: Resolver = _default_resolve) -> bool:
+ ...
+ return all(_is_public_ip(ip) for ip in parsed_ips)
+```
+
+`all()`, not `any()`. A deployment that is multi-homed onto both a public and an internal
+address is exactly the shape that would otherwise slip through on `any()`, and it is
+indistinguishable at this layer from a deployment whose public-looking address is a NAT
+gateway with nothing listening on the other side.
+
+**Where the fact comes from is a real limitation, stated rather than hidden.** DNS
+resolvability is not reachability. A public IP can still be firewalled, and split-horizon
+DNS can still make our own resolution disagree with what the authorization server sees.
+This detector does not solve that; it narrows it, and the next section says exactly what
+happens when it's wrong.
+
+**Re-probed per connect attempt, not cached, not configured.** The check is a local DNS
+lookup — cheap, and already bounded by the existing "reuse a stored registration if one
+exists" branch below, so a working deployment only ever pays for it once per
+authorization server before that branch short-circuits it.
+
+## The strategy, in order
+
+`MCPOAuthConnectService._resolve_client_info()` (specs-wp17.md's seam — WP17 named this
+call exactly: *"today it always does RFC 7591 dynamic client registration outbound...
+WP20's job... is entirely about the other mechanism"*):
+
+1. **Reuse a stored outbound registration if this authorization server already has
+ one.** Unchanged from WP17. A working registration is never displaced by a later,
+ possibly different, detection result — stability over re-optimizing a connection that
+ already works. (The document mechanism is never stored — see below — so this branch
+ can only ever be true for a prior *outbound* registration.)
+2. **Else, if `is_publicly_resolvable(AGENTA_API_URL)`: use the client identity
+ document.** No registration call is made — the document mechanism has no
+ registration step at all. `client_id` is `client_metadata_url()`; there is no
+ `client_secret` (a public client, secured by PKCE + `redirect_uri` matching, per the
+ mechanism's own design — the identity document declares who we are, it does not prove
+ a secret).
+3. **Else, register outbound (WP17, unchanged)** and store the result exactly as before.
+
+Steps 2 and 3 are mutually exclusive per attempt; nothing here ever tries one after the
+other inside a single `begin()` call, because — as established above — there is nothing
+that would ever tell it to.
+
+## The document is static and deployment-wide, not per-project or per-server
+
+`client_metadata_document()` needs nothing from the connect attempt except the fixed
+callback URL WP17 already built (`callback_redirect_uri`). It carries no project id, no
+server URL, no scope list — just `redirect_uris`, `grant_types`, `response_types`,
+`token_endpoint_auth_method: "none"`, `client_name: "Agenta"`. One document, one URL,
+served by one new unauthenticated route, for every project on the deployment.
+
+This is a deliberate departure from WP17's per-project outbound registrations (each
+project registers its own `client_id`/`client_secret` with a given authorization server,
+per specs-wp17.md's "Keys"). The document mechanism has no secret to keep separate
+between projects — the "identity" it asserts is the Agenta application, once, for the
+whole deployment, exactly as the tenant partition already treats `oauth_grant` (the
+tokens) as the project-scoped thing and `oauth_provider` (the client identity) as
+comparatively incidental. Nothing about per-project tenancy is at stake in *which*
+mechanism registered the client; only the tokens a project's user later grants are
+project-scoped, and those are unaffected by this package (`SecretsTokenStorage.write_
+tokens`, unchanged).
+
+**Consequence: nothing is written to the vault for the document path.** `oauth_provider`
+storage exists to remember a `client_secret` and avoid re-registering; the document
+mechanism has neither. `begin()` re-derives the same client identity deterministically
+every time it takes this branch, and `complete()` does the same — see below.
+
+## The route
+
+`GET /gateways/mcps/oauth/client-metadata.json`, unauthenticated (added to
+`middlewares/auth.py`'s `_PUBLIC_ENDPOINTS`, the same list `/tools/connections/callback`
+already sits in for the identical reason — a third party arrives with no token of ours).
+No path parameter: this is the one static document above, computed from `env.agenta.
+api_url` on every request, never persisted, never varying per caller.
+
+## Why `state` carries the choice, and `complete()` never re-decides
+
+`complete()` runs after the browser has come back — it re-discovers the token endpoint
+and needs a `client_info` to exchange the code with, same as WP17. It must resolve
+**the same client identity `begin()` actually put in the authorization URL**, not
+whatever `is_publicly_resolvable()` would answer *now*. Those can disagree: DNS can
+change between the redirect and the callback, and re-probing at `complete()` time would
+risk building a token-exchange request under a `client_id` the authorization server never
+saw at authorization time.
+
+So the state token (`core/gateways/mcps/oauth/state.py`) gains one field, `strategy:
+"document" | "outbound"`, alongside the fields WP17 already carries. `complete()` reads
+it and either re-derives the identity document deterministically (no storage lookup) or
+falls back to WP17's existing storage-backed lookup, raising the same
+`MCPOAuthClientNotRegisteredError` it always did if that lookup comes up empty — that
+error's meaning is unchanged: *a stored outbound registration existed at `begin()` time
+and is gone now.* It is not repurposed to mean anything about the document path, because
+the document path never has anything stored to lose.
+
+## Wrong in each direction
+
+The two failure modes are not symmetric, and the detector is deliberately biased toward
+the one that is recoverable.
+
+**Direction 1 — detected "resolvable" when the authorization server actually cannot
+reach us.** (A public-looking IP that is firewalled, NAT'd with nothing listening behind
+it, or resolves differently for us than for the authorization server's own resolver.) We
+redirect the user's browser to the authorization server with `client_id` pointing at our
+document. The authorization server's fetch fails. It shows **its own** error page instead
+of a consent screen. We receive no callback — not a success, not an OAuth error redirect,
+nothing — because, as above, the authorization server never even learns our
+`redirect_uri`. The signed `state` token simply expires unused an hour later. From the
+user's side this looks like the connect button leading nowhere. There is no code path in
+this package, or reachable from it, that detects or recovers from this: it is a genuine,
+acknowledged blind spot, not an oversight. The only lever an operator has is fixing the
+underlying DNS/network fact (the deployment's public-facing record must actually be
+reachable, not merely resolve), because there is deliberately no configuration flag to
+force the outbound path instead — retrying the connect attempt after that fix succeeds
+immediately, since nothing about the failed attempt persisted anywhere to clean up.
+
+**Direction 2 — detected "not resolvable" when the authorization server actually could
+have reached us.** (Split-horizon DNS answering our own lookup with a private address for
+a name that is genuinely public elsewhere, or a resolution that times out for a domain
+that is otherwise fine.) We take the outbound path. It works — WP17's mechanism was never
+conditioned on reachability in the first place, since nothing is ever fetched from us on
+that path. The only cost is one RFC 7591 registration call that a working deployment
+didn't strictly need, which is invisible to the user and has no functional consequence.
+This is the harmless direction, and it is why `all()` rather than `any()`, "fails closed"
+rather than "fails open", and every ambiguous case in `is_publicly_resolvable()` all point
+the same way: toward this direction rather than direction 1.
+
+**Why no config flag, stated against this specific risk.** A flag would let an operator
+who hits direction 1 force the outbound path permanently — the brief this package answers
+explicitly rules that out ("make that fallback automatic rather than a configuration
+flag... a deployment must not have to be told which world it is in"), and the reasoning
+holds even acknowledging direction 1's cost: a flag is a second thing that can be wrong
+(set and stale after a network change, or simply never set because nobody deploying today
+knows this package exists), where the detector is at least always reevaluated against the
+deployment's current DNS answer. The residual risk in direction 1 is real and is paid in
+full by the operator of a deployment whose public-looking address doesn't actually route
+— but it is paid once, is diagnosable (the authorization server's own error page names the
+failure, even if not to us), and self-heals the moment the underlying network fact is
+fixed, without a stale flag to also remember to flip back.
+
+## Keeping discovery and registration failures distinct
+
+`MCPOAuthDiscoveryError` (specs-wp17.md) means "no protected-resource or
+authorization-server metadata could be found" — it is raised by `client.discover()`,
+which every path through `begin()`/`complete()` still calls **first**, unchanged from
+WP17. This package's strategy choice runs only after that call returns successfully; nothing
+here wraps, catches, or re-raises a discovery failure as anything about registration.
+`MCPOAuthRegistrationError` keeps its exact WP17 meaning too — "the authorization server's
+own `registration_endpoint` rejected our outbound registration" — and is reachable only
+from step 3 of the strategy above. The identity-document branch introduces no new
+exception at all: `is_publicly_resolvable()` never raises (any internal failure answers
+`False`, per "Wrong in each direction" above), and there is nothing about the document
+mechanism itself that can fail synchronously in our process. A production incident
+report of "we could not find the authorization server" and one of "we found it but
+couldn't register" (or "registration looked fine but the user never came back") remain
+three distinguishable statements, not one collapsed diagnosis.
+
+## Contracts
+
+- **No config flag decides the strategy.** `is_publicly_resolvable()` takes an injectable
+ `resolve` for tests; production wiring supplies no override and gets real DNS by
+ default — the same shape as `MCPOAuthClient(transport=...)`'s existing seam.
+- **The document mechanism writes nothing to the vault.** Only `oauth_grant` (the tokens,
+ written by `complete()` regardless of strategy) and, on the outbound branch only,
+ `oauth_provider` (unchanged from WP17).
+- **`complete()` never re-probes.** The strategy travels in `state`, signed the same way
+ WP17's `code_verifier` already does, for the same reason: no server-side session to
+ hold it in, and a decision that must match what `begin()` actually put in the
+ authorization URL rather than a fresh answer to a question that could have changed.
+- **The route is one static document, unauthenticated, with no path parameter.**
+ `apis/fastapi/gateways/mcps/oauth_router.py` reads nothing from the request.
+
+## Tests
+
+Unit only, no live network, no real authorization server — `httpx.MockTransport` for the
+authorization server, an injected `resolve` for DNS, a `TestClient` against a bare
+`FastAPI()` app for the route, matching WP17's own precedent.
+
+- `registration.py`: a public address resolves as resolvable; a private one does not; a
+ mix of public and private does not (conservative `all()`); resolution failure, empty
+ answer, and non-`https` scheme all answer not-resolvable; the served document carries
+ no `client_secret`; `identity_document_client_info()` is deterministic across two calls.
+- Strategy: `begin()` prefers the document when resolvable, with no registration call and
+ no `oauth_provider` row written; `begin()` falls back to outbound when not resolvable,
+ identical to WP17's existing behavior; a second `begin()` for the same server keeps
+ using the document without re-registering (nothing to re-register).
+- `complete()` via the document path succeeds with nothing stored beforehand.
+- **Wrong in each direction:** a test pinning that a resolved address classifying public
+ is treated as resolvable regardless of actual reachability (the detector's documented
+ blind spot, direction 1) — proceeding is the correct, current behavior, not a bug to
+ fix later. A test proving a misdetected-as-internal domain (direction 2) still
+ completes a full authorization end to end via the outbound path.
+- The route: serves the document with no `Authorization` header required by the test
+ client itself (no middleware in the test app at all — the auth-exemption is a one-line
+ addition to `_PUBLIC_ENDPOINTS`, verified by inspection rather than a live-middleware
+ test, matching how the sibling exemptions in that list are treated).
+
+## Out of scope
+
+- Callback reachability — D26, already closed, not reopened here.
+- Whether real-world authorization servers still accept RFC 7591 outbound registration,
+ or reject a redirect target on a non-public domain — OD6's own "establish at
+ implementation time, neither blocking" note, unchanged by this package.
+- A stored-nonce replay ledger for `state` — WP17's own flagged gap, untouched.
+- Any UI for the consent screen or the connect button — WP18.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp23.md b/docs/design/gateways-research/v1/workstreams/specs-wp23.md
new file mode 100644
index 0000000000..e9fc33b31d
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp23.md
@@ -0,0 +1,83 @@
+# WP23 — Protocol front doors
+
+**Owns:** `apis/fastapi/gateways/llms/proxy.py`, `apis/fastapi/gateways/llms/utils.py`.
+**Depends on:** C1. **Blocks:** WP24.
+
+Three front doors instead of one (D33, D38). This is what makes D34 survivable: a gateway
+that may not convert a body reaches an upstream only through a door that speaks its protocol.
+
+---
+
+## The route table
+
+Today `LLMGatewayProxy` registers four routes — chat completions and models, per namespace
+(`proxy.py:150`). After this package, per namespace:
+
+```text
+POST /{namespace}/{name}/v1/chat/completions (exists)
+POST /{namespace}/{name}/v1/responses (new)
+POST /{namespace}/{name}/v1/messages (new)
+GET /{namespace}/{name}/v1/models (exists, unchanged)
+```
+
+`/v1/models` is **not** a front door. It answers from the endpoint's allowlist (R3) and has
+no upstream protocol behind it.
+
+`{namespace}` is `standard` or `custom` today; `builtin` is reserved and empty on the LLM
+plane (D30). Adding a door means adding it for every namespace the plane serves, which is
+the reason the handlers are thin and the parsing is not.
+
+## What each door owns, and what it does not
+
+Everything behind the door is protocol-blind — resolution, filters, ceilings, secrets,
+adapter selection and audit are all unchanged and must not learn a protocol. Each door owns
+exactly three things:
+
+1. **The policy-field parse.** `LLMCallContext` needs the model id and the stream flag, and
+ nothing else. `parse_llm_call_context` (`llms/utils.py:13`) is the Chat Completions
+ version; each door gets its own, reading its own protocol's field names.
+2. **The ceiling binding.** Chat Completions names it `max_tokens` or
+ `max_completion_tokens`; Responses names it `max_output_tokens`; Messages names it
+ `max_tokens`. The *config* key stays `settings.max_output_tokens` on the endpoint — what
+ varies is which request field it is compared against (D25: rejected, never clamped).
+3. **Usage extraction.** Each protocol reports usage in its own shape and in its own final
+ streaming frame. The adapter reads it out of the response without reconstructing the
+ response, exactly as the Chat Completions path already does.
+
+**The body is never parsed beyond those fields, and never re-serialized.** That is D34, and
+it is the property this package exists to preserve rather than erode. The minimal parse is
+already the pattern — WP6 wrote it that way — and three doors is three copies of a small
+function, not one clever one.
+
+## Contracts
+
+- **Byte-for-byte, per door.** A request relays to the upstream unchanged, and the response
+ relays back unchanged, streamed or not. Asserted as bytes, not as re-decoded equivalence.
+- **The service is not touched.** `LLMGatewayService.relay_chat_completion` takes a body, a
+ context and headers; a second door supplies a different context from a different parse and
+ calls the same method. If a door cannot be added without changing the service, say so
+ before changing it.
+- **A door with no upstream is a 404, not a 500.** Addressing `/v1/messages` on an endpoint
+ whose provider does not speak it fails cleanly and names both.
+- **The exceptions table is frozen** (`apis/fastapi/gateways/exceptions.py`, the seed). New
+ doors reuse it; they do not add codes.
+
+## Tests
+
+- **Unit, per door.** TestClient plus a mock service: the route reaches the handler, the
+ context carries the right model and stream flag, and the body is passed through untouched.
+- **Unit, per door.** The ceiling binds to that protocol's field name; a request above it is
+ refused with `CeilingExceededError`, and one at or below it is not.
+- **Unit, per door.** Usage is extracted from that protocol's response and its final
+ streaming frame.
+- **Unit.** A model outside the endpoint's allowlist is refused on every door, before any
+ secret is touched.
+- **Acceptance.** A request in each protocol relays byte for byte against a mock that speaks
+ it; the comparison is on bytes.
+
+## Out of scope
+
+- Removing `TranslatedLLMAdapter` (WP24). Until then a door may reach a converting adapter,
+ which is temporary and is why WP24 follows immediately.
+- Which upstreams each door can actually reach — that is OD16, verified in WP24.
+- The MCP plane, which has one protocol and no door problem.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp24.md b/docs/design/gateways-research/v1/workstreams/specs-wp24.md
new file mode 100644
index 0000000000..091e48a426
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp24.md
@@ -0,0 +1,92 @@
+# WP24 — The relay-only south port
+
+**Owns:** `core/gateways/llms/providers/`, `core/gateways/llms/registry.py`, and the
+`provider_key` migration.
+**Depends on:** WP23. **Blocks:** C2.
+
+D34 forbids body conversion. This package enforces it, which means deleting the adapter that
+does it and replacing the passthrough/translated split with one relay that can compose a URL
+and apply an authentication scheme.
+
+---
+
+## First task: OD16's verification, before any code
+
+For each of Azure, Bedrock, SageMaker and Vertex, and for each `direct` provider currently
+routed to the translated adapter, answer three questions from the provider's own request
+schema — not from what the current adapter does:
+
+1. **Does it accept the bytes a front door relays?** With all three doors shipped (D38), the
+ question is whether *some* door's body is what this upstream takes. Azure OpenAI takes the
+ OpenAI body; a Bedrock Anthropic model takes the Anthropic Messages body.
+2. **Can its URL be composed from route fields?** Azure needs base URL, deployment name and
+ API version; Bedrock needs region and model id. If the model id must come out of the body
+ and be removed from it, that provider fails question 1 rather than passing this one.
+3. **Can its auth be applied without touching the body?** A header of any name is trivial. A
+ signature is allowed and is real work; SigV4 signs the body it is given, which is
+ compatible with relaying it.
+
+**Record the answers in `open-designs.md` OD16 and close it.** A provider that fails becomes
+unreachable and that is a stated outcome, not a gap — say so in the record rather than
+keeping a converting path alive for it.
+
+## The shape that replaces the split
+
+One relay with two strategies per deployment:
+
+- **Routing** — how to build the URL. Today's passthrough does `base_url + protocol path`;
+ Azure and Bedrock add a composed path from route fields.
+- **Authentication** — how to present the secret. A bearer header, a differently named
+ header (`api-key`), a request signature, a minted token.
+
+`select_upstream` stops choosing between two adapters and starts choosing a pair of
+strategies. The mock stays a real adapter — it fabricates a response and is a test double,
+not an upstream.
+
+**`TranslatedLLMAdapter` is deleted, not deprecated.** A converting path that still exists is
+a path something will use. litellm stays as a library for two jobs that are not conversion:
+cost arithmetic (`cost_calculator.cost_per_token`, already used) and signing where the scheme
+is a signature.
+
+## The migration
+
+`provider_key`'s `NOT NULL` loses its last justification here. `select_upstream`'s `direct`
+branch is the only place a stored row's `provider_key` decides anything (entities.md §2.4);
+with the split gone it decides nothing, and a `custom` row pointed at a self-hosted gateway
+should not be made to name a provider that means nothing to it.
+
+- Make it nullable. Keep the column: it is what `query_endpoints` filters on and what an
+ upstream error names.
+- The mock's selection moves off `provider_key == "mock"` onto something that is not a
+ provider name — a deployment kind or the registry's own wiring. That short-circuit is a
+ test-double artifact and should not be the reason a column is required.
+
+## Contracts
+
+- **No code path parses a request body except to read the policy fields.** This is the
+ package's single assertion and it is checkable: the only `json.loads` of a request body in
+ `core/gateways/llms/` is the policy parse.
+- **Response bodies are never reconstructed.** Usage is read out; the bytes yielded are the
+ bytes received.
+- **Every provider OD16 clears is reachable through the door matching its shape**, and every
+ provider it does not clear fails with a message naming the protocol it needs.
+- **Streaming stays chunk-boundary faithful** — the existing passthrough discipline, now the
+ only discipline.
+
+## Tests
+
+- Unit: routing strategy per deployment composes the expected URL from route fields.
+- Unit: authentication strategy per deployment presents the secret the expected way, and a
+ caller's own auth survives when no secret resolved (pass-through, OD15).
+- Unit: byte-for-byte relay for every deployment that OD16 cleared, streamed and not.
+- Unit: an unreachable provider raises, naming the protocol it would need.
+- Unit: the repo contains no request-body `json.loads` outside the policy parse. A grep-style
+ guard is legitimate here — this is the one invariant a future edit is most likely to break
+ quietly.
+- Migration: verified by hand against a real database (`api/AGENTS.md` — no migration tests
+ in pytest).
+
+## Out of scope
+
+- The MCP plane, which has one protocol and never converted anything.
+- Adding providers. This package moves existing ones and removes what cannot move.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp25.md b/docs/design/gateways-research/v1/workstreams/specs-wp25.md
new file mode 100644
index 0000000000..6aec0c4ed4
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp25.md
@@ -0,0 +1,197 @@
+# WP25 — A refusal arrives as a cause, not a sentence
+
+**Owns:** `api/oss/src/apis/fastapi/gateways/utils.py` (the shared marker), `gateways/llms/
+proxy.py` and `gateways/mcps/proxy.py` (both apply it), `services/runner/src/gateway-error.ts`
+(the marker fallback), `sdks/python/agenta/sdk/agents/adapters/vercel/stream.py`, and
+`open-designs.md` OD18.
+**Depends on:** C2. **Blocks:** WP19, WP26.
+
+D35 requires a gateway target to be registered before an agent can use it. That makes a
+registration gap a normal failure mode, not an edge case, so the refusal it produces has to
+reach the agent as something it can act on. Both gateway planes raise a typed domain error for a
+missing credential, a rejected one, an unregistered target and a deactivated endpoint; the LLM
+plane additionally raises one for a disallowed model. Done means every one of those reaches the
+caller carrying its cause, proven per harness AND per plane — not assumed, and not merely
+recorded as lost where it is. WP26 (an agent requesting a missing connection) needs the MCP
+plane's version of this channel specifically — that is the plane its own affordance runs on.
+
+---
+
+## What arrives already built
+
+`AgentErrorDetail` (`services/runner/src/protocol.ts`) is `{code, message, retryable, next_step?,
+details?}` on `AgentRunResult.errorDetail`. `gateway-error.ts`'s `parseGatewayErrorDetail`
+recovers it from a harness-reported error string, and `engine.ts`'s `withGatewayErrorDetail`
+attaches it at the runner's single choke point. On the Python side, `AgentRunFailed`
+(`sdks/python/agenta/sdk/agents/errors.py`) carries `error_detail` and promotes its `code` to
+`failure_code`; `result_from_wire` (`utils/wire.py`) raises it with
+`error_detail=data.get("errorDetail")` when the wire result is `ok: false`.
+
+Do not invent a second shape.
+
+## The refusals, by their actual code, on each plane
+
+`_map_domain_exception` (`gateways/llms/proxy.py`) maps each LLM-plane domain exception to an
+OpenAI-shaped `{"error":{"message","type","code"}}` body; `_map_gateway_exception`
+(`gateways/mcps/proxy.py`) maps each MCP-plane one to a JSON-RPC error result carrying a stable
+`cause` in `error.data`. Same causes, two wire shapes:
+
+| launch-3.md's refusal | LLM exception | LLM `code` | MCP exception | MCP `cause` |
+| --- | --- | --- | --- | --- |
+| missing credential | `SecretNotFoundError` | `secret_missing` | `SecretNotFoundError` | `secret_missing` |
+| rejected credential | `SecretInvalidError` | `secret_invalid` | `SecretInvalidError` | `secret_invalid` |
+| unregistered target | `LLMEndpointNotFoundError` | `endpoint_not_found` | `MCPEndpointNotFoundError` | `endpoint_not_found` |
+| disallowed model | `LLMModelNotAllowedError` | `model_not_allowed` | — (no MCP equivalent) | — |
+| deactivated endpoint | `GatewayEndpointInactiveError` | `endpoint_inactive` | `GatewayEndpointInactiveError` | `endpoint_inactive` |
+
+**`SecretInvalidError` was not wired on the LLM plane before this package.** It is raised by the
+shared secret resolver (`policy/resolution.py`, both planes) when a bound secret exists but
+`is_valid` is false — the actual "rejected credential" case — but the LLM plane's
+`_map_domain_exception` had no branch for it and `_DOMAIN_EXCEPTIONS` (the tuple its `except`
+clause catches) did not list it either. It would have reached the caller as an unhandled 500,
+not a typed refusal. Fixed here: mapped to `secret_missing`'s sibling `secret_invalid` (409) and
+added to `_DOMAIN_EXCEPTIONS`.
+
+**The same audit, run on the MCP plane, found no gap.** Every exception
+`core/gateways/mcps/service.py`/`registry.py` actually raise has a branch in
+`_map_gateway_exception` and is listed in `_MAPPED_EXCEPTIONS` — `SecretInvalidError` included,
+already correct there. `CeilingExceededError`/`EntitlementDeniedError`/`MCPAuthRequiredError`/
+`MCPScopeInsufficientError` are mapped but not currently raised on this plane (reserved for
+ceiling/entitlement/step-up work not yet built, WP16-20) — not a gap, just unexercised.
+
+`upstream_error` (`LLMUpstreamError` / `MCPUpstreamError`) is not one of the refusals above on
+either plane — it is the upstream's own detail, forwarded untouched (D16) — and is excluded from
+everything below, on both planes identically.
+
+## Two channels the cause can travel by, tried in order
+
+**1. The JSON body, verbatim, in the harness's error text.** For the LLM plane,
+`parseGatewayErrorDetail` scans for the gateway's `{"error":{...}}` object and recovers the full
+envelope when it finds one — `code`, `message`, `next_step`, `details`. This path is unchanged
+by this package and is LLM-only by construction: the MCP plane's JSON-RPC shape keeps its stable
+cause at `error.data.cause` under a numeric `error.code`, which the scan's
+`typeof body.code === "string"` check never matches — so this channel never recovers an MCP
+refusal, full body or not, on any harness.
+
+**2. A marker inside `message`, when the body itself is gone (or never matched).** Verifying
+channel 1 per harness (OD18) found one that discards the body's structure entirely but keeps its
+`message` field untouched: Codex's `codex-rs` (`UnexpectedResponseError::extract_error_message`)
+parses the JSON response, pulls out `error.message`, and throws the rest away before formatting
+`"unexpected status {n}: {message}"`. No brace survives for channel 1's scan. Left there, Codex
+would satisfy neither this package's "done when" (a code reaching the caller) nor WP19/WP26 (a
+channel to build step-up and connection-request on).
+
+The fix is on the gateway's side, and Codex's own behavior names it: `message` survives on every
+harness examined, Codex included — it is the one field codex-rs keeps, on either plane's wire.
+So every TYPED refusal, on BOTH planes, now renders its `message` with a marker appended
+(`with_code_marker`, `gateways/utils.py` — shared by `llms/proxy.py`'s `_openai_error` and
+`mcps/proxy.py`'s `_protocol_error` rather than copied, the CU12 lesson applied here too):
+
+```
+ ⟦agenta_code:⟧
+```
+
+**The delimiter.** U+27E6/U+27E7 (MATHEMATICAL LEFT/RIGHT WHITE SQUARE BRACKET). Chosen because
+they do not occur in ordinary error prose, in a model's own output, in JSON's own delimiters
+(`{`/`}`/`[`/`]`), or in markdown — nothing else in the text a harness reports can produce or be
+mistaken for this exact sequence, and it is visually and byte-wise distinct from the `{...}`
+channel 1 scans for, so the two recovery paths cannot interfere with each other. A plain-ASCII
+tag (`[agenta_code:...]`) was rejected: square brackets are common in prose and in a model's own
+formatted output (citations, markdown links, tool-call syntax), so a false match — recovering
+the wrong code, or recovering one from text that never carried a real refusal — was a real risk
+a rare Unicode pair avoids entirely.
+
+`gateway-error.ts` scans for the marker as a **fallback** on the LLM plane (only after the body
+scan fails) and as the **only channel** on the MCP plane (the body scan never matches its shape
+at all — see above):
+
+- **Recovers `code` only.** `retryable` and `next_step` and `details` do not survive a
+ marker-only recovery — they are never backfilled from the runner's own `NEXT_STEPS` table,
+ so a caller can tell "code only" from "the full envelope" by whether those fields are present.
+ On the MCP plane this means every recovery is code-only today, since that plane's body never
+ reaches channel 1.
+- **Excluded from `upstream_error`.** D16 forwards the upstream's own detail untouched; this
+ surface must not inject text into a body it promised not to touch, so both `_openai_error`'s
+ and `_protocol_error`'s `marked` flag is `False` for that one cause, on both planes.
+- **Message is marker-stripped for display.** The recovered `AgentErrorDetail.message` has the
+ marker removed, so a caller surfacing it to a human never shows the raw bracket text.
+
+**What WP19/WP26 must do with a code-only recovery.** When `next_step`/`details` are absent,
+degrade to a generic step-up/connect prompt ("this connection needs attention") rather than
+assume a specific one — the marker path proves a cause exists and names it, but not what to tell
+the user to do about it beyond that. On the MCP plane, plan for this being the NORMAL case, not
+a fallback.
+
+**Claude Code's unverified status matters less now.** Whether the Claude Code CLI's own SDK
+preserves the full JSON body in the text it reports is still not verifiable from source — the
+CLI is a compiled, closed-source binary (`@anthropic-ai/claude-agent-sdk`'s
+`extractFromBunfs.js`), the same limit OD14 hit on this package. That stays recorded as
+unverified rather than guessed past. But since the marker rides inside `message`, the one field
+every harness examined — Pi, the Anthropic SDK, and Codex — keeps intact, `code` survives on
+Claude Code regardless of which way that unverified question resolves. The only thing still
+riding on it is `retryable`/`next_step`/`details`, none of which were load-bearing for this
+package's own "done when."
+
+## Gap 2 — the agent service never surfaced `errorDetail` onto its stream
+
+`AgentRunFailed.error_detail` reaches Python intact (confirmed by reading `wire.py` and
+`streaming.py`: nothing between `result_from_wire` and the vercel adapter catches or rewraps the
+exception). But `stream.py`'s `_error_parts` — the single function both
+`agent_run_to_vercel_parts` and `agent_stream_to_vercel_stream` call from their terminal
+`except Exception` handlers — read only `getattr(error, "failure_code", None)` for the
+`data-agent-error` part's `code` field. It never read `error_detail`, so `retryable`,
+`next_step` and `details` stopped at the Python boundary even when the runner recovered them.
+
+**The fix:** `_error_parts` also reads `getattr(error, "error_detail", None)` and, when present,
+carries it whole as `errorDetail` on the `data-agent-error` part's `data`. `code` and `errorText`
+are unchanged (a caller reading only those two fields sees no difference); `errorDetail` is
+additive, mirroring how it is additive-and-optional on the runner's own `AgentRunResult`. No
+wire-shape change — `errorDetail`'s field names are already the platform's agent-actionable
+envelope (`api/AGENTS.md`).
+
+## Contracts
+
+- **One shape.** `errorDetail` on the vercel stream is exactly `AgentErrorDetail`, byte-for-byte
+ what the runner attached. No renamed fields, no flattening.
+- **`error`/`errorText` never regresses.** A caller reading only the existing string field keeps
+ working unchanged; `errorDetail` is purely additive.
+- **`code`/`cause` reaches every harness for every refusal, on both planes.** Proven, not
+ assumed: the body path for Pi and (inferred but marker-backed) Claude Code on the LLM plane,
+ the marker path for Codex on the LLM plane and for every harness on the MCP plane. Nothing
+ degrades silently to a bare `error`/`errorText` string with no code for any of them.
+- **A marker-only recovery is distinguishable from a full one.** `next_step`/`details` present
+ means the body survived; absent means only the marker did. WP19/WP26 branch on that, not on
+ which harness or which plane is running.
+- **The marker never touches `upstream_error`, on either plane.** D16's byte-for-byte forwarding
+ of the upstream's own detail is unconditional and plane-independent.
+- **One marker implementation, not two.** `with_code_marker` lives once, in `gateways/utils.py`,
+ imported by both proxies. A change to the delimiter or the rendering changes both planes from
+ one place.
+
+## Tests
+
+- Unit (`api`): on each plane, every typed refusal's rendered `message` ends with its marker;
+ `upstream_error`'s never contains one; `SecretInvalidError` maps to `secret_invalid`/reaches
+ the caller as a typed refusal rather than an unhandled exception, on the plane where that was
+ previously untrue (LLM) and re-confirmed on the plane where it was already true (MCP).
+- Unit (`services/runner`): per LLM refusal code, two fixtures — the Pi/Anthropic-SDK shape
+ (body intact, marker riding inside it) recovering the full envelope via the body path, and
+ Codex's stripped shape (`"unexpected status {n}: {message} ⟦agenta_code:{code}⟧"`) recovering
+ `code` alone via the marker path, with `next_step`/`details` asserted absent. Per MCP refusal
+ cause, two more fixtures — the full JSON-RPC body embedded verbatim (still only the marker
+ recovers it, proving the body path never matches this plane's shape) and the Codex-stripped
+ shape (marker recovers it same as the LLM plane).
+- Unit (`sdks/python`): `_error_parts` / the two vercel projection functions, given a mock
+ `AgentRunFailed` carrying `error_detail`, emit a `data-agent-error` part whose
+ `data.errorDetail` equals it; given a plain exception (no `error_detail`), the part carries no
+ `errorDetail` key (not even `null`). Exercised for all five refusal codes end-to-end from a
+ `result_from_wire`-shaped `{"ok": false, ...}` dict.
+
+## Out of scope
+
+- The local-agent fallback (OD14's shape) — not needed here; the marker closes the gap it would
+ have existed for.
+- `request_connection` / the step-up interaction (WP26, WP19) — this package only makes the cause
+ reach the caller; acting on it is the next package's job.
+- Any change to the gateway's byte-for-byte relay of a live upstream response — the marker only
+ touches pre-dial refusals the gateway itself constructs.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp26.md b/docs/design/gateways-research/v1/workstreams/specs-wp26.md
new file mode 100644
index 0000000000..756a125d5d
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp26.md
@@ -0,0 +1,160 @@
+# WP26 — An agent can request a gateway connection
+
+**Owns:** `core/workflows/static_catalog.py`'s `request_connection` client tool, and the
+playground's client-tool widget that renders it
+(`web/oss/src/components/AgentChatSlice/components/clientTools/`).
+**Depends on:** C2. **Blocks:** WP19.
+
+D35 requires a target to be registered before an agent can reach it. The affordance that
+already exists — the reserved `request_connection` client tool — only lets an agent ask for
+an external integration. This package widens it to also cover a gateway target on either
+plane, per the wave-3 launch doc's instruction to extend rather than duplicate: the pause,
+the render hint (`render: {kind: "connect"}`) and the resume path are already built and
+tested for this tool, and none of that changes.
+
+---
+
+## The widened contract
+
+One new optional field, `target`, alongside the existing `integration`:
+
+```jsonc
+{
+ "integration": "slack", // existing path, unchanged
+ // — or —
+ "target": { // new path
+ "plane": "llm", // "llm" | "mcp"
+ "name": "openai" // provider name (llm) or server slug (mcp)
+ },
+ "slug": "my-connection", // existing, ignored for a gateway target
+ "mode": "oauth" // existing, ignored for a gateway target
+}
+```
+
+`input_schema.required` drops from `["integration"]` to `[]`; the tool description states
+that exactly one of `integration` or `target` must be given. Both are plain strings/objects
+in an already-untyped `Dict[str, Any]` schema — nothing downstream parses this schema
+strictly (the LLM decides which fields to fill from the description; no JSON-schema
+validator gates the call), so relaxing `required` and documenting the exclusivity in prose
+is consistent with how `mode`'s "defaults to oauth" is already documented rather than
+enforced.
+
+**Why a `target` object with a `plane` discriminant, and not the alternatives:**
+
+- **A second top-level field per plane (`llm_provider`, `mcp_server`) was rejected.** It
+ would need a third field the day a third plane exists (or a fourth for `agenta`-namespace
+ targets), and every consumer would need to check three fields instead of branching on one.
+ A single `target.plane` enum scales by adding an enum value, not a field.
+
+- **Overloading `integration` to also carry a provider/server name was rejected.** `slug`
+ and `mode` already read as integration-specific in the existing schema (`slug` defaults to
+ the *integration* key; `mode` is "oauth or api_key", which does not describe how a
+ gateway target's registration works — see below). Reusing one field for two concepts
+ forces every reader (the widget, a future harness-side validator, anyone grepping the
+ schema) to first decide which concept a given call is in, from context alone. A named
+ `target` object makes that decision explicit in the payload itself.
+
+- **A `kind` discriminant on the whole payload (`kind: "integration" | "gateway"`) was
+ considered and rejected as one field too many.** `target`'s mere presence already
+ discriminates — there is no state where both `integration` and `target` are meaningfully
+ set at once, so a wrapping switch adds nothing the presence check doesn't already give,
+ and it would need its own validation ("kind says gateway but integration is set").
+
+- **A second reserved client tool (`request_gateway_connection`) was rejected outright**,
+ per the wave-3 launch doc's explicit instruction: it would duplicate the pause/render/
+ resume machinery this tool already has, for no behavioral difference the machinery cares
+ about — the runner parks on any unsettled client tool regardless of its argument shape.
+
+**Why `plane`, not `namespace` (`builtin`/`standard`/`custom`, D30).** The agent asking for a
+connection does not know or care which namespace ultimately resolves the name — that is a
+gateway registry concern, not a request concern. `plane` is the one fact the agent actually
+has: which gateway refused it. The frontend resolves the rest (a provider name matches
+`standard`; a server slug not in the `builtin` Composio catalog is `custom`) the same way the
+existing settings surfaces already do, without the agent needing to know the split.
+
+**Why `mode` is left alone rather than widened.** `mode: oauth | api_key` describes how an
+*external integration* authenticates. A gateway target's registration story is different per
+plane and is not a request-time choice the agent makes: an LLM `standard` provider is always
+"bring a secret" (there is no OAuth mode for a provider API key); an MCP `custom` server's
+OAuth flow (WP17/WP18) is a property of the server, discovered when it is added, not
+something the agent selects up front. Overloading `mode` to mean two different things on two
+different paths was rejected for the same reason `integration` reuse was: it forces a reader
+to know which path they're on before the field means anything. `mode` simply does not apply
+to `target` calls, and the schema says so.
+
+---
+
+## What "lands the user on the right registration surface for that plane" means today
+
+No dedicated dashboard page exists yet for registering a `custom` gateway endpoint on either
+plane (checked: no frontend references `llms_endpoints` or `mcps_endpoints` CRUD). What
+*does* already exist, and is what this package wires the widget to:
+
+- **LLM plane → the model-providers drawer.** `ProviderDrawer`
+ (`@agenta/entity-ui/secretProvider`) is the exact surface a project's own "connect a model
+ provider" flow already opens (`ConnectModelBanner.tsx`, `useLLMProviderConfig.tsx`). It
+ covers `standard` (D30): the user picks a provider, brings a key, and the connection lands
+ in the vault. This is a complete, accurate landing for the LLM-plane case.
+
+- **MCP plane → the tool catalog drawer.** `CatalogDrawer`
+ (`@agenta/entity-ui/gatewayTool`) is already mounted once, globally, inside
+ `Playground.tsx` (the same tree the agent chat panel renders in), driven by the shared
+ `toolCatalogDrawerOpenAtom`. It covers `builtin` (Composio-backed servers). It does **not**
+ yet cover registering a `custom` server by URL — that surface does not exist anywhere in
+ the app today, gateway-specific or not. This package does not add it; that is a dashboard
+ feature outside "extend a client tool's contract." Opening the catalog drawer is still the
+ right move: it is the closest existing surface, and a `custom` registration UI can start
+ routing through the same `target: {plane: "mcp", ...}` request shape once it exists,
+ without a second protocol change.
+
+## Settle semantics, and why they differ by plane
+
+The existing integration flow settles on an explicit signal: `ProviderDrawer`'s `onSaved`
+callback (LLM) fires only when a secret was actually persisted; the OAuth popup settles only
+on the `tools:oauth:complete` postMessage (external integration). Both are decisive.
+
+The MCP catalog drawer has no equivalent per-call completion signal available at the point
+this widget opens it — it is a shared, globally-mounted drawer with a global
+`onConnectionCreated` prop already wired to a different caller (`GatewayToolsPanel`), and
+attaching a second, call-scoped listener would mean either mounting a second instance of a
+drawer keyed off the *same* shared atom (two components racing one boolean — rejected as a
+correctness risk for no clear benefit) or threading a per-call callback through a
+globally-mounted singleton (a prop-drilling change to `Playground.tsx` out of proportion to
+this package). So:
+
+- **LLM (`target.plane === "llm"`):** settle `{connected: true, target}` from
+ `ProviderDrawer.onSaved` — a real, verified signal, exactly like the existing flow's
+ `onSuccess`. Closing without saving settles `{connected: false, reason: "cancelled"}`.
+
+- **MCP (`target.plane === "mcp"`):** settle `{connected: true, target}` when the shared
+ catalog drawer transitions from open back to closed. This is optimistic, not verified —
+ documented as such rather than silently assumed. **This is safe because the gateway
+ remains the authority.** If the user closed the drawer without actually registering the
+ server, the agent's next call to that server reproduces the exact same typed refusal
+ (WP25's `AgentErrorDetail`), and the agent can request the connection again. A false
+ "connected" here costs one extra round trip through the same refusal-and-request loop it
+ would have taken anyway; it does not let anything unregistered through, because nothing
+ downstream trusts this widget's belief — the gateway re-checks registration on every call,
+ independent of what the client tool settled with.
+
+Both cases keep the existing "Not now" affordance, settling `{connected: false, reason:
+"declined"}` before anything opens — unchanged from the integration path.
+
+## Contracts
+
+- **The existing external-integration case is untouched.** `integration`-only calls parse
+ and render exactly as before; `useConnectFlow` and `ConnectToolWidget`'s existing branches
+ are not modified, only extended with a sibling path.
+- **Dispatch stays on `render.kind` / `toolName`**, unchanged (`registry.tsx`). The `target`
+ vs `integration` distinction is read from `meta.input`, one level below dispatch, matching
+ how the existing widget already reads `input.mode` and `input.slug`.
+- **No new client tool, no new render kind, no runner change.** The runner parks on any
+ unsettled client-tool part regardless of its input shape; this package changes only what
+ the input can contain and how the browser widget reacts to it.
+
+## Out of scope
+
+- Building a `custom` MCP server registration UI (by URL). Tracked as a gap this package
+ inherits, not one it closes; the widened contract already anticipates it (see above).
+- WP19's step-up interaction, which depends on this package rather than extending it.
+- Any change to the runner, the harness adapters, or `AgentErrorDetail` (WP25's scope).
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp27.md b/docs/design/gateways-research/v1/workstreams/specs-wp27.md
new file mode 100644
index 0000000000..2115d8cedb
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp27.md
@@ -0,0 +1,234 @@
+# WP27 — The static field rewrite for the one resold wire that still needs it (D40, OD19)
+
+**Owns:** `core/gateways/llms/providers/passthrough/static_fields.py`, the Messages-door URL
+strategies in `providers/passthrough/routing.py`, wired into `RelayLLMAdapter`
+(`providers/passthrough/adapter.py`).
+**Depends on:** C2. **Blocks:** nothing.
+
+D34 forbids body conversion. D40 carves out one bounded exception: Vertex's `rawPredict` resells
+the Anthropic Messages wire with a fixed structural difference — `anthropic_version` must be in
+the body and `model` must not be, because the model id rides the URL. Bedrock does not need this
+carve-out: its Messages door is `bedrock-mantle`, not `InvokeModel` (below), and mantle needs no
+rewrite at all.
+
+## `base_url`: one definition, host-only, shared by every door a kind serves
+
+OD19's finding: a stored `base_url` must mean the same thing on every door a deployment kind
+serves, or a row that works on one door composes a wrong URL on another. The fix is to make
+`base_url` **a host override and nothing more** — never a full per-door URL — with each door
+appending its own tail on top of it. Per kind:
+
+- **`DIRECT`**: `base_url` overrides the catalogued host (`routing.py`'s `DIRECT_BASE_URLS`).
+ Tail is the protocol path alone (`/chat/completions`, `/responses`, `/messages`).
+- **`CUSTOM`**: `base_url` is required and is the whole address up to the protocol path. Same
+ tail convention as `DIRECT`.
+- **`AZURE`**: `base_url` is the resource host (e.g. `https://acme.openai.azure.com`). Tail is
+ `/openai/deployments/{route.model}/{chat/completions|responses}?api-version=...`.
+- **`BEDROCK`**: `base_url` is the host alone — `https://bedrock-mantle.{region}.api.aws` when
+ unset, or a private host such as a VPC interface endpoint
+ (`https://vpce-{id}.bedrock-runtime.{region}.vpce.amazonaws.com`) when set. Every door on this
+ kind now speaks mantle, so the same host serves all three: `/v1/chat/completions`,
+ `/v1/responses`, `/anthropic/v1/messages`.
+- **`VERTEX`**: `base_url` is the host **plus** the shared
+ `/v1/projects/{project}/locations/{region}` prefix — the part every Vertex door has in common.
+ Each door appends only its own tail beyond that: `/endpoints/openapi/{chat/completions|
+ responses}` for the OpenAI-compatible door, `/publishers/anthropic/models/{route.model}:
+ {rawPredict|streamRawPredict}` for the Anthropic door. One stored string serves both, which is
+ the shape OD19 asked for.
+- **`SAGEMAKER`**: unreachable regardless of `base_url` (OD16).
+
+**Why the field is not decoration.** Both Bedrock and Vertex publish private-network addresses
+that replace only the host: Bedrock through VPC interface endpoints, Vertex through Private
+Service Connect. Forbidding `base_url` on these two kinds would make the gateway unusable from a
+VPC-only deployment. `base_url` being a pure host override, consistently defined, is what makes
+that substitution safe on every door at once instead of only the one a row happened to be tested
+against.
+
+**Still latent, as before.** No seed, fixture, or acceptance test in this codebase registers a
+Bedrock or Vertex row with an explicit `base_url`. `LLMEndpointCreate`/`LLMEndpointRoute` accept
+the field with no per-`deployment_kind` validation. The ambiguity OD19 opened against is closed at
+the definition level now — a future caller that sets `base_url` on either kind gets one coherent
+meaning across every door, not a per-door landmine.
+
+## Bedrock's Messages door moves to `bedrock-mantle`
+
+`bedrock-runtime.{region}.amazonaws.com` carries `InvokeModel`, which needs the D40-shaped
+rewrite (model out of the body, `anthropic_version` into it). But Bedrock also serves the
+Anthropic Messages API on a second, current-generation endpoint,
+`bedrock-mantle.{region}.api.aws`, natively: model stays in the body exactly as a native
+Anthropic client sends it, and the version travels as the `anthropic-version` **header** a native
+client already sets — not a body field. Routing the Messages door there instead of `InvokeModel`
+removes the need for any rewrite on Bedrock:
+
+- **Bedrock Messages**: `POST {base}/anthropic/v1/messages`, where `{base}` is `route.base_url`
+ or, when unset, `https://bedrock-mantle.{route.region}.api.aws`. `route.model` is left in the
+ body untouched — there is no model segment in this URL.
+- This is the same host `_bedrock_url` already composes for the OpenAI-compatible doors
+ (`/v1/chat/completions`, `/v1/responses`), so `routing.py` no longer needs a Messages-only
+ Bedrock strategy at all: one function, keyed by protocol, handles all three doors for
+ `BEDROCK`.
+- No stream-specific URL variant. Mantle's Anthropic surface streams via the body's own `stream`
+ flag, the same way the native Anthropic API and the OpenAI-compatible doors already do — unlike
+ `InvokeModel`, which named streaming as a separate operation
+ (`invoke-with-response-stream`). The `stream` parameter `build_url` still accepts is simply
+ unused for `BEDROCK`.
+
+**Vertex is unchanged by this move** — it has no equivalent second door; `rawPredict` is the only
+way to reach Claude models on Vertex, so its rewrite entry and its model-in-URL routing strategy
+both stand exactly as before.
+
+## The version header and the auth header, verified against mantle
+
+A native Anthropic client sends `anthropic-version: 2023-06-01` on every Messages call — exactly
+the header mantle wants, and it needs no equivalent in the body. `RelayLLMAdapter`'s stripped-
+header set (`adapter.py`, `_STRIPPED_HEADERS`) contains only hop-by-hop headers and the gateway's
+own credentials header; `anthropic-version` is not in it, so a caller's header passes through
+unmodified. This is forwarding, not injection — D34 still forbids inventing content, and nothing
+here adds the header if the caller omitted it.
+
+Bedrock's auth strategy (`auth.py`, `_bedrock_auth`) already presents a Bedrock API key as
+`Authorization: Bearer ` rather than SigV4 — that was written for the OpenAI-compatible doors
+when they moved to mantle, and is exactly what mantle's Messages surface accepts too, unchanged by
+this package. Both facts were checked, not assumed; nothing needed to change to make them true.
+
+**A `route.model` missing on the Messages door for `VERTEX` still raises before any I/O**, naming
+the provider — Vertex still needs the model id to build the URL. `BEDROCK` has no equivalent
+check: mantle takes the model from the body, so the routing strategy never inspects `route.model`
+for this door.
+
+## The trade AWS's own documentation disagrees on — recorded, not resolved
+
+AWS's Bedrock endpoint-comparison page states `bedrock-mantle` does **not** support structured
+outputs on Messages (`output_config.format` rejected with 400), cross-region inference profiles,
+guardrails, or intelligent prompt routing. AWS's Messages API reference page, separately, lists
+structured outputs among the Messages API's supported features with no endpoint carve-out. **The
+two pages disagree**, and this package does not pick a side — it is not this package's call to
+make, and doing so would be inventing a fact rather than reading one. What is settled regardless
+of which page is right: `bedrock-runtime` remains the endpoint for cross-region inference
+profiles, guardrails, and intelligent prompt routing, none of which this relay-only package
+builds a path to. No fallback between the two Bedrock endpoints is built here — an endpoint is
+chosen once, by `deployment_kind` and door, never switched per-request. That trade-off is a later
+decision if a caller ever needs guardrails or profiles on a Messages-shaped Bedrock call.
+
+---
+
+## Phase 0 — already closed
+
+Whether a rewritten body that still carried `model` was rejected or merely ignored was the open
+question for Vertex specifically (Bedrock's `InvokeModel` — no longer routed to — was attested to
+reject it with `Malformed input request: #: extraneous key [model] is not permitted`, the finding
+that first established the removal half was necessary at all). Vertex has no attestation either
+way and removes it regardless, because removing a field the endpoint does not read costs nothing.
+No live vendor call was made or is needed. See D40 for the full citation trail.
+
+## The table
+
+One literal entry, of the exact shape D40 permits:
+
+```python
+STATIC_FIELD_REWRITES: Dict[LLMDeploymentKind, LLMStaticFieldRewrite] = {
+ LLMDeploymentKind.VERTEX: LLMStaticFieldRewrite(
+ fields_added={"anthropic_version": "vertex-2023-10-16"},
+ fields_removed=["model"],
+ ),
+}
+```
+
+The list is literal: fixed key names, a fixed constant value, nothing computed from the request.
+`fields_added` uses **setdefault semantics** — a caller who already sent `anthropic_version` is
+not overwritten, mirroring how the vendor SDKs treat the field.
+
+**Keyed by `deployment_kind`, not by a finer identifier.** Every `VERTEX` route reaches
+`rawPredict` (`routing.py`'s `_vertex_messages_url`); there is no second Vertex Messages
+operation this table would need to distinguish.
+
+**Gated to the Messages front door.** The rewrite only applies when
+`context.protocol == LLMProtocol.MESSAGES`. A `VERTEX` route hit through a different door (which
+nothing in this codebase configures on purpose) is left untouched rather than mangled.
+
+## Why the applying function cannot become conversion
+
+`apply_static_fields(*, deployment_kind, protocol, body) -> bytes` takes only those three
+parameters. It does one generic thing: look up the deployment's table entry, `pop()` each
+name in `fields_removed`, `setdefault()` each pair in `fields_added`. It never inspects a
+value already in the body, never branches on a field's content, and never takes a parameter
+that could carry request semantics beyond the body it patches generically. **The function's
+signature is the proof, not a docstring's claim** — a unit test asserts the signature directly
+so a future edit that smuggles in a fourth "helpful" parameter fails loudly.
+
+## What this is not
+
+- Not a general body-rewrite mechanism. The table has exactly one entry; a second deployment
+ needing this shape earns its own literal entry, not a parameterized rule.
+- Not an auth strategy, and `static_fields.py` itself is not a routing strategy either — it
+ runs on the body only, after the URL is built and after auth is resolved, immediately
+ before the adapter hands bytes to `httpx`. The URL half of the pair lives in `routing.py`'s
+ own Messages-only Vertex strategy, described above, which never touches the body.
+- Not a relaxation of D34's byte-for-byte relay elsewhere. Every other deployment, `BEDROCK`
+ included, is untouched by this file; `apply_static_fields` is a no-op whenever
+ `deployment_kind` is not the one table key.
+
+## Contracts
+
+- **The table is the only place either operation is named.** No deployment-kind branch
+ anywhere else in `core/gateways/llms/` adds or removes a body field.
+- **`apply_static_fields` never reads a value to decide anything.** Checkable by its
+ signature and by the fact that it never compares a payload value against anything.
+- **Byte-for-byte relay is no longer universal.** `VERTEX` is the named exemption; every other
+ deployment kind, `BEDROCK` included, is unaffected and stays byte-for-byte, request and
+ response, streamed and not.
+- **The response is never touched.** D40 amends what the gateway sends, not what it returns;
+ `RelayLLMAdapter`'s response-side discipline (bytes yielded are the bytes received) is
+ unchanged.
+- **`base_url`, when a row sets one, means the same host override on every door the row's
+ `deployment_kind` serves.** No routing strategy reads it as a full per-door URL.
+
+## Tests
+
+- Unit: `VERTEX` route removes `model` and adds `anthropic_version: "vertex-2023-10-16"`.
+- Unit: a body that already carries `anthropic_version` keeps its own value (setdefault, not
+ overwrite).
+- Unit: a non-`MESSAGES` protocol body is untouched even on a `VERTEX` route.
+- Unit: every other `deployment_kind`, `BEDROCK` included, is untouched, byte for byte, on the
+ Messages door.
+- Unit: the table has exactly one entry (`VERTEX`), and it is data-only — every `fields_added`
+ value and every `fields_removed` entry is a literal (`str`/`int`/`float`/`bool`/`None`), never
+ a callable.
+- Unit: `apply_static_fields`'s signature carries only `deployment_kind`, `protocol`, `body` —
+ proof the function cannot see anything beyond the table and the raw bytes.
+- Guard: the existing "no unexpected `json.loads`" test
+ (`test_gateways_llm_no_body_conversion.py`) allows `static_fields.py`, with the reason
+ recorded there rather than the assertion being loosened.
+- Unit: `routing.py`'s Messages door composes `{base}/anthropic/v1/messages` for `BEDROCK`
+ (model left in the body, no stream-specific path) and
+ `.../publishers/anthropic/models/{model}:rawPredict` (`:streamRawPredict` when streaming) for
+ `VERTEX`; CHAT_COMPLETIONS/RESPONSES for both kinds are unchanged; a missing `route.model` on
+ the Messages door raises for `VERTEX` before any I/O, naming the provider.
+- Unit: a stored `base_url` on `BEDROCK` composes correctly across all three of its doors
+ (`/v1/chat/completions`, `/v1/responses`, `/anthropic/v1/messages`); a stored `base_url` on
+ `VERTEX` (host + shared prefix) composes correctly across both of its doors
+ (`/endpoints/openapi/...`, `/publishers/anthropic/models/{model}:rawPredict`).
+- Unit, the pairing test for `VERTEX` on the Messages door: one test asserts both halves
+ together — the outbound URL contains `route.model` AND the outbound body does not — through
+ `RelayLLMAdapter` end to end, so the two halves cannot drift apart.
+- Unit, the Bedrock counterpart: one test asserts the outbound URL is the mantle Messages
+ address AND the outbound body is byte-for-byte identical, including `model` — the negative
+ space of the Vertex pairing test, proving Bedrock genuinely needs nothing.
+- Acceptance: no acceptance test covers the URL half for either kind. The mock upstream mounts
+ only `/v1/{chat/completions,responses,messages}` (`providers/mock/app.py`); it does not speak
+ Vertex's real `rawPredict` path shape or Bedrock mantle's `/anthropic/v1/messages` path, so an
+ acceptance test pointed at it would prove nothing about the composed URL and was not added.
+ Written-not-run acceptance coverage for the general Messages door (not these two kinds'
+ specifics) already exists in `test_llm_gateway_proxy_acceptance.py`.
+
+## Out of scope
+
+- Any other resold wire. If a third vendor turns up reselling a body with a fixed structural
+ difference, it earns its own D40-shaped decision and its own table entry — this package does
+ not generalize ahead of that.
+- Anything OD16 already settled about whether Bedrock/Vertex are reachable at all; this package
+ assumes they are (D40, `routing.py`, `auth.py`) and only adds the one remaining field rewrite.
+- Choosing between `bedrock-runtime` and `bedrock-mantle` per request. The endpoint is fixed by
+ `deployment_kind` and door; a caller needing `bedrock-runtime`'s exclusive capabilities
+ (structured outputs per one AWS page, cross-region inference profiles, guardrails, intelligent
+ prompt routing) is not served by this package's Messages door, and no fallback is built.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp3.md b/docs/design/gateways-research/v1/workstreams/specs-wp3.md
new file mode 100644
index 0000000000..e177d860ec
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp3.md
@@ -0,0 +1,335 @@
+# WP3 — Policy core
+
+Delivers `GatewayPolicyService.authorize()` — the permission check on a `GatewayTarget`,
+returning a `PolicyDecision` rather than raising (there is no entitlement check: D29) — and
+the six new `Permission` members (`VIEW_LLM_ENDPOINTS`, `EDIT_LLM_ENDPOINTS`,
+`USE_LLM_ENDPOINTS`, `VIEW_MCP_ENDPOINTS`, `EDIT_MCP_ENDPOINTS`, `USE_MCP_ENDPOINTS`)
+plus their role wiring. Owns `core/gateways/policy/service.py` and one edit to
+`core/access/permissions/types.py`.
+
+There is no principal to design (D2): every authenticated call already resolves a
+frozen `AuthScope` (organization, workspace, project, user, all required). WP3 consumes
+that scope as a parameter; it does not read `request.state`, does not call
+`get_auth_scope()` itself (the caller — a router handler in WP10, or the relay path in
+WP6/7/8/9 — resolves scope and passes it in), and does not construct a new principal
+type.
+
+## What this is NOT
+
+- **No credit check.** `plan.md` is explicit: "the entitlement check. No credit check."
+ Credit checks arrive with metering and billing, after C3 (`plan.md`, "After
+ C3"). `authorize()` never touches the legacy credits counter (D24 — left
+ alone) and never touches a spend ceiling.
+- **Not secret resolution.** `authorize()` never resolves a secret and never calls
+ `SecretsResolverInterface` — that is WP2, called separately by the plane service
+ *after* `authorize()` returns `allowed=True` (`entities.md` §8's relay pseudocode).
+- **Not the real audit publish.** `GatewayPolicyService.record()` is declared here (it is
+ part of the frozen `entities.md` §8 surface `authorize`/`record` pair, and the relay
+ path in every wave-1 plane service calls it on every outcome), but its real body —
+ building `EventType`/attribute payloads and calling `publish_event` — belongs to
+ `core/gateways/policy/audit.py`, which is **WP4's file, landing in wave 2** (`plan.md`:
+ *"Moved out of wave 1: wave 1 makes the call work, and a record of a call that does not
+ happen is worth nothing"*). WP3 must still ship a **safe, callable, non-raising**
+ `record()` in wave 1 — because WP6/WP7/WP8/WP9's relay path calls it on the C1
+ hot path — but it does nothing yet beyond satisfying the contract "never raises." See
+ the `record()` section below; do not build a partial audit pipeline to fill the gap and
+ do not raise `NotImplementedError` (that would break C1, since a stub that
+ raises is called on every relay).
+- **Not the caller's exception raising.** `authorize()` never raises `PolicyDeniedError`
+ or `EntitlementDeniedError` (both are seed-owned, `core/gateways/policy/types.py`, and
+ WP3 does not edit that file). It returns a `PolicyDecision` with `allowed=False` and a
+ `reason`; the calling plane service is the one that raises, specifically so it can call
+ `record()` with the denial *before* the exception leaves the service (`entities.md`
+ §8's ordering rule: "the denial is recorded before the exception leaves").
+- **Not the target resolution.** `GatewayTarget` is built by the calling plane service
+ (WP6/7/8/9) from whatever row or generated entry it already resolved; WP3 only
+ evaluates the target it is handed.
+
+## Files
+
+New:
+- `api/oss/src/core/gateways/policy/service.py` — `GatewayPolicyService`.
+
+Edited:
+- `api/oss/src/core/access/permissions/types.py` — one edit: six new `Permission`
+ members plus their entries in `default_permissions()`'s per-role lists. This file has
+ no other owner during wave 1 (`workstreams/README.md`'s ownership table names WP3
+ explicitly), but it is a shared enum every other domain also extends — add members,
+ touch nothing else in the file.
+
+WP3 adds one construction line to `api/entrypoints/routers.py` at the IM1 merge (below).
+
+## `GatewayPolicyService` (reproduce verbatim, `entities.md` §8)
+
+```python
+class GatewayPolicyService:
+ def __init__(
+ self,
+ *,
+ resolver: SecretsResolverInterface,
+ ) -> None: ...
+
+ # --- authorization (WP3) ------------------------------------------------ #
+
+ async def authorize(self, *, scope, permission, target) -> PolicyDecision: ...
+ # scope: AuthScope; target: GatewayTarget. Permission via check_action_access
+ # (core/access/permissions/service.py), fail-CLOSED. No entitlement check in
+ # wave 1 (D29). Raises nothing — returns the decision; the caller raises
+ # PolicyDeniedError so the audit event can record the denial before the
+ # exception leaves the service.
+
+ # --- audit + usage (WP4, D22, §2.7) ------------------------------------- #
+
+ async def record(self, *, scope, target, decision, outcome) -> None: ...
+ # One event per call, allowed or denied, built by policy/audit.py and
+ # published through publish_event. Never raises — the caller's response
+ # must not depend on the stream (the _safe_publish discipline).
+```
+
+The constructor takes only `resolver` — WP3 does not need `SecretsResolverInterface`
+for `authorize()` or the wave-1 `record()` stub, but the seed-frozen constructor
+signature already includes it (`entities.md` §8), and `entities.md` §9's wiring line is
+`GatewayPolicyService(resolver=secret_resolver)`. Store it on `self`; a later package
+(WP4, or a future policy concern) may need it. Do not drop the parameter to simplify the
+constructor — the signature is authoritative.
+
+## `authorize()` — implementation
+
+```python
+async def authorize(
+ self, *, scope: AuthScope, permission: Permission, target: GatewayTarget,
+) -> PolicyDecision:
+ allowed = await check_action_access(
+ user_uid=str(scope.user_id),
+ project_id=str(scope.project_id),
+ permission=permission,
+ )
+ if not allowed:
+ return PolicyDecision(allowed=False, permission=permission, reason="permission_denied")
+
+ return PolicyDecision(allowed=True, permission=permission, reason=None)
+```
+
+**There is no entitlement arm, by ruling (D29, closing R5).** Every user has both gateways, so
+the check would ask a question with one answer, and what entitlements will express here are
+*limits* — which cannot be enforced before anything is measured. It ships with usage metering and
+billing. Do not add a placeholder key that always permits: a later reader mistakes it for
+enforcement.
+
+`check_action_access` (`core/access/permissions/service.py`) is the existing, unconditional
+RBAC entry point every domain already calls — it takes `user_uid: str`, `project_id:
+Optional[str]`, `permission: Optional[Permission]`, both `str`, not `UUID`; convert with
+`str(...)`. It already runs the EE plan-gated RBAC bypass internally
+(`check_project_has_role_or_permission`'s `is_ee()`-guarded `Flag.RBAC` check) — **that is
+not the entitlement soft check this method performs separately**; it is folded inside
+`check_action_access` and WP3 does not touch it.
+
+**"Permission is fail-CLOSED"**: `check_action_access` returning `False` (including on
+any internal exception it does not itself swallow) must produce `allowed=False`. Do not
+wrap this call in a broad `try/except` that defaults to `True` on error — that inverts
+the fail-closed requirement `entities.md` states explicitly for this half of the check.
+With the entitlement arm gone (D29), fail-closed is the whole of this
+method's error behaviour — there is no fail-open half left to balance it.
+
+### What is deliberately absent: the entitlement soft check
+
+An earlier draft of this spec carried an EE-guarded soft check with a placeholder key. **D29
+removed it.** `EntitlementDeniedError` and `PolicyDecision.reason == "entitlement_denied"` stay
+declared in the seed and mapped at the boundary, so the wave that adds limits changes a body
+rather than a signature — but nothing in wave 1 raises either.
+
+`check_action_access` still runs the EE plan-gated RBAC bypass internally
+(`check_project_has_role_or_permission`'s `is_ee()`-guarded `Flag.RBAC` check). That is inside the
+permission call and is not an entitlement check this method performs; WP3 does not touch it.
+
+## `record()` — the wave-1 stub, ruled at kickoff (R4)
+
+This is now a ruling, not this spec's proposal. R4 asked whether a method on the C1
+hot path may be the seed's usual not-implemented default; the answer is no — **it ships as a
+no-op that returns `None` and never raises.** What the seed freezes is the *call*, which every
+wave 1 relay makes unconditionally on both the allow and the deny branch, so wave 2 changes a
+body and never a call site.
+
+```python
+async def record(
+ self, *, scope: AuthScope, target: GatewayTarget, decision: PolicyDecision, outcome: GatewayOutcome,
+) -> None:
+ # WP4 (wave 2) replaces this body with policy/audit.py's
+ # build_gateway_call_attributes + publish_gateway_call. Wave 1 has no
+ # audit.py yet (plan.md: "a record of a call that does not happen is worth
+ # nothing"), but this method is on the C1 hot path — every
+ # relay call in WP6/7/8/9 calls it on both the allow and deny branch — so
+ # it must exist, accept the full signature, and never raise. It does
+ # nothing observable in wave 1.
+ return
+```
+
+Do not log at a level that could be mistaken for a working audit trail (no `log.info`
+claiming an event was recorded), and do not partially implement `publish_event` here —
+that duplicates WP4's file ownership of `policy/audit.py` and produces two half-built
+audit paths to reconcile at the wave-2 merge. A `log.debug` noting the stub was hit is
+fine; anything that looks like audit output is not.
+
+## The six new `Permission` members and role wiring (`entities.md` §9)
+
+```python
+class Permission(str, Enum):
+ ...
+ # Gateway: LLM endpoints
+ VIEW_LLM_ENDPOINTS = "view_llm_endpoints"
+ EDIT_LLM_ENDPOINTS = "edit_llm_endpoints"
+ USE_LLM_ENDPOINTS = "use_llm_endpoints"
+
+ # Gateway: MCP endpoints
+ VIEW_MCP_ENDPOINTS = "view_mcp_endpoints"
+ EDIT_MCP_ENDPOINTS = "edit_mcp_endpoints"
+ USE_MCP_ENDPOINTS = "use_mcp_endpoints"
+```
+
+`USE`, not `RUN`, is the data-plane verb — following `USE_MOUNTS`, because a gateway
+endpoint is *used* like a mount, not *run* like a workflow or *executed* like a tool
+(`entities.md` §9: *"'run' belongs to things that execute on our infrastructure; a
+gateway endpoint is used"*).
+
+**Role wiring follows the `RUN_TOOLS` precedent exactly**, inside
+`Permission.default_permissions()`'s existing per-role list-building
+(`core/access/permissions/types.py`, the same method that already builds
+`VIEWER_PERMISSIONS`/`ANNOTATOR_PERMISSIONS`/`EDITOR_PERMISSIONS`):
+
+- `VIEWER_PERMISSIONS` gains `VIEW_LLM_ENDPOINTS`, `VIEW_MCP_ENDPOINTS` — alongside the
+ existing `VIEW_TOOLS`, `VIEW_TRIGGERS`, `VIEW_MOUNTS`.
+- `ANNOTATOR_PERMISSIONS` (built as `VIEWER_PERMISSIONS + [...]`) gains
+ `USE_LLM_ENDPOINTS`, `USE_MCP_ENDPOINTS` — alongside `RUN_TOOLS`, `RUN_TRIGGERS`
+ (Annotator already holds `RUN_TOOLS` today, which is the precedent `entities.md` cites
+ verbatim for why `USE` lands here rather than at Editor).
+- `EDITOR_PERMISSIONS` (built as `ANNOTATOR_PERMISSIONS + [...]`) gains
+ `EDIT_LLM_ENDPOINTS`, `EDIT_MCP_ENDPOINTS` — alongside `EDIT_TOOLS`, `EDIT_TRIGGERS`,
+ `EDIT_MOUNTS`.
+- `DEVELOPER_PERMISSIONS` and `ADMIN_PERMISSIONS` need no explicit addition — both are
+ built as supersets of `EDITOR_PERMISSIONS` (`DEVELOPER_PERMISSIONS = EDITOR_PERMISSIONS
+ + [...]`, `ADMIN_PERMISSIONS = DEVELOPER_PERMISSIONS + [...]`) and `OWNER` is `[p for p
+ in cls]` (every permission) — inserting at Editor's list already propagates upward.
+ Do not add the two pairs a second time to either superset list.
+
+**Two triples, not one shared pair**, because the planes are separately governable — an
+organization may grant annotators model access without tool-server access
+(`entities.md` §9). Do not collapse `USE_LLM_ENDPOINTS`/`USE_MCP_ENDPOINTS` into a single
+`USE_GATEWAY_ENDPOINTS`.
+
+**Every member must be checked by a named route before this package is considered
+done at the wave-1 boundary** — `entities.md` explicitly calls out `RUN_TRIGGERS` as the
+counter-example not to repeat ("defined, role-wired, checked by nothing"). WP3 defines
+and wires all six; WP6/WP8 (data plane) and WP10 (management CRUD) are the packages that
+actually call `authorize(permission=Permission.USE_LLM_ENDPOINTS, ...)` /
+`.VIEW_*`/`.EDIT_*` from their handlers. WP3's own done test (below) cannot fully close
+this by itself — flag it in the merge notes for IM1/IM2 as a cross-package check, not
+something WP3 can verify alone from its own worktree.
+
+## Contracts this package must honour
+
+- **Permissions and entitlements are kept distinct** — never merged into one boolean, one
+ exception type, or one test (`policy.md`: *"conflating them in tests or in code is a
+ known trap"*). `PolicyDecision.reason` distinguishes `"permission_denied"` from
+ `"entitlement_denied"` for exactly this reason; do not use one generic `"denied"`
+ string that a caller cannot map back to `PolicyDeniedError` vs `EntitlementDeniedError`.
+- **`authorize()` never raises.** It is a pure decision function from the caller's point
+ of view — `try`/`except` around it in a caller that expects an exception is a bug in
+ the caller, not something WP3 should accommodate by raising sometimes.
+- **`AuthScope` over `request.state`.** WP3 never reads `request.state.project_id` /
+ `.user_id` as raw strings — every value it needs comes from the `AuthScope` its caller
+ passes in. This departs from the older gateway/tools/triggers routers on purpose
+ (`entities.md` §9): those re-wrap raw request state per call site, this domain does
+ not.
+- **No credit check, anywhere in this file.** If a task here seems to need a spend
+ ceiling or the legacy credits counter, that task belongs to the post-checkpoint-C
+ metering work (`plan.md` WP11/WP22), not this package.
+
+## Tests
+
+**Unit (no services running, run now):**
+`api/oss/tests/pytest/unit/gateways/test_gateways_policy_service.py`
+
+- `authorize()` returns `allowed=True, reason=None` when `check_action_access` returns
+ `True` and the entitlement check passes — mock both.
+- `authorize()` returns `allowed=False, reason="permission_denied"` when
+ `check_action_access` returns `False` — and the entitlement check is **not even
+ invoked** in this case (assert the mock was never called — permission is checked
+ first, per the docstring order, and an unnecessary entitlement call on an already-denied
+ request is wasted work at best and a confusing audit entry at worst).
+- `authorize()` returns `allowed=False, reason="entitlement_denied"` when
+ `check_action_access` returns `True` but `_check_entitlement` returns `False`.
+- `authorize()` never raises when `check_action_access` itself raises — decide and test
+ the actual behavior explicitly (either fail-closed by catching and returning
+ `allowed=False`, or let it propagate — `entities.md` says "raises nothing", so the
+ test should confirm the chosen implementation matches that literally: no exception
+ escapes `authorize()`).
+- `_check_entitlement` returns `True` unconditionally when `is_ee()` is `False` (OSS
+ path) — assert the EE-only import is never attempted in this branch (no import error
+ possible in OSS-only environments).
+- `record()` is called with a representative `scope`/`target`/`decision`/`outcome` and
+ returns without raising and without any observable side effect (no publish call, since
+ `policy/audit.py` does not exist in this package's scope) — this is the test that
+ guards against someone accidentally wiring a real `publish_event` call into WP3's stub
+ ahead of WP4.
+- `Permission.VIEW_LLM_ENDPOINTS` through `.USE_MCP_ENDPOINTS` all exist and are members
+ of `Permission.default_permissions(DefaultRole.VIEWER)`,
+ `.default_permissions(DefaultRole.ANNOTATOR)`, `.default_permissions(DefaultRole.EDITOR)`
+ respectively, per the table above — one assertion per (role, permission) pair, six
+ pairs for VIEW+USE+EDIT is really 2+2+2 = 6 checks across three roles (`VIEW_*` in
+ VIEWER and everything above it, `USE_*` in ANNOTATOR and above, `EDIT_*` in EDITOR and
+ above) — also assert `VIEW_LLM_ENDPOINTS` is present in `ADMIN`/`DEVELOPER`/`OWNER`'s
+ lists (superset propagation) so a future refactor of the list-building order cannot
+ silently drop it from an upper tier without a red test.
+
+**Integration:** none required for this package specifically. `check_action_access`
+touches the database and Redis cache (`get_cache`/`set_cache`) and `check_entitlements`
+touches EE's meters/subscriptions services — both are mocked in the unit suite above, so
+`GatewayPolicyService` itself needs no live dependency to test. A cross-package
+acceptance test that a caller without permission is refused end-to-end belongs to
+C1's acceptance suite (`plan.md`), not to this package's own tests.
+
+## `api/entrypoints/routers.py` diff (apply at the IM1 merge)
+
+```python
+from oss.src.core.gateways.policy.service import GatewayPolicyService
+
+gateway_policy_service = GatewayPolicyService(resolver=secret_resolver)
+```
+
+(`entities.md` §9; depends on WP2's `secret_resolver` construction landing in the
+same merge — order this line after WP2's.)
+
+## Checkpoint
+
+Feeds **IM1**, then **C1** through every plane service (WP6/7/8/9) and the
+management routers (WP10), all of which call `authorize()`.
+
+Exit condition, verbatim from `plan.md`: *"a caller without permission on an endpoint is
+refused before any upstream call."*
+
+WP3 is done when: the unit suite above passes; `Permission` carries all six new members
+correctly wired into `VIEWER`/`ANNOTATOR`/`EDITOR` (and their supersets); and
+`authorize()` returns a `PolicyDecision` — never raises, never returns `None` — for every
+combination of permission-allowed/denied × entitlement-allowed/denied.
+
+## Out of scope
+
+- `core/gateways/policy/resolution.py` — WP2.
+- `core/gateways/policy/audit.py`, the real `publish_event` wiring inside `record()` —
+ WP4 (wave 2).
+- Anything under `core/gateways/{llms,mcps}/` — WP1, WP6, WP7, WP8, WP9.
+- The routers and handlers that call `authorize()` — WP6, WP8 (data plane), WP10
+ (management CRUD).
+- Credit checks and spend ceilings — post-checkpoint-C metering work.
+
+## Missing from the design, needs a ruling
+
+- **The `PolicyDecision.reason` vocabulary.** `entities.md` §4.2 only says "denial cause,
+ stable and terse" with no enumerated set of allowed strings. This spec picks
+ `"permission_denied"` / `"entitlement_denied"` as the two wave-1 values because they
+ are the only two failure modes `authorize()` produces, but nothing in the design
+ document fixes this string set as a contract other packages (WP4's audit attribute
+ builder, WP10's exception-mapping) can rely on verbatim. Flagging so WP4 (which reads
+ `decision.reason` into the audit event's attributes) and WP10 (whose
+ `handle_gateway_exceptions()` may want to surface it) agree on the same strings rather
+ than each inventing their own.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp4.md b/docs/design/gateways-research/v1/workstreams/specs-wp4.md
new file mode 100644
index 0000000000..a56da0dc42
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp4.md
@@ -0,0 +1,80 @@
+# WP4 — Audit events
+
+**Owns:** `core/gateways/policy/service.py::record`, and a new `core/gateways/policy/audit.py`.
+**Depends on:** C1 only. **Blocks:** nothing. Can start on day one.
+
+One event per gateway call, into the existing events domain (D22). No new table, no new
+worker, no new queryable surface.
+
+---
+
+## The seam already exists
+
+R4 shipped `record()` as a no-op that never raises, and **every relay already calls it on
+both the allow and the deny branch** — `core/gateways/policy/service.py:74`. This package
+fills the body. It changes no call site, which is the whole reason the stub was written
+that way.
+
+```python
+async def record(self, *, scope, target, decision, outcome) -> None: ...
+```
+
+All four arguments are already computed and already typed:
+
+- `scope: AuthScope` — organization, workspace, project, user. The principal.
+- `target: GatewayTarget` — plane, namespace, name, `endpoint_id`, and `model` on the LLM
+ plane. What was addressed.
+- `decision: PolicyDecision` — allowed or not, and the reason when not.
+- `outcome: GatewayOutcome` — status code, and the secret's `owner` and `origin` when one
+ was resolved. `origin` is what carries spend attribution.
+
+## Follow the domain's own pattern
+
+`core/events/utils.py` already has the shape, several times over: a `build_*_attributes`
+function producing a flat attribute mapping, and an `async def publish_*` that emits it.
+`build_trace_fetched_attributes` / `publish_trace_fetched` is the closest reader.
+
+Copy that pair. Do not invent a gateway-specific publishing path, a second queue, or a new
+event store — D22 is explicit that the audit record is an event, not a table.
+
+## What the attributes must carry
+
+- The principal, from `scope` — including `organization_id`, which is why the gateways read
+ `AuthScope` rather than `request.state` (entities.md §9).
+- The target: plane, namespace, name, and `model` when present.
+- The decision, and the denial reason when denied.
+- The outcome: status, and `secret_origin` when a secret was resolved. **`secret_origin` is
+ the spend-attribution field** — a call the caller funded through pass-through resolves no
+ secret of ours, and its absence is the record of that.
+- No prompt, no completion, no secret value, no `X-AG-Credentials`. The observability rule
+ is the platform's existing one and this package does not relax it.
+
+## Contracts
+
+- **`record()` never raises.** It is called on the failure path, where an exception would
+ turn a clean 403 into a 500. Wrap the publish the way `_safe_publish` already does.
+- **One event per call**, on the allow path and the deny path alike. A refused call is the
+ one most worth having a record of.
+- **Both planes.** The LLM and MCP services call the same method; the event distinguishes
+ them by `target.plane`, not by having two shapes.
+- **Streaming records after the drain.** The LLM service records usage after the response
+ body is fully consumed, which is already where `record()` is called from — do not move it
+ earlier to make the code simpler.
+
+## Tests
+
+- **Unit.** A mock event publisher; assert one event per relay, with the right principal,
+ target, decision and outcome, on both planes.
+- **Unit.** A denied call records exactly one event, with the reason, and the relay still
+ raises `PolicyDeniedError` afterwards.
+- **Unit.** A publisher that raises does not propagate — the relay's own result is
+ unaffected.
+- **Unit.** A pass-through call (no secret resolved) records with `secret_origin` unset.
+- **Acceptance, at C2.** A run's model and tool calls appear as events with the
+ right principal.
+
+## Out of scope
+
+- Usage recording and charging (WP11, WP22) — those are meters, not audit, and they ship
+ with billing.
+- Any new query surface. The events domain's existing one answers this.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp5.md b/docs/design/gateways-research/v1/workstreams/specs-wp5.md
new file mode 100644
index 0000000000..6a8baf91a9
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp5.md
@@ -0,0 +1,417 @@
+# WP5 — Test doubles
+
+A mock LLM endpoint and a mock MCP server, first-class deliverables (D23) rather than test
+scaffolding bolted on afterwards. Depends on nothing — starts immediately, in parallel with the
+seed if necessary. Blocks every acceptance test in wave 1: C1's whole target set is
+"our own servers and the mocks" (`open-designs.md` OD10), so nothing downstream can be
+acceptance-tested without this package.
+
+Two deliverables per plane, and `entities.md` is explicit that only one of them is its concern:
+
+- **The adapter-level mock** — `MockLLMAdapter` / `MockMCPAdapter`, in-process classes
+ implementing the south port (`LLMUpstreamInterface` / `MCPUpstreamInterface`), registered under
+ the `"mock"` adapter key alongside `passthrough`/`translated` and `http`/`composio`
+ (`entities.md` §9, the wiring block). No network call, no process. This is what `entities.md`
+ draws in the file tree (§0, `providers/mock/adapter.py`) and what satisfies **unit and contract
+ tests**: a test constructs a service with `upstream_registry=...Registry(adapters={"mock":
+ MockLLMAdapter()})` and calls it directly — nothing running.
+- **The deployable mock** — a standalone process speaking the real upstream protocol (OpenAI-
+ compatible HTTP for the model plane, MCP Streamable HTTP for the tool plane), run as its own
+ docker-compose service. `entities.md` §0 says outright: *"Not shown: the deployable mocks...
+ C1's acceptance tests additionally need the mocks running as compose services in the
+ local stack (`plan.md` WP5). Those are services, not entities, and are out of this document's
+ scope."* This is what satisfies **acceptance tests**: a real HTTP request travels gateway →
+ passthrough/http adapter → real socket → this process → a real streamed/hung response back,
+ which an in-process object cannot exercise (SSE framing over the wire, an actual timeout).
+
+Both deliverables answer to the **same control convention**, defined once by this package so a
+test behaves identically whether it drives the in-process adapter or the compose service (see
+"Controllable behavior" below).
+
+**Explicitly not built here:** the registry that picks `"mock"` for a given `(provider_key,
+deployment)` pair (`select_upstream`, `core/gateways/llms/registry.py` — WP7); the MCP-plane
+equivalent (`core/gateways/mcps/registry.py` — WP9); the `builtin/agenta` code that generates
+the mock MCP server's catalog entry (`core/gateways/mcps/service.py` — WP9); the custom LLM
+endpoint row that points a slug at the mock LLM server's URL (WP1's DAO / WP10's CRUD, seeded by
+whoever owns local-stack fixtures). WP5 supplies the two processes and the classes; it does not
+wire either into an endpoint.
+
+## Files
+
+New, all inside this package's owned paths (`workstreams/README.md`):
+- `core/gateways/llms/providers/mock/adapter.py` — `MockLLMAdapter(LLMUpstreamInterface)`
+- `core/gateways/llms/providers/mock/app.py` — the deployable OpenAI-compatible mock server
+ (not in `entities.md`'s tree — it is the "deployable mock" the document explicitly disclaims;
+ see "Missing from the design" below for the naming call this makes)
+- `core/gateways/llms/providers/mock/__init__.py`
+- `core/gateways/mcps/providers/mock/adapter.py` — `MockMCPAdapter(MCPUpstreamInterface)`
+- `core/gateways/mcps/providers/mock/app.py` — the deployable MCP Streamable HTTP mock server
+- `core/gateways/mcps/providers/mock/__init__.py`
+
+Compose wiring (owned by no other package):
+- `hosting/docker-compose/oss/docker-compose.dev.yml` — two new services, `mock-llm-gateway` and
+ `mock-mcp-gateway`
+- `hosting/docker-compose/ee/docker-compose.dev.yml` — same two services (EE dev stack mirrors
+ OSS dev for anything not license-gated, per the compose file list; the mocks are not EE work)
+- `api/oss/src/utils/env.py` — one `MockGatewaysConfig` block, following the `ComposioConfig`
+ shape (`env.py` lines 685–704): two URLs the mocks' own consumers (WP1/WP9/WP10) read to seed
+ or generate their catalog entries
+
+Edited: `api/entrypoints/routers.py` — two import lines only (diff below); the registry dict
+entries themselves belong to WP7/WP9's wiring blocks, not this package.
+
+## Interfaces
+
+Reproduced verbatim from `entities.md` §7.1 — the exact shapes both adapters must satisfy. Do
+not rename, do not add parameters not listed here.
+
+```python
+# core/gateways/llms/interfaces.py (seed-owned; read, not edited, by WP5)
+
+@dataclass
+class LLMRelayResult:
+ status_code: int
+ headers: Dict[str, str]
+ body: AsyncIterator[bytes]
+ usage: Optional[GatewayUsage] = None
+
+class LLMUpstreamInterface(ABC):
+ @abstractmethod
+ async def relay_chat_completion(
+ self, *, route: LLMResolvedRoute, secret: Optional[ResolvedSecret],
+ context: LLMCallContext, body: bytes, headers: Dict[str, str],
+ ) -> LLMRelayResult: ...
+```
+
+```python
+# core/gateways/mcps/interfaces.py (seed-owned; read, not edited, by WP5)
+
+@dataclass
+class MCPRelayResult:
+ status_code: int
+ headers: Dict[str, str]
+ body: bytes
+
+class MCPUpstreamInterface(ABC):
+ @abstractmethod
+ async def relay(
+ self, *, route: MCPResolvedRoute, auth: MCPRelayAuth,
+ context: MCPCallContext, body: bytes, headers: Dict[str, str],
+ ) -> MCPRelayResult: ...
+```
+
+Exceptions to raise, from `entities.md` §5 (`core/gateways/llms/types.py`,
+`core/gateways/mcps/types.py`) — verbatim, do not add fields:
+
+```python
+class LLMUpstreamError(GatewaysError):
+ def __init__(self, *, provider_key: str, status_code: Optional[int] = None,
+ detail: Optional[str] = None): ...
+
+class MCPUpstreamError(GatewaysError):
+ def __init__(self, *, target: str, status_code: Optional[int] = None,
+ detail: Optional[str] = None): ...
+```
+
+`GatewayUsage` (§4.2, `core/gateways/policy/dtos.py`), populated by `MockLLMAdapter` once `body`
+is exhausted, per `LLMRelayResult`'s own docstring ("usage is populated by the adapter once body
+is exhausted"):
+
+```python
+class GatewayUsage(BaseModel):
+ calls: int = 1
+ input_tokens: Optional[int] = None
+ output_tokens: Optional[int] = None
+ cost: Optional[float] = None
+```
+
+## `MockLLMAdapter`
+
+```python
+# core/gateways/llms/providers/mock/adapter.py
+
+class MockLLMAdapter(LLMUpstreamInterface):
+ async def relay_chat_completion(
+ self, *, route, secret, context, body, headers,
+ ) -> LLMRelayResult: ...
+```
+
+No constructor arguments beyond what the interface needs — registered once, statically, in
+`api/entrypoints/routers.py` per the wiring snippet (§9): `"mock": MockLLMAdapter()`. It never
+opens a socket; `secret` may be `None` (targets with `GatewayAuthScheme.NONE` are the
+intended callers, per §2's "an endpoint with no secret is legitimate — the mock (D23)") and
+the adapter does not require one either way.
+
+**Controllable behavior, keyed by `context.model`** (a field `LLMCallContext` already carries —
+no new DTO field). Three model-name suffixes, checked as a prefix match so the base model name
+stays free-form:
+
+| `context.model` | Behavior |
+|---|---|
+| `mock/echo` (default; any name not matching a suffix below) | Returns a well-formed chat-completion response echoing the request's last message content; if `context.stream` is set, streams it as 2–3 SSE chunks ending `data: [DONE]\n\n`, matching the OpenAI streaming shape |
+| `mock/error` | Raises `LLMUpstreamError(provider_key="mock", status_code=500, detail="forced by mock/error")` |
+| `mock/slow-{seconds}` | `await asyncio.sleep(seconds)` before returning the `mock/echo` response — this is what WP6's relay-side timeout must fire against; `{seconds}` is a plain integer, e.g. `mock/slow-30` |
+
+`GatewayUsage` on a successful call: `calls=1`, `input_tokens`/`output_tokens` counted off the
+request/response body length (word count is enough — this is a mock, not a tokenizer), `cost=0.0`
+(the mock spends nothing).
+
+## `MockMCPAdapter`
+
+```python
+# core/gateways/mcps/providers/mock/adapter.py
+
+class MockMCPAdapter(MCPUpstreamInterface):
+ async def relay(
+ self, *, route, auth, context, body, headers,
+ ) -> MCPRelayResult: ...
+```
+
+Unlike the real `http`/`composio` adapters, this one *is* the upstream — it parses `body` (the
+caller's JSON-RPC payload) itself, because there is nothing behind it to relay to, and returns a
+JSON-RPC response built in-process. This does not violate D16's transparency rule: D16 constrains
+the **gateway**, which still passes `body` through this port untouched (§7.1, `MCPUpstreamInterface`
+docstring — "same method, same body, same response"); a mock *server* interpreting its own
+JSON-RPC input is exactly what any real MCP server does.
+
+Three tools, advertised by `tools/list` and dispatched by `tools/call`'s `params.name`:
+
+| Tool | Behavior on `tools/call` |
+|---|---|
+| `echo` | Echoes `params.arguments` back as the tool result content |
+| `fail` | Returns a JSON-RPC **result** carrying an MCP tool error (`isError: true`), not a transport failure — matching the pass-through rule that a server's own failure reason is not an exception (`api/AGENTS.md`'s error-envelope scope, and `MCPUpstreamInterface`'s docstring: "protocol-level errors from the server are NOT exceptions") |
+| `slow` | `await asyncio.sleep(seconds)` first, `seconds` from `params.arguments.seconds` (default 5) |
+
+A transport-level failure (`MCPUpstreamError`) is reserved for a distinct control path: a
+`method` other than `initialize` / `tools/list` / `tools/call` / `notifications/*` raises
+`MCPUpstreamError(target="agenta/", status_code=501)` — there is no fourth method to mock.
+
+**Forced scope challenges (D23's "later"): explicitly not built.** `MCPScopeInsufficientError`
+exists in `entities.md` §5 as a declared-but-unreachable type until the OAuth checkpoint (wave 3).
+Adding a `scope-challenge` tool now would exercise a code path (`403` handling in the OAuth
+client) that does not exist yet. Note the extension point in a comment; do not implement it.
+
+## The deployable mocks
+
+Two standalone ASGI apps, each importable and runnable independently of the main API process
+(`uvicorn core.gateways.llms.providers.mock.app:app`). They implement the **same control
+convention** as the adapters above, because the whole point is that a test written against the
+in-process `MockLLMAdapter` and a test written against the compose service see identical
+behavior for the same input:
+
+- `core/gateways/llms/providers/mock/app.py` — a FastAPI (matching the API's own framework)
+ app exposing `POST /v1/chat/completions`, OpenAI-shaped request/response, real
+ `text/event-stream` SSE when `"stream": true`, dispatching on the request body's `"model"`
+ field with the identical `mock/echo` / `mock/error` / `mock/slow-{n}` convention. No
+ `/v1/models` route is required — the gateway's own `/v1/models` handler (WP6) answers from the
+ endpoint's model allowlist, never by asking the upstream.
+- `core/gateways/mcps/providers/mock/app.py` — a stateless-JSON-mode MCP Streamable HTTP server:
+ `POST` carries JSON-RPC, `GET`/`DELETE` answer `405`, `202` for a notification — the exact shape
+ `entities.md` §7.1 cites as precedent (`services/runner/src/tools/tool-mcp-http.ts`, read in
+ full: stateless, no session id, no SSE leg, `application/json` responses). Same three tools as
+ `MockMCPAdapter`.
+
+Both apps expose `GET /health` for the compose healthcheck.
+
+## Compose wiring
+
+Following the existing profile-gated satellite-service precedent
+(`hosting/docker-compose/oss/docker-compose.dev.yml`, the `composio` and tunnel services under
+`with-tunnel`) for shape — copy their shape, never their names: the tunnel services belong to the
+development-ingress work, which renames and adds to them (D26), but **not profile-gated** — C1's acceptance tests need these
+every run, unconditionally, matching D23 ("no third-party dependency to gate on... the gateways
+have no third-party dependency to gate on" — same reasoning: the mocks are ours, not optional):
+
+```yaml
+ mock-llm-gateway:
+ image: agenta-oss-dev-api:latest # reuses the already-built api image; no new Dockerfile
+ command: ["uvicorn", "oss.src.core.gateways.llms.providers.mock.app:app",
+ "--host", "0.0.0.0", "--port", "9091"]
+ networks:
+ - agenta-network
+ restart: always
+ healthcheck:
+ test: ["CMD", "curl", "-sf", "http://localhost:9091/health"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ mock-mcp-gateway:
+ image: agenta-oss-dev-api:latest
+ command: ["uvicorn", "oss.src.core.gateways.mcps.providers.mock.app:app",
+ "--host", "0.0.0.0", "--port", "9092"]
+ networks:
+ - agenta-network
+ restart: always
+ healthcheck:
+ test: ["CMD", "curl", "-sf", "http://localhost:9092/health"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+```
+
+Reusing the `agenta-oss-dev-api:latest` image (built already for the `api` service, `docker-
+compose.dev.yml` lines 6–7) rather than a new Dockerfile: the mock apps are pure Python modules
+inside the already-built `api` package tree, so the same image serves them with a different
+`command`, the same move the `runner` service's `.runner` build anchor makes for its own
+container, minus even that — no build step at all, just a different entrypoint on an image that
+already exists.
+
+`env.py` addition, following `ComposioConfig`'s shape exactly (`api/oss/src/utils/env.py` lines
+685–704):
+
+```python
+class MockGatewaysConfig(BaseModel):
+ """Local-stack mock upstream addresses (WP5). Unset in production images —
+ nothing references these outside dev/gh compose."""
+ llm_url: str = os.getenv("AGENTA_MOCK_LLM_GATEWAY_URL", "http://mock-llm-gateway:9091")
+ mcp_url: str = os.getenv("AGENTA_MOCK_MCP_GATEWAY_URL", "http://mock-mcp-gateway:9092")
+```
+
+registered on `EnvironSettings` next to `composio` (`env.py` line 1609). WP1/WP10 (the custom LLM
+endpoint row that seeds the mock) and WP9 (the `builtin/agenta` MCP entry) read these; WP5 does
+not consume them itself.
+
+## `api/entrypoints/routers.py` diff
+
+Two import lines, added wherever the file's existing gateway-adapter imports land (near the
+`ComposioConnectionsAdapter` import block, `routers.py` lines 142–150):
+
+```diff
++from oss.src.core.gateways.llms.providers.mock.adapter import MockLLMAdapter
++from oss.src.core.gateways.mcps.providers.mock.adapter import MockMCPAdapter
+```
+
+WP7 and WP9 add the corresponding `"mock": MockLLMAdapter()` / `"mock": MockMCPAdapter()` entries
+inside their own `LLMUpstreamRegistry(...)` / `MCPUpstreamRegistry(...)` construction blocks
+(`entities.md` §9's wiring snippet) — this package does not touch those blocks.
+
+## Contracts this package must honour
+
+- **No third-party dependency, ever.** Both adapters and both deployable apps depend only on
+ what the API already ships (`fastapi`, `httpx`/`uvicorn` if needed) — D23 exists specifically so
+ C1 needs nothing external.
+- **Registered always, reachable conditionally.** The wiring snippet's own comment: `"mock":
+ MockLLMAdapter(), # registered always; reachable only via the mock endpoints the local stack
+ defines`. The adapter class is present in every deploy (nothing branches on environment inside
+ `core/`); only the compose services and the seed data that points an endpoint at them are
+ dev/gh-local concerns.
+- **Same control convention on both tiers.** A test that passes on the in-process adapter and
+ fails against the compose service (or vice versa) is a WP5 bug, not a caller bug.
+- **Transparent-relay discipline still applies to the adapter's exceptions.** `LLMUpstreamError`
+ / `MCPUpstreamError` are for transport-level failures the mock is asked to simulate
+ (`mock/error`); a tool's own business failure (`fail`) is a JSON-RPC result, never an exception
+ — this is D16 and `api/AGENTS.md`'s pass-through rule, and the mock exists partly to prove the
+ distinction is testable.
+- **No secret material anywhere in this package.** The mocks' whole point is an unauthenticated
+ target (`GatewayAuthScheme.NONE` on the MCP side, `secret_id=None` on the LLM side per §2); if a
+ test ever needs the mocks to *require* a secret, that is a different fixture, not this one.
+
+## Tests
+
+Unit — nothing running, both adapters exercised as plain Python objects:
+- `MockLLMAdapter().relay_chat_completion(..., context=LLMCallContext(model="mock/echo", ...))`
+ returns a well-formed `LLMRelayResult`, `status_code=200`, non-empty `body`.
+- `context.model="mock/error"` raises `LLMUpstreamError` with `provider_key="mock"`.
+- `context.model="mock/slow-1"` takes ≥1s wall-clock (use a short value, not the eventual
+ timeout-test duration) and then returns normally.
+- Streaming: `context.stream=True` yields more than one chunk over `body`, terminated by `data:
+ [DONE]`.
+- `MockMCPAdapter().relay(...)` with a `tools/list` body returns all three tools;
+ `tools/call` with `name="echo"` echoes arguments; `name="fail"` returns `isError: true` in the
+ JSON-RPC **result**, not a raised exception; `name="slow"` with `arguments={"seconds": 1}` takes
+ ≥1s.
+- An unrecognized `method` raises `MCPUpstreamError(status_code=501)`.
+- `GatewayUsage` is populated (non-`None`) after a successful `relay_chat_completion` call and its
+ `body` iterator is exhausted.
+
+Contract — the same fixture both a mock and (once it exists) a real adapter must pass, run against
+`MockLLMAdapter`/`MockMCPAdapter` now and reused by WP6/WP7/WP8/WP9's own adapters later. Still
+nothing running:
+- `relay_chat_completion`'s return type is `LLMRelayResult` for every `context.model`, never a
+ raw dict or a bare exception escaping unwrapped.
+- `relay`'s return type is `MCPRelayResult` for every method in `{initialize, tools/list,
+ tools/call}`.
+
+Acceptance — needs the compose stack (`hosting/docker-compose/oss/docker-compose.dev.yml`) up:
+- `curl -sf http://localhost:9091/health` and `.../9092/health` both return 200 once
+ `mock-llm-gateway` / `mock-mcp-gateway` are healthy.
+- `POST http://mock-llm-gateway:9091/v1/chat/completions` with `"model": "mock/echo"` returns a
+ real OpenAI-shaped JSON body over a genuine HTTP round trip (from inside the compose network —
+ the container is not published to the host by default).
+- The same request with `"stream": true` returns `Content-Type: text/event-stream` and multiple
+ SSE frames observable on the wire (not just in a mocked client).
+- The same request with `"model": "mock/slow-30"` and a client-side timeout shorter than 30s
+ observes the connection cut, proving the hang is real (a genuine open socket, not a Python
+ `await` a test harness can preempt).
+- `POST http://mock-mcp-gateway:9092/` with a `tools/list` JSON-RPC body returns the three tools
+ over real Streamable HTTP; a bare `GET`/`DELETE` to the same URL returns `405`.
+
+## Done test
+
+```bash
+bash hosting/docker-compose/run.sh --oss --dev --build
+curl -sf http://localhost:/... # or, from inside the network:
+docker compose exec api curl -sf http://mock-llm-gateway:9091/health
+docker compose exec api curl -sf http://mock-mcp-gateway:9092/health
+```
+
+Both healthchecks green, and each can be driven to fail on demand:
+
+```bash
+docker compose exec api python -c "
+import asyncio, httpx
+async def main():
+ async with httpx.AsyncClient(base_url='http://mock-llm-gateway:9091') as c:
+ r = await c.post('/v1/chat/completions', json={'model': 'mock/error', 'messages': []})
+ assert r.status_code == 500
+asyncio.run(main())
+"
+```
+
+Matches `plan.md` WP5's own done condition verbatim: *"both mocks run in the local stack and can
+be driven to fail on demand."*
+
+## Out of scope
+
+- `select_upstream` and the `LLMUpstreamRegistry`/`MCPUpstreamRegistry` classes — WP7 / WP9.
+- The `builtin/agenta` code that turns the mock MCP server's URL into a listed endpoint — WP9's
+ `service.py`.
+- The custom LLM endpoint row that turns the mock LLM server's URL into a reachable
+ `custom/{slug}` — WP1's DAO, seeded by whichever package owns local-stack fixtures (WP10 is the
+ natural owner once Endpoint CRUD exists; until then a raw `INSERT` against the migration WP1
+ ships is an acceptable interim seed, not this package's job to write).
+- `MCPScopeInsufficientError`-driven behavior (forced scope challenges) — deferred to wave 3
+ alongside the OAuth checkpoint; the type exists, the mock does not yet exercise it.
+- Any change to `passthrough`/`translated`/`http`/`composio` adapters — WP6, WP7, WP8.
+
+## Missing from the design, needs a ruling
+
+- **The deployable mocks' implementation files.** `entities.md` explicitly places the deployable
+ mocks "out of this document's scope," so `app.py` under each `providers/mock/` directory is not
+ a name that document specifies — it is this spec's own choice, made because the design
+ delegates the decision here rather than settling it. Flagged so a reviewer checks the naming
+ call rather than assuming it was copied from `entities.md` verbatim like everything else in this
+ spec.
+- **The env var names** (`AGENTA_MOCK_LLM_GATEWAY_URL`, `AGENTA_MOCK_MCP_GATEWAY_URL`) and the
+ compose service names (`mock-llm-gateway`, `mock-mcp-gateway`) are this package's own choice for
+ the same reason — no document names them. WP1/WP9/WP10 should treat them as an interface this
+ spec fixes, not as a detail to re-derive.
+- **How the mock LLM server gets a seeded `custom` endpoint row in the local stack** (a migration
+ data-seed, a startup fixture, or a manual `POST /gateways/llms/endpoints/` call scripted into
+ `run.sh`) is not decided anywhere in `v1/`. This blocks nothing in WP5 itself but blocks the
+ acceptance test that reaches the mock *through the gateway* rather than directly — raise it at
+ the IM1→C1 merge point.
+
+## Checkpoint
+
+Feeds **C1** (`plan.md`): *"The LLM gateway and the MCP gateway both accept a call,
+authorise it, resolve and inject a secret, reach a mock upstream, and return... Everything it
+proves is proved against our own mocks."* WP5's direct contribution: without it there is no mock
+upstream for WP6/WP7/WP8/WP9's relay paths to reach, and C1's acceptance suite (a
+permitted request reaches the mock with the caller's token replaced by the upstream secret; a
+streamed response arrives byte for byte on both gateways; a tool call outside the allowlist is
+refused) has nothing to run against.
+
+*Depends on:* nothing. *Blocks:* every acceptance test in wave 1 (`plan.md`: "Blocks: every
+acceptance test").
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp6.md b/docs/design/gateways-research/v1/workstreams/specs-wp6.md
new file mode 100644
index 0000000000..e71cf78c77
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp6.md
@@ -0,0 +1,368 @@
+# WP6 — LLM ingress and relay
+
+The OpenAI-compatible north-port surface for the model plane: the proxy router, streaming, the
+request body kept byte for byte, and timeouts. This is the **transport** half of the LLM
+gateway — the ingress package, per `workstreams/README.md`'s central cut: "on each plane,
+transport and domain are different packages... WP6 and WP8 own the HTTP surface, streaming,
+timeouts and the byte-for-byte relay. WP7 and WP9 own the service, the registry, the catalogue
+and the allowlists." WP6 therefore never decides *which* endpoint a call reaches or *whether* it
+is allowed — it parses the caller's request, calls `LLMGatewayService.relay_chat_completion`
+(WP7's method, called through the seed-frozen signature), and relays the answer back exactly as
+it arrived.
+
+**Explicitly not built here, and who owns it instead:**
+- Endpoint resolution, the allowlist check, the ceiling check, policy authorization, secret
+ resolution, and the choice of south-port adapter (`select_upstream`) — all inside
+ `LLMGatewayService.relay_chat_completion`, owned by **WP7**.
+- The adapter that translates through the routing library for non-OpenAI-shaped upstreams
+ (Anthropic direct, Azure, Bedrock, SageMaker, Vertex) — `providers/translated/adapter.py`,
+ **WP7**.
+- The mock upstream this package's own acceptance tests reach — **WP5**.
+- The shared exception→HTTP-status mapping, `handle_gateway_exceptions()` — **WP10**
+ (`apis/fastapi/gateways/exceptions.py`); WP6 consumes it, does not define it.
+- The management CRUD for LLM endpoints (`router.py`, `models.py`) — **WP10**.
+
+## Files
+
+New:
+- `apis/fastapi/gateways/llms/proxy.py` — `LLMGatewayProxy`
+- `apis/fastapi/gateways/llms/utils.py` — `parse_llm_call_context`
+- `core/gateways/llms/providers/passthrough/adapter.py` — `PassthroughLLMAdapter`
+- `core/gateways/llms/providers/passthrough/__init__.py`
+
+Edited: `api/entrypoints/routers.py` — proxy router mount + `PassthroughLLMAdapter` import and
+registry entry (diff below). No other file; WP6 does not touch `core/gateways/llms/service.py`,
+`registry.py`, or `catalog.py` (all WP7).
+
+## Interfaces
+
+Reproduced verbatim from `entities.md` §7.1 and §9. Do not rename, do not add parameters not
+listed here.
+
+### The south port this package implements
+
+```python
+# core/gateways/llms/interfaces.py (seed-owned)
+
+@dataclass
+class LLMRelayResult:
+ status_code: int
+ headers: Dict[str, str]
+ body: AsyncIterator[bytes]
+ usage: Optional[GatewayUsage] = None
+
+class LLMUpstreamInterface(ABC):
+ @abstractmethod
+ async def relay_chat_completion(
+ self, *, route: LLMResolvedRoute, secret: Optional[ResolvedSecret],
+ context: LLMCallContext, body: bytes, headers: Dict[str, str],
+ ) -> LLMRelayResult:
+ """Relay one completion call. `body` is the caller's payload untouched;
+ `headers` are the caller's headers already stripped of authorization.
+ `secret` is None only for targets whose auth scheme is NONE (the
+ mocks). Raises LLMUpstreamError on upstream failure."""
+```
+
+```python
+# core/gateways/llms/providers/passthrough/adapter.py
+
+class PassthroughLLMAdapter(LLMUpstreamInterface):
+ async def relay_chat_completion(
+ self, *, route, secret, context, body, headers,
+ ) -> LLMRelayResult: ...
+```
+
+Which providers land here versus `translated` is `select_upstream`'s decision (WP7,
+`core/gateways/llms/registry.py`) — per §7.1: "**passthrough** for upstreams that speak the
+caller's protocol (OpenAI-compatible: `deployment=custom`, and direct providers whose API is
+OpenAI-shaped)." WP6 builds an adapter correct for that whole class, without needing the
+provider list itself.
+
+### `LLMResolvedRoute` (input, seed-owned, `core/gateways/llms/dtos.py` §4.3)
+
+```python
+class LLMResolvedRoute(BaseModel):
+ provider_key: str
+ deployment_kind: LLMDeploymentKind
+ model: str
+ base_url: Optional[str] = None
+ api_version: Optional[str] = None
+ region: Optional[str] = None
+ headers: Optional[Dict[str, str]] = None
+ settings: LLMEndpointSettings = Field(default_factory=LLMEndpointSettings)
+```
+
+`settings.timeout_seconds` (inherited from `GatewayEndpointSettings`, §4.1) is the per-call timeout;
+`None` on every generated endpoint (§2.4: "generated endpoints take the code defaults") — this
+package supplies that default, since timeouts are WP6's stated scope in `plan.md`.
+
+### `ResolvedSecret` and its two relevant secret shapes
+
+`ResolvedSecret.secret: SecretResponseDTO`, kind-dispatched by the adapter (§4.2: "the
+adapter, not the resolver, knows which fields its upstream needs"). Real field shapes, read from
+`api/oss/src/core/secrets/dtos.py` (not paraphrased):
+
+```python
+# lines 20–26
+class StandardProviderSettingsDTO(BaseModel):
+ key: str
+
+class StandardProviderDTO(BaseModel):
+ kind: StandardProviderKind
+ provider: StandardProviderSettingsDTO
+
+# lines 29–48
+class CustomProviderSettingsDTO(BaseModel):
+ url: Optional[str] = None
+ version: Optional[str] = None
+ key: Optional[str] = None
+ extras: Optional[dict] = None
+
+class CustomProviderDTO(BaseModel):
+ kind: CustomProviderKind
+ provider: CustomProviderSettingsDTO
+ models: List[CustomModelSettingsDTO]
+ provider_slug: Optional[str] = None
+ model_keys: Optional[List[str]] = None
+```
+
+`secret.secret.data` is one of these (a `SecretDTO` union member, §4.5). For a `provider_key`
+secret (`StandardProviderDTO`), inject `Authorization: Bearer {provider.key}`. For a
+`custom_provider` secret (`CustomProviderDTO`), inject `provider.key` the same way and merge
+`provider.extras` into the outbound request's non-body configuration (headers only — never the
+JSON body, which stays byte for byte). This mirrors the SDK's own dispatch in
+`get_provider_settings`/`get_provider_settings_from_workflow`
+(`sdks/python/agenta/sdk/managers/secrets.py` lines 228–255 and 372–399, read in full): both copies
+branch on `secret.get("kind") == "provider_key"` vs `"custom_provider"` identically — the same
+branch this adapter needs, moved behind the gateway.
+
+**`secret` is `None` for the mocks** (`GatewayAuthScheme`-equivalent NONE targets, §2 —
+"an endpoint with no secret is legitimate — the mock (D23)"): no `Authorization` header is
+sent at all.
+
+### `apis/fastapi/gateways/llms/utils.py`
+
+```python
+def parse_llm_call_context(*, body: bytes) -> LLMCallContext:
+ """Extract model and stream from the JSON body without materializing a
+ parsed copy for relay — the body itself stays byte-for-byte (§7.1).
+ Raises ValueError when the body names no model; the proxy translates that
+ into the surface's own invalid-request error shape."""
+```
+
+`LLMCallContext` (seed-owned, §4.3): `model: str`, `stream: bool = False`. This function reads
+just enough of the body (`json.loads`, two keys) to route and to pick a timeout; it must not
+construct a new serialized body anywhere in the relay path — `body: bytes` stays the same object
+handed to the adapter.
+
+### `apis/fastapi/gateways/llms/proxy.py`
+
+Route declarations verbatim from `entities.md` §9:
+
+```python
+class LLMGatewayProxy:
+ def __init__(self, *, llm_gateway_service: LLMGatewayService):
+ self.service = llm_gateway_service
+ self.router = APIRouter()
+
+ self.router.add_api_route(
+ "/builtin/{provider}/v1/chat/completions",
+ self.chat_completions_builtin, methods=["POST"],
+ operation_id="llm_gateway_chat_completions_builtin",
+ )
+ self.router.add_api_route(
+ "/custom/{slug}/v1/chat/completions",
+ self.chat_completions_custom, methods=["POST"],
+ operation_id="llm_gateway_chat_completions_custom",
+ )
+ self.router.add_api_route(
+ "/builtin/{provider}/v1/models",
+ self.list_models_builtin, methods=["GET"],
+ operation_id="llm_gateway_list_models_builtin",
+ )
+ self.router.add_api_route(
+ "/custom/{slug}/v1/models",
+ self.list_models_custom, methods=["GET"],
+ operation_id="llm_gateway_list_models_custom",
+ )
+```
+
+No wire models (§6): these handlers take the raw `Request`, never a Pydantic request body — that
+is the whole reason the body stays byte for byte.
+
+`chat_completions_builtin` / `chat_completions_custom` bodies:
+
+```python
+scope = get_auth_scope() # AuthScope, never request.state (§9)
+raw_body = await request.body() # bytes, untouched
+caller_headers = {k: v for k, v in request.headers.items()
+ if k.lower() not in {"authorization", "secret", ...}} # strip inbound auth
+result = await self.service.relay_chat_completion(
+ scope=scope,
+ namespace=GatewayEndpointNamespace.BUILTIN, # or CUSTOM
+ name=provider, # or slug
+ body=raw_body,
+ headers=caller_headers,
+)
+if context.stream: # from parse_llm_call_context(body=raw_body)
+ return StreamingResponse(result.body, status_code=result.status_code,
+ headers=result.headers, media_type="text/event-stream")
+chunk = await anext(result.body) # exactly one chunk (interface docstring)
+return Response(content=chunk, status_code=result.status_code, headers=result.headers)
+```
+
+`list_models_builtin` / `list_models_custom`: answer from the endpoint's allowlist — "the static
+catalogue for builtin, the allowlist for custom" (§9's comment on the route declarations). **R3
+named the backing method at kickoff**: `await self.service.list_models(scope=..., namespace=...,
+name=...) -> List[str]`, owned by WP7. It authorizes and resolves the target itself; this handler
+shapes the OpenAI list body inline —
+
+```python
+slugs = await self.service.list_models(scope=scope, namespace="builtin", name=provider)
+return {"object": "list", "data": [{"id": s, "object": "model"} for s in slugs]}
+```
+
+— because the data plane has no wire models (§6).
+
+**Audit timing is not this package's problem.** §9: "Streaming rides `StreamingResponse` over
+`LLMRelayResult.body`, with the audit record written in the handler's finally after the iterator
+is exhausted (§8)." Read together with §8's own note — "for a streamed body the outcome's usage
+is read off the `LLMRelayResult` after exhaustion... the surface drains, the service records in a
+finally" — the wrapping that fires `policy.record(...)` on exhaustion is `LLMGatewayService`'s own
+`finally` around the iterator it returns (WP7's job). WP6's handler only has to drain
+`result.body` through `StreamingResponse`; it must **not** add its own `try/finally` calling into
+policy, because `LLMGatewayProxy` never holds a `GatewayPolicyService` reference (its constructor
+takes only `llm_gateway_service`) — if this reading is wrong, it is a WP7 spec bug, not a WP6 one.
+
+### Error shape
+
+Denials wear the surface's own error shape (§9): `{"error": {"message", "type", "code"}}`, `code`
+carrying a stable cause — `policy_denied`, `model_not_allowed`, `ceiling_exceeded`,
+`secret_missing` are the four `entities.md` names explicitly. The mapping from domain
+exception to HTTP status is `handle_gateway_exceptions()` (WP10, not yet built when WP6 starts
+per the wave-1 fan-out — both run against IM1 in parallel). Until that merge, `proxy.py` catches
+the domain exceptions it can already type against (everything in `core/gateways/llms/types.py`
+and `core/gateways/policy/types.py`, all seed-owned) directly and renders the OpenAI body itself;
+reconcile with WP10's shared decorator at the IM2 merge rather than blocking on it — this is a
+merge-point conversation per `workstreams/README.md` rule 1, not a WP6 commit that waits.
+
+What must never happen: leaking the house envelope (`count`, entity-wrapped) onto this surface,
+or rewriting the upstream's own error body once a call reaches `PassthroughLLMAdapter` — a
+`LLMUpstreamError` raised there passes its `detail` through untouched (D16's pass-through rule,
+`api/AGENTS.md`'s error-envelope scope).
+
+## `api/entrypoints/routers.py` diff
+
+```diff
++from oss.src.core.gateways.llms.providers.passthrough.adapter import PassthroughLLMAdapter
++from oss.src.apis.fastapi.gateways.llms.proxy import LLMGatewayProxy
+...
++llm_gateway_proxy = LLMGatewayProxy(llm_gateway_service=llm_gateway_service)
+...
+ app.include_router(router=llm_gateway.router, prefix="/gateways/llms", tags=["Gateway: LLM"])
++app.include_router(router=llm_gateway_proxy.router, prefix="/gateways/llms", include_in_schema=False)
+```
+
+The `upstream_registry=LLMUpstreamRegistry(adapters={"passthrough": PassthroughLLMAdapter(), ...})`
+dict entry is WP7's edit inside its own service-construction block (`entities.md` §9's wiring
+snippet) — WP6 contributes the import and the proxy mount only.
+
+## Contracts this package must honour
+
+- **Byte-for-byte, no exceptions inside this adapter's reach.** `scope-checklist.md`: "Body
+ byte-for-byte, **both gateways**... on the model side it is what keeps prompt caching working."
+ `PassthroughLLMAdapter` never deserializes and re-serializes `body`; it forwards the exact bytes
+ it received, adding only transport-level auth (a header, never a body mutation).
+- **The proxy carries no wire models** (§6) — house-style `models.py` request/response classes
+ never appear on `proxy.py`'s routes.
+- **`AuthScope` via `get_auth_scope()`, never `request.state`** (§9) — the design's explicit
+ correction of the existing gateway/tools/triggers habit.
+- **Timeout is enforced here, not assumed away.** `plan.md` WP6's own done condition: "a hung
+ upstream times out rather than hanging the gateway." `PassthroughLLMAdapter` wraps its upstream
+ call in `asyncio.wait_for`/an `httpx` client timeout keyed on `route.config.timeout_seconds`
+ (falling back to this package's own default when `None`), and on expiry raises
+ `LLMUpstreamError(provider_key=route.provider_key, status_code=None, detail="upstream timed
+ out")` — never lets the coroutine hang the request indefinitely.
+- **Streaming preserves ordering and framing.** SSE chunk boundaries from the upstream are not
+ recombined or re-chunked; `StreamingResponse` receives the adapter's `AsyncIterator[bytes]`
+ directly.
+- **No secret ever appears in a log or an exception message.** `secret.secret` never
+ crosses into `LLMUpstreamError.detail` or any log line this package writes.
+
+## Tests
+
+Unit — nothing running:
+- `parse_llm_call_context`: extracts `model`/`stream` from representative bodies; raises
+ `ValueError` when `model` is absent; does not mutate or copy the input bytes object
+ observably (assert the returned context, not a re-encoded body).
+- `PassthroughLLMAdapter.relay_chat_completion` against a stubbed `httpx` transport
+ (`httpx.MockTransport`, no real socket): a `StandardProviderDTO` secret produces a
+ `Authorization: Bearer {key}` header; a `CustomProviderDTO` secret produces the same header
+ from `provider.key`; `secret=None` sends no `Authorization` header at all.
+- The same stub, but the transport raises/times out: `relay_chat_completion` raises
+ `LLMUpstreamError`, never lets the exception surface as something else.
+- The outbound URL is `route.base_url` + `/chat/completions` with `route.headers` merged in
+ (non-secret routing headers) — assert against `httpx.MockTransport`'s captured request, not by
+ reading the module's internals.
+
+Contract — reuses WP5's fixture (`test_mock_adapters_contract.py`, extended once this adapter
+exists): `PassthroughLLMAdapter` is added to the parametrized fixture asserting
+`relay_chat_completion` returns `LLMRelayResult` for every input. Still nothing running (the
+`httpx.MockTransport` stub, not a real mock).
+
+Acceptance — needs the compose stack, WP5's `mock-llm-gateway` reachable, and WP7's service/
+catalog/registry wired (i.e., this suite only runs post-IM2, at C1):
+- A seeded custom endpoint pointing at `mock-llm-gateway`'s URL: `POST
+ /gateways/llms/custom/{slug}/v1/chat/completions` with `"model": "mock/echo", "stream": true`
+ streams back the exact SSE bytes the mock produced — byte comparison, not a re-decoded
+ equivalence check.
+- The same endpoint with `"model": "mock/slow-30"` and the endpoint's `config.timeout_seconds`
+ set below 30: the gateway responds with a timeout error inside that window, not after 30s —
+ the gateway's own request does not hang even though the upstream does.
+- An unauthenticated request (no `Secret `) is refused before reaching WP5's mock at all.
+- A request naming a model outside the endpoint's allowlist is refused with `model_not_allowed`
+ — proves WP7's allowlist check runs before WP6's relay is ever invoked.
+
+## Done test
+
+```bash
+bash hosting/docker-compose/run.sh --oss --dev --build
+curl -N -X POST http://localhost/api/gateways/llms/custom//v1/chat/completions \
+ -H "Authorization: ApiKey " -H "Content-Type: application/json" \
+ -d '{"model": "mock/echo", "stream": true, "messages": [{"role":"user","content":"hi"}]}'
+# observe SSE frames arriving unmodified, terminated by data: [DONE]
+
+curl -m 5 -X POST http://localhost/api/gateways/llms/custom//v1/chat/completions \
+ -H "Authorization: ApiKey " -H "Content-Type: application/json" \
+ -d '{"model": "mock/slow-30", "messages": [{"role":"user","content":"hi"}]}'
+# curl's own 5s timeout is irrelevant; the assertion is that the GATEWAY returns before 30s
+```
+
+Matches `plan.md` WP6 verbatim: *"a streamed response is relayed unmodified and a hung upstream
+times out rather than hanging the gateway."*
+
+## Out of scope
+
+- `core/gateways/llms/service.py`, `registry.py`, `catalog.py`, `providers/translated/` — WP7.
+- `apis/fastapi/gateways/llms/router.py`, `models.py` (management CRUD) — WP10.
+- `apis/fastapi/gateways/exceptions.py` — **the seed** (R1). Already on the branch when this
+ package starts; import `handle_gateway_exceptions()`, never write a local copy.
+- Anything on the MCP plane — WP8/WP9.
+- Audit event emission itself (`publish_gateway_call`) — wave 2, WP4; WP6 must not add a second
+ recording path even provisionally.
+
+## Settled at kickoff — was "needs a ruling"
+
+- **`GET /v1/models` has no backing service method → `LLMGatewayService.list_models`, R3.** It
+ resolves one target by `(namespace, name)`, authorizes with `USE_LLM_ENDPOINTS`, and returns
+ the allowlist as `List[str]`. WP7 owns it, this package calls it. The alternative considered
+ and rejected — filtering `list_endpoints` client-side — pulls full entities for a listing use
+ case on every models request, and re-derives a static catalogue each time for `builtin`.
+- **`apis/fastapi/gateways/exceptions.py` → the seed, R1.** Already on the branch when this
+ package forks; import the decorator.
+
+## Missing from the design, needs a ruling
+
+- **The default request timeout constant.** No document states a value. WP6 must pick one (a
+ defensible number, e.g. 60s, is fine) and record it in code with a comment — flagged here so a
+ reviewer knows it is this package's own call, not a transcribed design number.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp7.md b/docs/design/gateways-research/v1/workstreams/specs-wp7.md
new file mode 100644
index 0000000000..39c398fc50
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp7.md
@@ -0,0 +1,421 @@
+# WP7 — LLM routing and model allowlist
+
+The **domain** half of the LLM gateway (`workstreams/README.md`'s cut: "WP7 and WP9 own the
+service, the registry, the catalogue and the allowlists"). Owns `LLMGatewayService` in full —
+management CRUD orchestration, the generated-endpoint catalogue, the relay's policy/allowlist/
+ceiling/secret/adapter-selection pipeline — and the `translated` south-port adapter that puts
+the routing library (litellm, `sdks/python/pyproject.toml` line 31: `"litellm>=1,<2"`) in-process
+for every upstream whose wire is not OpenAI-shaped.
+
+**Explicitly not built here, and who owns it instead:**
+- The HTTP surface, streaming, timeouts, and the `passthrough` adapter — **WP6**.
+- The management router and its wire models (`router.py`, `models.py`) — **WP10**; WP7 exposes
+ the service methods those routes call, it does not declare the routes.
+- The DAO and storage for custom endpoint rows — **WP1**; WP7 depends on
+ `LLMEndpointsDAOInterface`, never a concrete DAO.
+- Secret resolution itself — **WP2**; WP7 calls `SecretsResolverInterface.resolve`, never
+ reimplements the mode logic.
+- Policy authorization and audit — **WP3**/**WP4**; WP7 calls `GatewayPolicyService.authorize`/
+ `.record`, never reimplements permission or entitlement checks.
+- The mock adapter — **WP5**; WP7 registers it under the `"mock"` key but does not write it.
+
+## Files
+
+New:
+- `core/gateways/llms/service.py` — `LLMGatewayService`
+- `core/gateways/llms/registry.py` — `LLMUpstreamRegistry`, `select_upstream`
+- `core/gateways/llms/catalog.py` — `standard_llm_endpoint`, `standard_llm_endpoints`
+- `core/gateways/llms/providers/translated/adapter.py` — `TranslatedLLMAdapter`
+- `core/gateways/llms/providers/translated/__init__.py`
+
+Edited: `api/entrypoints/routers.py` — service/registry construction (diff below). Also, if
+litellm is added as a direct API dependency rather than relied on transitively through the
+`agenta` SDK package (see "Missing from the design" below): `api/pyproject.toml`.
+
+## Interfaces
+
+Reproduced verbatim from `entities.md` §8. Do not rename, do not add parameters not listed here.
+
+### Constructor
+
+```python
+class LLMGatewayService:
+ def __init__(
+ self,
+ *,
+ llm_endpoints_dao: LLMEndpointsDAOInterface,
+ policy: GatewayPolicyService,
+ resolver: SecretsResolverInterface,
+ upstream_registry: LLMUpstreamRegistry,
+ ) -> None: ...
+```
+
+### Management surface — thin over the DAO, plus the generated merge
+
+```python
+async def create_endpoint(self, *, project_id, user_id, endpoint) -> Optional[LLMEndpoint]: ...
+async def fetch_endpoint(self, *, project_id, endpoint_id) -> Optional[LLMEndpoint]: ...
+async def edit_endpoint(self, *, project_id, user_id, endpoint) -> Optional[LLMEndpoint]: ...
+async def delete_endpoint(self, *, project_id, endpoint_id) -> bool: ...
+async def query_endpoints(self, *, project_id, endpoint=None, windowing=None) -> List[LLMEndpoint]: ...
+async def list_endpoints(self, *, project_id) -> List[LLMEndpoint]: ...
+```
+
+`list_endpoints` is the merge: generated builtin endpoints (`catalog.py`, existing *iff* a
+`provider_key` secret exists for the provider — D20) plus the custom rows; `agenta` joins when it
+has members (D27, currently none on the LLM plane). This is the **only** read that spans
+namespaces — `query_endpoints` filters rows only, per §9's router comment: "generated endpoints
+have nothing to filter on but the provider, which GET already shows."
+
+To compute "existing iff a `provider_key` secret exists," `list_endpoints` needs the project's
+provider keys. **R2 settled this at kickoff: the resolver port gained one method, and the
+constructor above is unchanged.**
+
+```python
+keys = await self.resolver.available_provider_keys(scope=scope) # Set[str], names only
+```
+
+Intersect `standard_llm_endpoints()` with that set. The port answers because existence of a
+secret is a secret-layer question; handing this service a `VaultService` would give it two
+secret seams and defeat the port, and calling `resolve()` once per provider to catch
+`SecretNotFoundError` is control flow by exception plus eleven vault reads per list. The method
+returns the empty set for a project with no keys — that is an ordinary state, not an error, so
+there is nothing to catch here.
+
+WP2 implements it; this package calls it. A test double for the resolver in this package's unit
+tests must implement it too.
+
+### `catalog.py` — two pure functions over the SDK's static map
+
+```python
+def standard_llm_endpoint(*, provider_key: str) -> Optional[LLMEndpoint]:
+ """The generated endpoint for one provider: namespace=BUILTIN, slug=provider_key,
+ deployment_kind=DIRECT, models.allowlist from the map, settings at code defaults, no id and
+ no lifecycle — it is not a row. None for an unknown provider. Keeps the secrets
+ domain's *standard* vocabulary; the namespace it stamps says builtin (D27, §2.3)."""
+
+def standard_llm_endpoints() -> List[LLMEndpoint]:
+ """All eleven, existence-unfiltered. The service intersects with the vault's
+ provider keys, because existence is a fact about the project, not the catalogue (D20)."""
+```
+
+Source map: `sdks/python/agenta/sdk/utils/assets.py::supported_llm_models` — eleven providers
+today (`anthropic`, `cohere`, `deepinfra`, `gemini`, `groq`, `mistral`, `openai`, `openrouter`,
+`perplexityai`, `together_ai`, `minimax`; read in full, lines 6–201), imported the way
+`core/workflows/static_catalog.py` already imports the SDK for a static catalogue (§1). Every
+model id in the map is **already litellm-prefixed except the `openai` family**
+(`"anthropic/claude-..."`, `"gemini/gemini-..."`, `"gpt-5.5"` bare) — `standard_llm_endpoint`
+stores these ids verbatim in `LLMEndpointData.models.allowlist`; nothing here re-derives or strips a
+prefix.
+
+`provider_key` here is the secrets-domain `StandardProviderKind` value (`core/secrets/enums.py`
+lines 17–31) — fourteen members exist there against eleven in `supported_llm_models` (`anyscale`,
+`alephalpha`, `mistralai` have no catalogue entry); `standard_llm_endpoint` returns `None` for
+those three, consistent with its own docstring ("None for an unknown provider").
+
+### `registry.py` — the adapter registry and the routing split
+
+```python
+class LLMUpstreamRegistry:
+ def __init__(self, *, adapters: Dict[str, LLMUpstreamInterface]): ...
+ def get(self, key: str) -> LLMUpstreamInterface: ... # raises on a miss
+ def keys(self) -> list[str]: ...
+```
+
+Copied verbatim from `entities.md` §7.1's registry shape — the same structure as
+`ConnectionsGatewayRegistry` (`core/gateway/connections/registry.py`, read in full: `get`/`keys`/
+`items`, dict lookup, `ProviderNotFoundError` on a miss — WP7's `.get` raises the LLM-plane
+equivalent).
+
+`select_upstream(provider_key: str, deployment: LLMDeploymentKind) -> str` — a pure function,
+`entities.md` §7.1: *"picks the adapter key; the mocks register under a third key."* The split
+this package must implement, stated qualitatively in §7.1 (*"passthrough for upstreams that speak
+the caller's protocol... translated for providers whose wire differs and for cloud resellers
+whose auth is request signing"*) and made concrete here against the real endpoint map
+(`sdks/python/agenta/sdk/agents/connections/endpoints.py::_DIRECT_ENDPOINTS`, lines 11–21, read
+in full):
+
+| `deployment_kind` | Adapter | Why |
+|---|---|---|
+| `AZURE`, `BEDROCK`, `SAGEMAKER`, `VERTEX` | `translated` | Cloud-reseller auth is request signing, not a bearer header — never byte-for-byte by construction (§7.1) |
+| `CUSTOM` | `passthrough` | A `custom_provider` row is OpenAI-compatible by definition (D19/§2.4: "self-hosted server or third party") |
+| `DIRECT`, provider ∈ `{openai, groq, together_ai, openrouter, mistral, mistralai}` | `passthrough` | `_DIRECT_ENDPOINTS`' own base URLs say so: `groq` is literally `.../openai/v1`; Together, OpenRouter and Mistral's chat-completions wire is OpenAI-shaped |
+| `DIRECT`, provider ∈ `{anthropic, gemini, cohere, deepinfra, perplexityai, minimax}` | `translated` | Native wire differs from OpenAI's (Anthropic's Messages API and Gemini's `generateContent` are the clearest cases); litellm already knows each shape |
+
+This table is this package's own classification, not a transcription — `entities.md` states the
+rule, not the per-provider table. Verify each `DIRECT` provider against litellm's own adapter
+registry before shipping (a provider litellm added OpenAI-compatible support for after this
+document was written should move rows, not require an entities.md change — this table lives in
+code, not in the design set).
+
+### `providers/translated/adapter.py`
+
+```python
+class TranslatedLLMAdapter(LLMUpstreamInterface):
+ async def relay_chat_completion(
+ self, *, route, secret, context, body, headers,
+ ) -> LLMRelayResult: ...
+```
+
+Same interface WP6's `PassthroughLLMAdapter` implements (`entities.md` §7.1) — the two share a
+south port and nothing else. Internally: `json.loads(body)` to get parseable parameters (this
+adapter alone is exempt from byte-for-byte, per §7.1's own resolution: *"the library takes parsed
+parameters and re-serializes, which is not byte-for-byte... only there [passthrough] is the
+constraint honest"*), call `litellm.acompletion(model=..., **kwargs)` with:
+
+- `model` prefixed per `route.deployment` (`"azure/{model}"`, `"bedrock/{model}"`,
+ `"sagemaker/{model}"`, `"vertex_ai/{model}"`; for `DIRECT` non-OpenAI-shaped providers, the
+ model id already carries its litellm prefix from the catalogue, e.g. `"anthropic/claude-..."`).
+- secret kwargs assembled by the **same branch** the SDK's settings builder already runs —
+ `sdks/python/agenta/sdk/managers/secrets.py::get_provider_settings` /
+ `get_provider_settings_from_workflow` (both copies read in full, lines 172–260 and 308–404):
+ STEP 4 there merges `secret_provider_extras` (`CustomProviderDTO.provider.extras`) straight into
+ the kwargs dict passed to litellm — this adapter performs the identical merge, moved behind the
+ gateway, against `secret.secret.data` (`StandardProviderDTO`/`CustomProviderDTO`,
+ `core/secrets/dtos.py` lines 20–48). `api_version` (Azure) and `region` (Bedrock/Vertex) come
+ from `route.api_version`/`route.region`, not from the secret.
+- streaming: `stream=context.stream`; litellm's async generator is wrapped into an
+ `AsyncIterator[bytes]` re-serialized as OpenAI-shaped SSE chunks (litellm already emits
+ OpenAI-shaped `ChatCompletionChunk` objects for every provider it translates — this is the
+ library doing the normalization work D9 assigns it).
+- `GatewayUsage` populated from litellm's own `response.usage` (input/output tokens) and
+ `response._hidden_params["response_cost"]` or `litellm.cost_calculator.cost_per_token` (already
+ used elsewhere in this codebase for cost math, `sdks/python/agenta/sdk/utils/assets.py`
+ lines 206–230 and `api/oss/src/core/tracing/utils/trees.py`) — a real cost figure, not `None`,
+ for every successful call, unlike `PassthroughLLMAdapter`'s "usage only when the upstream
+ volunteers it."
+- litellm exceptions (`litellm.exceptions.*`, all subclass `openai.OpenAIError` in recent litellm
+ versions) are caught and re-raised as `LLMUpstreamError(provider_key=route.provider_key,
+ status_code=, detail=str(exc))`.
+
+### `service.py` — the relay path
+
+Reproduced from `entities.md` §8, the six-step body both planes share (D7 made concrete) — the
+LLM instance of it, which this package owns in full:
+
+```python
+async def relay_chat_completion(self, *, scope, namespace, name, body, headers):
+ target = await self._resolve_target(project_id=scope.project_id,
+ namespace=namespace, name=name)
+ # generated (catalog.py) or row (llm_endpoints_dao); LLMEndpointNotFoundError
+
+ context = parse_call_context(body) # WP6's parse_llm_call_context
+ self._check_allowlist(target, context) # LLMModelNotAllowedError — before
+ # any secret is touched
+ self._check_ceilings(target, context) # CeilingExceededError: reject,
+ # never clamp (D25)
+
+ decision = await self.policy.authorize(
+ scope=scope, permission=Permission.USE_LLM_ENDPOINTS,
+ target=target.as_policy_target(context),
+ )
+ if not decision.allowed:
+ await self.policy.record(scope=scope, target=..., decision=decision,
+ outcome=GatewayOutcome(status_code=403))
+ raise PolicyDeniedError(...)
+
+ secret = await self.resolver.resolve(
+ scope=scope, ref=target.secret_ref(), mode=SecretMode.PROJECT_ONLY,
+ ) # NONE-scheme targets (the mocks) skip this step
+
+ result = await self.upstream_registry.get(
+ select_upstream(target.provider_key, target.deployment_kind)
+ ).relay_chat_completion(route=target.route(context), secret=secret,
+ context=context, body=body, headers=headers)
+
+ await self.policy.record(scope=scope, target=..., decision=decision,
+ outcome=outcome_from(result, secret))
+ return result
+```
+
+`target` (the resolved generated-or-row value plus which namespace answered) is
+**service-internal** — it never crosses a layer, so it is not one of §4's DTOs and this package
+is free to shape it (a small dataclass, not a Pydantic model, matching the south-port result
+types' own reasoning in §7.1: "lives for one call... never validated, stored or serialized").
+
+**The LLM plane resolves with `SecretMode.PROJECT_ONLY`** (§7.2: "the LLM plane resolves with
+PROJECT_ONLY, the MCP plane with USER_OPTIONAL — the deliberate asymmetry... one billing identity
+for models, personal authority for tools"). This is not a parameter WP7 exposes; it is hardcoded
+at this call site, per §7.2's own note that the mode is "an argument at the call site," not the
+resolver's default.
+
+**Streaming and audit.** For a streamed `LLMRelayResult`, `result.body` has not been drained when
+this method returns — WP6's proxy drains it via `StreamingResponse` afterward. §8's own note:
+*"Usage is recorded even when the stream broke... for a streamed body the outcome's usage is read
+off the LLMRelayResult after exhaustion... the surface drains, the service records in a
+finally."* Concretely, this package must wrap `result.body` in a generator that runs
+`self.policy.record(...)` in its own `finally` (catching a mid-stream break too, recording
+`usage=None` if the crash happened before the adapter populated it) — **not** call
+`policy.record` unconditionally before returning, or the non-streaming ordering shown in the
+pseudocode above (record-after-relay) silently becomes record-before-drain for every streaming
+call, which breaks WP6's stated assumption that it owns nothing audit-related.
+
+### `list_models` — the backing method for `GET /v1/models` (R3, added at kickoff)
+
+```python
+async def list_models(self, *, scope, namespace, name) -> List[str]: ...
+```
+
+The route existed in `entities.md` §9 with no service method behind it; R3 named one. It is per
+endpoint, not global — the routes are `/builtin/{provider}/v1/models` and
+`/custom/{slug}/v1/models` — and it answers **from the allowlist**, so a harness that lists
+before calling sees exactly what policy will allow: the catalogue's allowlist for a generated
+`builtin` endpoint, the row's own allowlist for a `custom` one.
+
+Body: `_resolve_target` exactly as the relay does, then `policy.authorize` with
+`Permission.USE_LLM_ENDPOINTS` — it is a data-plane read that reveals configuration, so it is
+authorized like one — then return the slugs. No secret is resolved and no upstream is called.
+
+**No new DTO.** It returns `List[str]`, and WP6's proxy shapes the OpenAI list body inline; the
+data plane has no wire models (§6). Inventing a response DTO here would break the "do not invent
+names" rule for no gain.
+
+### The three orderings, restated as this package's obligations
+
+- **Allowlist before secret** (`_check_allowlist` before `self.resolver.resolve`) — a refused
+ model must not cost a vault read.
+- **The denial is recorded before the exception leaves** — `policy.record` runs inside the
+ `if not decision.allowed` branch, before `raise PolicyDeniedError`.
+- **Usage is recorded even when the stream broke** — the `finally`-wrapped generator above.
+
+## Contracts this package must honour
+
+- **An explicit empty allowlist refuses; an absent one does not** (§4.4's LLM-plane echo):
+ `models: {"allowlist": []}` means no model may be called, while `models: {}` constrains
+ nothing — the list was never written. Standard endpoints
+ expose their provider's whole catalogue — the static map **is** the allowlist for `builtin`.
+- **A model outside the allowlist never reaches `select_upstream`.** `_check_allowlist` runs
+ before adapter selection; `LLMModelNotAllowedError` carries `model`, `namespace`, `name`
+ (`entities.md` §5) — do not collapse this into a generic 400.
+- **`CeilingExceededError` names all three numbers** (D25): `ceiling`, `requested`, `allowed`,
+ `target`. Guards **our** ceilings (`LLMEndpointSettings.max_output_tokens`) only — never
+ second-guesses a model's own context window, which the upstream clamps or refuses in its own
+ shape.
+- **Generated endpoints take code defaults, never a stored row** (D20). `standard_llm_endpoint`
+ must not query `llm_endpoints_dao` for anything; existence is answered by a vault provider-key
+ check, never by a table read.
+- **`select_upstream` is pure** — no I/O, no DAO, no vault — callable in a unit test with a bare
+ `(provider_key, deployment)` pair.
+- **Registration under `"mock"` is unconditional** (D23): `LLMUpstreamRegistry(adapters={
+ "passthrough": ..., "translated": ..., "mock": MockLLMAdapter()})` in every environment; only
+ reachability (a seeded endpoint pointing at it) is environment-specific, and that is WP1/WP10's
+ concern, not this package's registration decision.
+
+## `api/entrypoints/routers.py` diff
+
+```diff
++from oss.src.core.gateways.llms.service import LLMGatewayService
++from oss.src.core.gateways.llms.registry import LLMUpstreamRegistry
++from oss.src.core.gateways.llms.providers.translated.adapter import TranslatedLLMAdapter
+...
+ llm_gateway_service = LLMGatewayService(
+ llm_endpoints_dao=llm_endpoints_dao,
+ policy=gateway_policy_service,
+ resolver=secret_resolver,
+ upstream_registry=LLMUpstreamRegistry(adapters={
+- "passthrough": PassthroughLLMAdapter(), # WP6's import, added at that merge
+- "translated": TranslatedLLMAdapter(),
+- "mock": MockLLMAdapter(), # WP5's import, added at that merge
++ "passthrough": PassthroughLLMAdapter(),
++ "translated": TranslatedLLMAdapter(),
++ "mock": MockLLMAdapter(),
+ }),
+ )
+```
+
+This whole block is where WP5, WP6 and WP7's imports converge — per `workstreams/README.md`,
+"Four packages need a line in it... the merge applies them together as one edit." WP7 authors the
+`LLMGatewayService(...)` construction itself (it owns `service.py`) and the dict literal's shape;
+the three adapter imports arrive from their respective packages at the same merge.
+
+## Tests
+
+Unit — nothing running:
+- `standard_llm_endpoint(provider_key="openai")` returns an `LLMEndpoint` with
+ `namespace=BUILTIN`, `slug="openai"`, `deployment=DIRECT`, `models.allowlist` matching
+ `supported_llm_models["openai"]` exactly; an unknown provider (`"not-a-provider"`) and the
+ three `StandardProviderKind` members absent from the catalogue (`anyscale`, `alephalpha`,
+ `mistralai`) all return `None`.
+- `standard_llm_endpoints()` returns exactly eleven entries.
+- `select_upstream` returns `"translated"` for every `(provider, AZURE/BEDROCK/SAGEMAKER/VERTEX)`
+ pair regardless of provider; `"passthrough"` for `(any, CUSTOM)`; the `DIRECT` split matches the
+ table above exactly, provider by provider.
+- `LLMGatewayService.relay_chat_completion` against stubbed DAO/policy/resolver/registry
+ (no real adapters): a refused permission raises `PolicyDeniedError` and calls
+ `policy.record` exactly once, before the exception propagates; a disallowed model raises
+ `LLMModelNotAllowedError` **without** calling `resolver.resolve` (assert the stub was never
+ invoked); a ceiling breach raises `CeilingExceededError` naming all three values; a successful
+ streaming call's `policy.record` fires only after the returned iterator is exhausted (assert
+ ordering with a spy).
+- `TranslatedLLMAdapter` against a stubbed `litellm.acompletion` (monkeypatched, not a real call):
+ `StandardProviderDTO` secret passes `api_key=...`; `CustomProviderDTO` secret merges
+ `provider.extras` into the call kwargs; `route.deployment=AZURE` prefixes the model
+ `"azure/..."` and passes `api_version`; `BEDROCK`/`VERTEX` pass `region`; a raised litellm
+ exception becomes `LLMUpstreamError`.
+- `list_endpoints`: with two provider-key secrets present, the merged list contains exactly those
+ two generated `builtin` entries plus every custom row for the project — no duplicates, no
+ entries for providers with no key.
+
+Contract — extends WP5's fixture (`test_mock_adapters_contract.py`): `TranslatedLLMAdapter`
+against the same `relay_chat_completion` → `LLMRelayResult` assertion, litellm mocked out. Nothing
+running.
+
+Acceptance — needs the compose stack, real provider secrets are **not** available in CI, so
+this suite runs against WP5's mocks for the relay-path shape and is otherwise a manual /
+staging-only check against real providers:
+- Every `DIRECT` provider in `supported_llm_models` reachable via `builtin/{provider}` returns a
+ 200 for a trivial prompt (staging only — needs real keys; document as manual, not automated in
+ CI).
+- A custom endpoint with `deployment=BEDROCK`, real AWS keys in a `custom_provider` secret,
+ reaches Bedrock through `TranslatedLLMAdapter` (staging only).
+- A custom endpoint with `models.allowlist=["gpt-4o"]` refuses a request for `"gpt-4o-mini"` with
+ `model_not_allowed`, verifiable against WP5's mocks alone (no real provider needed — the refusal
+ happens before any upstream call).
+
+## Done test
+
+```bash
+cd api && uv sync --locked && uv run --no-sync python run-tests.py # unit + contract, no deploy
+```
+
+Plus, once the compose stack and WP5/WP1 are up:
+
+```bash
+curl -X POST http://localhost/api/gateways/llms/custom//v1/chat/completions \
+ -H "Authorization: ApiKey " -d '{"model": "not-in-allowlist", "messages": []}'
+# expect 403 {"error": {"code": "model_not_allowed", ...}}
+```
+
+Matches `plan.md` WP7 verbatim: *"every provider and deployment pair reachable today is reachable
+through the gateway, including the cloud-reseller shapes, and a model outside a custom endpoint's
+list is refused."*
+
+## Out of scope
+
+- `apis/fastapi/gateways/llms/{proxy,utils}.py`, `providers/passthrough/` — WP6.
+- `apis/fastapi/gateways/llms/{router,models}.py` — WP10.
+- `core/gateways/llms/{dtos,types,interfaces}.py` — seed; WP7 imports, does not edit.
+- `dbs/postgres/gateways/llms/` — WP1.
+- `core/gateways/policy/{resolution,service}.py` — WP2, WP3.
+- Embeddings (`relay_embedding`) — deferred with the evaluator path (D15); the seam is declared
+ in `LLMUpstreamInterface` as a comment, not implemented.
+
+## Settled at kickoff — was "needs a ruling"
+
+- **`list_endpoints`'s constructor cannot reach the vault → option (b), R2.** The resolver port
+ gains `available_provider_keys(*, scope) -> Set[str]`; the constructor is untouched. Option (a)
+ would have given this service two secret seams, and (c) breaks §8's own DI rule for this
+ very service. The seed carries the new method, so nothing here is a mid-wave signature change.
+- **`GET /v1/models` has no backing method → `list_models`, R3.** Owned here, called by WP6.
+ Section above.
+
+## Missing from the design, needs a ruling
+
+- **litellm as a direct API dependency.** `api/pyproject.toml` does not list `litellm` — it
+ reaches the API today only transitively through the `agenta` SDK package (confirmed: `grep
+ litellm api/pyproject.toml` finds nothing; `core/tracing/utils/trees.py` already imports
+ `litellm.cost_calculator` on the strength of that transitive dependency). `TranslatedLLMAdapter`
+ needs `litellm.acompletion`, a heavier surface than one function. Whether to add `litellm` as an
+ explicit `api/pyproject.toml` dependency (recommended — transitive reliance on another
+ package's dependency is fragile) is not decided in any `v1/` document; raise it, do not decide
+ it silently in a commit.
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp8.md b/docs/design/gateways-research/v1/workstreams/specs-wp8.md
new file mode 100644
index 0000000000..48ad08985a
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp8.md
@@ -0,0 +1,431 @@
+# WP8 — MCP ingress and proxy
+
+Delivers the MCP gateway's data-plane HTTP surface: one router class,
+`MCPGatewayProxy`, declaring the three namespaced relay routes over the
+route grammar D27 settles, plus the one south-port adapter that turns a
+resolved route and a resolved secret into a real Streamable HTTP call
+against a `custom` server. Owns the transport and the byte-for-byte relay;
+does **not** own routing decisions, allowlist enforcement, secret
+resolution or the builtin/agenta merge — those are `MCPGatewayService`
+(WP9). This is the cut `workstreams/README.md` names: "on each plane,
+transport and domain are different packages."
+
+## Files
+
+New:
+- `api/oss/src/apis/fastapi/gateways/mcps/proxy.py` — `MCPGatewayProxy`
+ (§9): two POST routes plus the shared 405 handler for the stream
+ verbs.
+- `api/oss/src/apis/fastapi/gateways/mcps/utils.py` — `parse_mcp_call_context`
+ (§9): the one pure function that reads the caller's request for routing.
+- `api/oss/src/core/gateways/mcps/providers/http/adapter.py` — `HttpMCPAdapter`,
+ the `MCPUpstreamInterface` implementation for remote Streamable HTTP
+ servers (`custom`, per the wiring block in §9: registered under the
+ `"http"` key).
+
+Edited: none. `core/gateways/mcps/{dtos,types,interfaces}.py` are seed-owned
+and frozen; `core/gateways/mcps/service.py` and `registry.py` are WP9's.
+
+## Interfaces
+
+Reproduced verbatim from `entities.md` §7.1 and §9. Do not rename, do not
+add routes or parameters not listed here.
+
+### The route grammar — two routes, three shapes (D30, §2.3)
+
+```text
+/gateways/mcps/builtin/agenta/{slug:path} builtin/agenta/tools
+/gateways/mcps/builtin/{provider}/{integration}/{connection} builtin/composio/notion/my-notion
+/gateways/mcps/custom/{slug} custom/acme-notion
+```
+
+Both builtin shapes are served by **one** route, `/builtin/{provider}/{rest:path}`:
+the arity differs per provider, and two competing routes cannot share a
+provider segment. `split_builtin_path(provider, rest)` reads each provider's
+own grammar off the tail.
+
+- **`agenta`** takes the tail whole. An identifier this codebase owns may
+ carry `/` separators (`builtin/agenta/tools/search` is one endpoint whose
+ slug is `tools/search`, §2.3), which is why the route parameter is
+ FastAPI's `{rest:path}` converter — a plain `{slug}` component would 404
+ on the first nested identifier.
+- **`composio`** takes two components, `{integration}/{connection}` — the
+ brokered connection's own unique key minus the project and the provider
+ (`gateway_connections`'s `(project_id, provider_key, integration_key,
+ slug)`, §2.3). Neither is ever nested.
+- **`custom/{slug}`** — its own route, **one fixed path component**. The
+ slug is unique per project (`uq_mcps_endpoints_project_slug`), never
+ nested.
+
+The routes cannot collide with the CRUD router's paths sharing the
+same `/gateways/mcps` prefix (`router.py`, WP10) because every CRUD path
+starts with `endpoints`, and none of `builtin | standard | custom` can
+spell it (§9).
+
+### `MCPGatewayProxy` (§9)
+
+```python
+class MCPGatewayProxy:
+ def __init__(self, *, mcp_gateway_service: MCPGatewayService):
+ self.service = mcp_gateway_service
+ self.router = APIRouter()
+
+ # One URL per server (D16). Streamable HTTP, stateless JSON mode:
+ # POST carries JSON-RPC; GET/DELETE answer 405, as the runner's
+ # internal tool server already does. No version segment: the MCP
+ # protocol is a POST to the endpoint URL itself, revision negotiated
+ # in a header (§2.3). Two routes: builtin's arity differs per
+ # provider, so its tail is a catch-all that split_builtin_path
+ # divides; custom's slug is a single component.
+ self.router.add_api_route(
+ "/builtin/{provider}/{rest:path}",
+ self.relay_builtin, methods=["POST"],
+ operation_id="mcp_gateway_relay_builtin",
+ )
+ self.router.add_api_route(
+ "/custom/{slug}", self.relay_custom, methods=["POST"],
+ operation_id="mcp_gateway_relay_custom",
+ )
+ # the same two paths answer GET/DELETE with 405 via
+ # self.reject_stream_verbs, include_in_schema=False — elided
+```
+
+The two handlers "are thin ... they exist because the routes carry
+different path parameters, not because the behaviour differs" (§9): each
+parses headers via `parse_mcp_call_context`, reads `get_auth_scope()`, and
+delegates to `self.service.relay(...)` with its own namespace and path
+segments. `relay_builtin` passes `provider`, and `integration` when its
+provider's grammar carries one; `relay_custom` passes only `name` (the slug).
+
+`reject_stream_verbs` answers GET and DELETE on the same two paths with
+405, `include_in_schema=False` — the shape the runner's own internal MCP
+server already uses for the Streamable-HTTP stream-management verbs it does
+not implement (`services/runner/src/tools/tool-mcp-http.ts`, lines 367–371:
+`if (req.method !== "POST") { res.writeHead(405, ...) }`).
+
+### `parse_mcp_call_context` (§9)
+
+```python
+# apis/fastapi/gateways/mcps/utils.py
+def parse_mcp_call_context(*, headers: Dict[str, str]) -> MCPCallContext:
+ """Read the protocol's method and target headers (`mcp.md`, header-based
+ routing) — the body is never parsed for routing. Header names are pinned
+ against the 2026-07-28 revision at implementation time, in this one file."""
+```
+
+`MCPCallContext` (seed, `core/gateways/mcps/dtos.py`, §4.4):
+
+```python
+class MCPCallContext(BaseModel):
+ method: str
+ target: Optional[str] = None
+```
+
+The exact header names are **not** given in `entities.md` — the docstring
+above says so explicitly ("pinned ... at implementation time, in this one
+file"), so this is deferred implementation work, not a design gap. `mcp.md`
+only establishes that the revision moved method and target routing onto
+required HTTP headers (§"Three changes that are explicitly about
+intermediaries" — "Header-based routing").
+
+### The south port: `MCPUpstreamInterface` and `HttpMCPAdapter` (§7.1)
+
+```python
+# core/gateways/mcps/interfaces.py (seed, frozen — read only)
+
+@dataclass
+class MCPRelayResult:
+ """A single JSON answer. The gateway targets the stateless revision in JSON
+ mode — one request, one `application/json` response, 202 for notifications
+ (`mcp.md`; the in-tree precedent is the runner's internal tool server,
+ services/runner/src/tools/tool-mcp-http.ts). No SSE leg to carry."""
+ status_code: int
+ headers: Dict[str, str]
+ body: bytes
+
+
+class MCPUpstreamInterface(ABC):
+ @abstractmethod
+ async def relay(
+ self,
+ *,
+ route: MCPResolvedRoute,
+ auth: MCPRelayAuth,
+ #
+ context: MCPCallContext,
+ body: bytes,
+ headers: Dict[str, str],
+ ) -> MCPRelayResult:
+ """Transparent per-server relay (D16): same method, same body, same
+ response, with only the route and the authorization changed. `auth` is
+ the discriminated union from §4.4 — MCPDirectAuth for agenta and custom,
+ MCPBrokeredAuth for builtin — so the two secret mechanisms cannot be
+ conflated by an adapter (D27). Raises MCPUpstreamError on transport
+ failure; protocol-level errors from the server are NOT exceptions — they
+ are the response body, relayed, because the server's own failure reason
+ is what lets the model correct itself (the pass-through rule in
+ api/AGENTS.md's error-envelope scope)."""
+ ...
+```
+
+`MCPResolvedRoute` (seed, §4.4):
+
+```python
+class MCPResolvedRoute(BaseModel):
+ url: str
+ headers: Dict[str, str] = Field(default_factory=dict)
+ settings: MCPEndpointSettings = Field(default_factory=MCPEndpointSettings)
+```
+
+`MCPDirectAuth` / `MCPRelayAuth` (seed, §4.4 — reproduced so the adapter's
+input shape is unambiguous):
+
+```python
+class MCPDirectAuth(BaseModel):
+ """agenta + custom: the secret is ours to present — an oauth_grant
+ resolved from the vault (§7.2), or nothing for a NONE-scheme target."""
+ secret: Optional[ResolvedSecret] = None
+
+class MCPBrokeredAuth(BaseModel):
+ """builtin: the integrations domain brokered the authorization and holds the
+ secret upstream; what we carry is its connection row."""
+ connection: Connection
+
+MCPRelayAuth = Union[MCPDirectAuth, MCPBrokeredAuth]
+```
+
+`HttpMCPAdapter(MCPUpstreamInterface)` implements `relay()` against
+`MCPDirectAuth` only — it is registered under the `"http"` key and is only
+ever reached via the `custom` namespace (§4.4: "builtin and custom are two
+secret mechanisms ... the fork is real behaviour ... at the south
+port"). It never receives `MCPBrokeredAuth`; that arm is `ComposioMCPAdapter`
+(a separate provider, out of this package's ownership per
+`workstreams/README.md`'s file table, and out of scope entirely — no work
+package in wave 1 owns it, since `builtin` MCP servers are not called in
+C1 under D23).
+
+Body: POST `body` verbatim to `route.url`, with `route.headers` (the
+endpoint's own non-secret configured headers, §2.4) merged under the
+caller's forwarded `headers` (already stripped of Agenta's own
+authorization, §7.1's LLM-side analog), plus one derived header when
+`auth.secret` is present. The exact translation from a `ResolvedSecret`
+into a wire header depends on which secret kind backs it —
+`OAuthGrantSettingsDTO.access_token` / `.token_type` (`entities.md` §4.5) is
+the only populated shape reachable in this wave, and it maps to
+`Authorization: {token_type} {access_token}`. **In C1 this branch
+is unreachable in practice**: D23 restricts wave 1's reachable MCP targets
+to unauthenticated servers (`auth_mode = NONE`) and the mocks, and OAuth
+`oauth_grant` secrets do not exist until WP16/WP17 (wave 3). Implement the
+secret branch so it type-checks against `entities.md`'s frozen shapes, but do
+not build integration tests that depend on a real one existing — there is
+nothing to resolve yet.
+
+No JSON-RPC parsing happens in the adapter. `MCPRelayResult` carries
+whatever `status_code`, `headers` and `body` the upstream returned,
+untouched — the pass-through discipline in `api/AGENTS.md`'s error-envelope
+scope ("the gateway and workflow-tool arms carry their upstream's shape").
+Any tool-list filtering by policy happens one layer up, in
+`MCPGatewayService.relay` (WP9) — that requires inspecting the JSON body,
+which is a service-level concern, not this adapter's.
+
+## Contracts this package must honour
+
+- **Transparent per server (D16).** Tool names, schemas, error bodies pass
+ through byte for byte. The proxy must never rewrite a name; the slug-grammar
+ precedent for why is already in the tree
+ (`apis/fastapi/tools/utils.py::parse_tool_slug`, cited in `entities.md`
+ §2.3).
+- **No wire models on the proxy** (§6). The data plane has no
+ `models.py` entry — request and response bodies are relayed as bytes.
+- **`AuthScope` over `request.state`** (§9, D2). Handlers call
+ `get_auth_scope()`, never `request.state.project_id` /
+ `request.state.user_id`.
+- **Exceptions are mapped once, not duplicated.** `handle_gateway_exceptions()`
+ lives in `apis/fastapi/gateways/exceptions.py`, which the **seed** owns (R1) —
+ three packages need the decorator, so no one package can. It is already on the
+ branch when this package starts; import it, do not write it.
+- **`MCPAuthRequiredError` maps to 409, carrying `GatewayConnectionRequirement`**
+ — an interaction, not a failure (D17). Unreachable in wave 1 (no OAuth
+ targets exist yet), but the mapping must exist so nothing breaks when
+ wave 3 lands.
+- **Streaming is not this plane's concern.** Unlike the LLM proxy
+ (`StreamingResponse` over an `AsyncIterator[bytes]`), `MCPRelayResult.body`
+ is `bytes` — one JSON answer, no SSE leg (§7.1). Do not adapt LLM-plane
+ streaming code into this adapter.
+- **The allowlist check happens before secret resolution, in the
+ service, not here** (§8: "Allowlist before secret. A refused model or
+ tool must not cost a vault read"). WP8's adapter must not be the place
+ that decides whether a tool is allowed — it relays whatever
+ `MCPGatewayService.relay` hands it after that decision already passed.
+
+## The SSRF guard at relay time (D28) — this package owns the relay half
+
+A `custom` endpoint's URL was typed by a user and **this adapter is the process that
+connects to it**. Without the guard, a tenant can point an endpoint at
+`http://169.254.169.254/` and have the gateway fetch cloud secrets with our network
+position. WP10 gates the URL at registration; that is not sufficient on its own, because
+a hostname's DNS answer can change between the row being saved and this relay running.
+
+**Write no new guard.** `api/oss/src/core/webhooks/utils.py` already implements it, and
+three call sites already import it across domains (webhook delivery, EE's organization
+OIDC issuer, the custom-provider URL on a secret) — so the cross-domain import has
+precedent. Blocked means private, loopback, link-local (which is what covers the metadata
+address), reserved, multicast or unspecified, plus plain `http`.
+
+```python
+from oss.src.core.webhooks.utils import resolve_validated_webhook_ip
+```
+
+In `HttpMCPAdapter.relay`, before the outbound POST:
+
+1. `resolved_ip = resolve_validated_webhook_ip(route.url)` — raises `ValueError` on a
+ blocked target. Translate it into `MCPUpstreamError`; it is a transport-layer refusal,
+ not a protocol error, so it must not be relayed as an upstream body.
+2. **Connect to the returned IP, not the hostname.** This is the part that is easy to drop
+ and is the only reason the function returns a value. Copy the pinning from
+ `api/oss/src/core/webhooks/delivery.py::send_webhook_request`: swap the host in the URL
+ for the literal IP (bracketing IPv6, preserving an explicit port), set `Host` back to
+ the original authority, and pass `extensions={"sni_hostname": parsed.hostname}` so TLS
+ still validates against the real name. Re-resolving the hostname in the HTTP client
+ reopens the rebind window the check just closed.
+3. **Distinguish the two failure messages.** `resolve_validated_webhook_ip` raises with
+ "could not be resolved" for a DNS failure and "blocked IP range" for a guard hit — keep
+ them distinct in the error text, so an operator reading a hostname typo does not see a
+ security rejection. The runner's guard makes the same distinction on purpose
+ (`services/runner/src/engines/sandbox_agent/mcp.ts:191`).
+
+**Only the `custom` namespace needs this.** `agenta` targets are ours and `builtin` targets
+are the broker's — neither URL comes from a user. Guard on the namespace rather than
+guarding unconditionally, or the mocks (WP5, reachable on a compose host) fail their own
+acceptance tests.
+
+**Two facts about the flag, both load-bearing.** `AGENTA_INSECURE_EGRESS_ALLOWED` defaults
+to `true` (`api/oss/src/utils/env.py`), and the guard is a no-op when it is on — so a unit
+test that does not set it `false` will pass while proving nothing. Set it explicitly in the
+test. The second: nothing in this repo's deployment configuration sets it, so the
+C1 verification runs with it `false`.
+
+**The host allowlist.** Carry the runner's escape hatch so a self-hoster can permit one
+known internal server without disabling the guard globally — the runner reads
+`AGENTA_AGENT_MCPS_HOST_ALLOWLIST` (comma-separated hostnames). Add the API-side equivalent
+through `api/oss/src/utils/env.py` and the shared `env` object, never `os.getenv` in feature
+code (`api/AGENTS.md`).
+
+## Missing from the design, needs a ruling
+
+- **Exact HTTP header names for MCP routing are undecided by design**
+ (noted above; `entities.md` explicitly defers this to implementation
+ time, so it is not treated as a gap needing a ruling, only as
+ implementation work this package must do first).
+
+## Test layer
+
+Per the house rule: unit tests import freely and need nothing running;
+anything needing Postgres, Redis or the API is integration or acceptance.
+
+- `parse_mcp_call_context` — **unit**. Pure function; feed representative
+ header dicts (both routing headers present, one missing, malformed
+ values) and assert the parsed `MCPCallContext` or the raised error.
+- `HttpMCPAdapter.relay()` — **unit**. Run against an in-process mock HTTP
+ server (or an `httpx` mock transport) standing in for the upstream — no
+ real network, no real MCP server. Assert: body passed through
+ byte-for-byte; `route.headers` merged under caller headers; no
+ `Authorization` header added when `auth.secret is None`; the derived
+ `Authorization` header is correct when a secret is present; a
+ connection failure raises `MCPUpstreamError`; a non-2xx JSON-RPC error
+ body from the mock upstream is returned as `MCPRelayResult`, not raised.
+- `MCPGatewayProxy` routing (which handler each path reaches, the 405s) —
+ **unit**. Mount the router in a bare `FastAPI()` app with `TestClient`,
+ a mock `MCPGatewayService` (a stub whose `relay()` returns a canned
+ `MCPRelayResult` and records its call arguments), and a mock
+ `get_auth_scope()`. Assert: `POST /builtin/agenta/tools/search` reaches
+ `relay_builtin` with `provider="agenta", name="tools/search"` (proving the
+ catch-all nests correctly); `POST /builtin/composio/notion/my-notion`
+ reaches the same handler with `provider="composio", integration="notion",
+ name="my-notion"`; `POST /custom/acme-notion` reaches `relay_custom` with
+ `name="acme-notion"`; `GET`/`DELETE` on any of the three return 405. No
+ Postgres, no real service — this is in-process ASGI against a mock,
+ which the house rule's "nothing running" test still passes (no
+ external process, no network).
+- Byte-for-byte relay end to end, and the tool-outside-allowlist refusal —
+ **acceptance**, part of C1. Needs the deployed stack: real
+ Postgres (WP1's tables), the mock MCP server running as a compose
+ service (WP5, D23), and WP9's real `MCPGatewayService`. WP8 does not own
+ writing this test alone — it is the shared C1 suite
+ (`plan.md`) — but WP8's own "done" claim rests on it passing.
+
+## Executable done test
+
+Plan.md's stated done condition for WP8: *"list and call both relay
+unchanged and a tool outside the allowlist is refused."* Concretely, once
+WP8 and WP9 are both merged at IM2 and the stack is deployed:
+
+```text
+POST /gateways/mcps/builtin/agenta/ {"method":"tools/list", ...}
+ -> 200, body identical to the mock server's own tools/list response
+
+POST /gateways/mcps/builtin/agenta/ {"method":"tools/call","tool":"", ...}
+ -> 200, body identical to the mock server's own tool result
+
+POST /gateways/mcps/builtin/agenta/ {"method":"tools/call","tool":"", ...}
+ -> 403, MCPToolNotAllowedError mapped through handle_gateway_exceptions
+```
+
+## Out of scope
+
+- Everything in `core/gateways/mcps/service.py` and `registry.py` — target
+ resolution, the allowlist check, secret resolution, the
+ three-source `list_endpoints` merge, tool-list filtering by policy —
+ **WP9**.
+- `ComposioMCPAdapter` (the `builtin` south-port adapter) and anything
+ touching `MCPBrokeredAuth` — not owned by any wave-1 package; `builtin`
+ MCP servers are not reachable under D23 in C1.
+- The management CRUD router (`apis/fastapi/gateways/mcps/{router,models}.py`)
+ — **WP10**. `apis/fastapi/gateways/exceptions.py` — **the seed** (R1),
+ already present; import it.
+- OAuth, consent, step-up — wave 3 (WP16–WP20). `MCPAuthRequiredError`'s 409
+ mapping must exist (it is part of the frozen exceptions table) but is
+ unreachable until then.
+- Endpoint configuration (timeouts, ceilings, extra headers) — WP21, after
+ C3.
+
+## `api/entrypoints/routers.py` diff
+
+This file is never owned by a package (`workstreams/README.md`). WP8
+contributes two fragments, applied together with WP6's, WP7's, WP9's and
+WP10's fragments at the IM2 merge (the merge that follows wave 1's second
+fan-out, per `plan.md`).
+
+Adapter registration (into the `MCPUpstreamRegistry` construction WP9
+owns — see `specs-wp9.md`'s diff for the surrounding block; this package
+contributes only the `"http"` entry):
+
+```diff
+ upstream_registry=MCPUpstreamRegistry(adapters={
+- # WP9 constructs this dict; WP8, WP5 and (later) the Composio
+- # adapter each contribute one entry, combined at the IM2 merge.
++ "http": HttpMCPAdapter(), # custom: MCPDirectAuth
+ }),
+```
+
+Proxy construction and mount:
+
+```diff
++from oss.src.apis.fastapi.gateways.mcps.proxy import MCPGatewayProxy
++
++mcp_gateway_proxy = MCPGatewayProxy(mcp_gateway_service=mcp_gateway_service)
+```
+
+```diff
++app.include_router(
++ router=mcp_gateway_proxy.router,
++ prefix="/gateways/mcps",
++ include_in_schema=False,
++)
+```
+
+(`mcp_gateway_service` here is the shared instance WP9 constructs; the
+exact local variable names above are wiring convenience, not symbols
+`entities.md` names — the class name `MCPGatewayProxy`, its constructor
+signature, and the mount's `prefix`/`include_in_schema` kwargs are the load-
+bearing parts, taken verbatim from §9.)
diff --git a/docs/design/gateways-research/v1/workstreams/specs-wp9.md b/docs/design/gateways-research/v1/workstreams/specs-wp9.md
new file mode 100644
index 0000000000..da9951a026
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/specs-wp9.md
@@ -0,0 +1,430 @@
+# WP9 — MCP registry and tool allowlist
+
+Delivers `MCPGatewayService` and `MCPUpstreamRegistry`: target resolution
+across the three namespaces, the per-server tool allowlist check, secret
+resolution dispatch (the two-mechanism fork), the builtin/agenta/custom
+merge for listing, and the relay orchestration WP8's proxy calls into. This
+is the domain half of the transport/domain cut
+(`workstreams/README.md`): WP8 owns the HTTP surface and the byte-for-byte
+relay adapter; this package owns the service, the registry and the
+allowlist.
+
+## Files
+
+New:
+- `api/oss/src/core/gateways/mcps/service.py` — `MCPGatewayService` (§8).
+- `api/oss/src/core/gateways/mcps/registry.py` — `MCPUpstreamRegistry` (§7.1).
+
+Edited: none. `core/gateways/mcps/{dtos,types,interfaces}.py` are seed-owned
+and frozen. `dbs/postgres/gateways/mcps/` (the DAO implementation this
+package calls through the `MCPEndpointsDAOInterface` port) is **WP1**'s,
+already landed by IM1.
+
+## Interfaces
+
+Reproduced verbatim from `entities.md` §7.1 and §8. Do not rename, do not
+add methods not listed here.
+
+### `MCPUpstreamRegistry` (§7.1)
+
+```python
+class MCPUpstreamRegistry:
+ def __init__(self, *, adapters: Dict[str, MCPUpstreamInterface]): ...
+ def get(self, key: str) -> MCPUpstreamInterface: ...
+ def keys(self) -> list[str]: ...
+```
+
+"Registries copy an existing shape verbatim" (§7.1) — four structurally
+identical registries already exist. The closest, read and confirmed:
+`api/oss/src/core/gateway/connections/registry.py::ConnectionsGatewayRegistry`:
+
+```python
+class ConnectionsGatewayRegistry:
+ def __init__(self, *, adapters: Dict[str, ConnectionsGatewayInterface]):
+ self._adapters = adapters
+
+ def get(self, provider_key: str) -> ConnectionsGatewayInterface:
+ adapter = self._adapters.get(provider_key)
+ if not adapter:
+ raise ProviderNotFoundError(provider_key)
+ return adapter
+
+ def keys(self) -> list[str]:
+ return list(self._adapters.keys())
+```
+
+`MCPUpstreamRegistry.get` raises on a miss, per §7.1's own comment
+(`# raises on a miss`) — reuse `MCPEndpointNotFoundError` or a dedicated
+registry-miss exception only if `entities.md` names one; it does not, so
+raise the existing `ProviderNotFoundError`-shaped pattern is **not**
+available (that class lives in the connections domain, out of bounds per
+D15/§4.1's "the gateways define their own vocabulary"). Add no new public
+exception name beyond what `core/gateways/mcps/types.py` already declares;
+if a registry-miss needs its own type, that is a "missing from the design"
+item (below), not a name to invent silently.
+
+### `MCPGatewayService` (§8)
+
+```python
+class MCPGatewayService:
+ def __init__(
+ self,
+ *,
+ mcp_endpoints_dao: MCPEndpointsDAOInterface,
+ policy: GatewayPolicyService,
+ resolver: SecretsResolverInterface,
+ connections_service: ConnectionsService,
+ upstream_registry: MCPUpstreamRegistry,
+ ) -> None: ...
+```
+
+`connections_service` is the existing connections-domain service
+(`core/gateway/connections/service.py`), passed by reference — a concrete
+service object, not a port, per `entities.md` §8's own note ("the interface
+rule is enforced at the DAO and adapter seams, not between services"). It is
+required, not optional: `list_endpoints`'s `builtin` merge and `relay`'s
+brokered-target resolution both call through it (below), so the constructor
+must accept it even though nothing in this package instantiates it.
+
+Method surface, in full, with the wave-1 implementation split marked:
+
+```python
+class MCPGatewayService:
+ # --- management --------------------------------------------------------- #
+
+ async def create_endpoint(self, *, project_id, user_id, endpoint) -> Optional[MCPEndpoint]: ...
+ async def fetch_endpoint(self, *, project_id, endpoint_id) -> Optional[MCPEndpoint]: ...
+ async def edit_endpoint(self, *, project_id, user_id, endpoint) -> Optional[MCPEndpoint]: ...
+ async def delete_endpoint(self, *, project_id, endpoint_id) -> bool: ...
+ async def query_endpoints(self, *, project_id, endpoint=None, windowing=None) -> List[MCPEndpoint]: ...
+ async def list_endpoints(self, *, project_id) -> List[MCPEndpoint]: ...
+ # The three-source merge (D30): builtin/agenta entries from code, builtin/composio entries
+ # generated from the Composio catalog with their connection state resolved
+ # through the existing connections service, custom rows from the DAO.
+
+ # No connect/consent verbs here. Composio (builtin) servers connect
+ # through the existing integrations connect flow, whose state machine and
+ # redirect it already drives. Whatever the OAuth checkpoint (WP17, WP18)
+ # ends up wiring for a custom server writes the vault secret, then calls
+ # edit_endpoint to point secret_id at it (§2.1) — the same full PUT every
+ # other field on the row goes through, not a document or verb of its own.
+
+ # --- the data plane (WP8) ----------------------------------------------- #
+
+ async def relay(
+ self, *, scope, namespace, name, provider=None, integration=None,
+ context, body, headers,
+ ) -> MCPRelayResult: ...
+ # name is the last path component (§2.3): the agenta slug (possibly nested),
+ # the custom slug, or the builtin connection slug — in which case provider
+ # and integration carry the other two segments
+```
+
+This package declares no connect/consent verbs at all — there is no grant
+row to create, revoke or query, so there is nothing here for a later
+package to fill in. WP17's `TokenStorage` adapter (`core/gateways/mcps/
+token_storage.py`) is where a custom server's OAuth exchange actually
+lands: it resolves through the endpoint's own `secret_id` and writes the
+`oauth_grant` secret in place, calling this package's `edit_endpoint` the
+first time to point `secret_id` at it. WP9 owns none of that; it only has
+to make sure `edit_endpoint` remains the one door every field on the row
+goes through, including this one.
+
+### Management CRUD — thin DAO delegation
+
+`create_endpoint` / `fetch_endpoint` / `edit_endpoint` / `delete_endpoint` /
+`query_endpoints` delegate to `MCPEndpointsDAOInterface` (WP1's
+implementation, already landed):
+
+```python
+# core/gateways/mcps/interfaces.py (seed, frozen — read only)
+
+class MCPEndpointsDAOInterface(ABC):
+ async def create_endpoint(self, *, project_id: UUID, user_id: UUID, endpoint: MCPEndpointCreate) -> Optional[MCPEndpoint]: ...
+ async def fetch_endpoint(self, *, project_id: UUID, endpoint_id: UUID) -> Optional[MCPEndpoint]: ...
+ async def fetch_endpoint_by_slug(self, *, project_id: UUID, slug: str) -> Optional[MCPEndpoint]: ...
+ async def edit_endpoint(self, *, project_id: UUID, user_id: UUID, endpoint: MCPEndpointEdit) -> Optional[MCPEndpoint]: ...
+ async def delete_endpoint(self, *, project_id: UUID, endpoint_id: UUID) -> bool: ...
+ async def query_endpoints(self, *, project_id: UUID, endpoint: Optional[MCPEndpointQuery] = None, windowing: Optional[Windowing] = None) -> List[MCPEndpoint]: ...
+```
+
+### `list_endpoints` — the three-source merge (D30, §8)
+
+The one read that spans namespaces. There is no `catalog.py` under
+`core/gateways/mcps/` (unlike the LLM plane, which has one explicitly —
+compare the top-of-document file tree in `entities.md` §0: `llms/` lists
+`catalog.py`, `mcps/` does not). The merge logic therefore lives directly in
+`service.py`:
+
+- **`agenta`** — entries defined in code. In wave 1 (D23) these are the
+ mocks WP5 registers; nothing in `entities.md` names a public function or
+ module for this enumeration (contrast the LLM plane's
+ `standard_llm_endpoint`/`standard_llm_endpoints`, which are explicitly
+ named in §8). Keep the agenta enumeration a private, service-internal
+ list — do not invent a public symbol name `entities.md` does not give.
+- **`builtin`** — generated from the Composio catalog, "with their
+ connection state resolved through the existing connections service"
+ (§8). This is a **real integration with an already-landed dependency**,
+ not a stub: `ConnectionsService` (`api/oss/src/core/gateway/connections/service.py`,
+ read and confirmed) already exposes `query_connections(*, project_id,
+ provider_key=None, integration_key=None, is_active=True) ->
+ List[Connection]`. `list_endpoints` calls it filtered to
+ `provider_key="composio"` and maps each `Connection` row into an
+ `MCPEndpoint` with `namespace=BUILTIN`, `connection_id`, `provider_key`,
+ `integration_key` and `slug` stamped from the connection (§4.4's
+ documented fields on `MCPEndpoint`). D23 restricts wave 1's **reachable
+ call targets**, not catalog **listing** — `scope-checklist.md` puts MCP
+ registry work in wave 1 unconditionally, so this branch must be
+ genuinely implemented, even though nothing calls through it yet.
+- **`custom`** — `MCPEndpointsDAOInterface.query_endpoints` rows, mapped
+ 1:1, `namespace=CUSTOM` (the DTO's own default).
+
+Connection-state derivation (§8, verbatim): *"`GatewayConnectionState` is
+derived in `MCPGatewayService` per namespace: `READY` iff the endpoint's
+scheme is NONE (every `agenta` entry), or — `custom` — `secret_id` is set
+and `flags.is_valid` is true, or — `builtin` — the referenced connection row
+is active and valid, read through the existing connections service;
+`NEEDS_AUTH` otherwise for an OAuth or builtin endpoint ... `NEEDS_INPUT`
+reserved for the api_key scheme (deferred with its kind, D14)."* Implement
+this for real in wave 1: a `custom` OAuth-scheme endpoint with `secret_id`
+still `None` correctly derives `NEEDS_AUTH` today — reading the column
+directly off the row, not a special case.
+
+### The two secret mechanisms — the fork is at the south port, not the entity (D27, §4.4)
+
+Do not build a discriminated endpoint type (`MCPBuiltinEndpoint` /
+`MCPCustomEndpoint`) — `entities.md` explicitly rejects that split: "the
+endpoint's identity, config and listing shape are one — only the secret
+path forks, and forking every DAO and service signature for a difference
+that appears at secret time would spread the fork everywhere it does
+not matter." The fork is expressed once, in `relay`'s secret-resolution
+step, by constructing the right `MCPRelayAuth` arm:
+
+```python
+class MCPDirectAuth(BaseModel):
+ """agenta + custom: the secret is ours to present — an oauth_grant
+ resolved from the vault (§7.2), or nothing for a NONE-scheme target."""
+ secret: Optional[ResolvedSecret] = None
+
+class MCPBrokeredAuth(BaseModel):
+ """builtin: the integrations domain brokered the authorization and holds the
+ secret upstream; what we carry is its connection row."""
+ connection: Connection
+
+MCPRelayAuth = Union[MCPDirectAuth, MCPBrokeredAuth]
+```
+
+For `agenta`/`custom` targets: resolve via `SecretsResolverInterface.resolve()`
+(WP2, already landed) with `mode=SecretMode.PROJECT_ONLY` — every gateway
+secret is project-owned (`../out-of-scope.md`). For a `custom` OAuth-scheme
+endpoint the ref is `BoundSecretRef(secret_id=endpoint.secret_id)` — the
+endpoint's own column is the project-level answer, so this package needs no
+grants dependency to resolve it. `NONE`-scheme targets skip resolution
+entirely (`secret=None`). For `builtin` targets: **never** call the
+resolver — "its secret lives at the broker and never enters our vault"
+(§4.4). Instead fetch the connection row directly via `ConnectionsService`
+(the same instance `list_endpoints` already uses) and wrap it in
+`MCPBrokeredAuth`. Routing `builtin` through the resolver with a third ref
+arm was explicitly rejected (§7.2) — do not add one.
+
+### `relay` — the six-step orchestration (§8, D7 applied to MCP)
+
+`entities.md` gives the full pseudocode for the LLM plane
+(`relay_chat_completion`) and states "both planes walk the same six steps ...
+only the nouns differ." Implement the MCP equivalent following that shape
+exactly:
+
+1. **Resolve target.** By namespace: `agenta` → the code-defined entry
+ matching `name`; `builtin` → the connection row matching
+ `(provider, integration, name)` via `ConnectionsService`; `custom` →
+ `MCPEndpointsDAOInterface.fetch_endpoint_by_slug(project_id, slug=name)`.
+ Raise `MCPEndpointNotFoundError` (constructed with `namespace`,
+ `provider`, `integration`, `name` per its signature in §5) when nothing
+ resolves.
+2. **Allowlist before secret.** `_check_allowlist(target, context)`:
+ when `context` names a specific tool, refuse anything the target's
+ `tools` filter disallows with
+ `MCPToolNotAllowedError` — **before** any secret resolution or
+ upstream call (§8: "A refused model or tool must not cost a vault
+ read").
+3. **Authorize.** `self.policy.authorize(scope=scope,
+ permission=Permission.USE_MCP_ENDPOINTS, target=...)`. On denial, record
+ the decision via `self.policy.record(...)` **before** raising
+ `PolicyDeniedError` — the audit ordering rule (§8: "The denial is
+ recorded before the exception leaves").
+4. **Resolve secret**, per the two-mechanism fork above.
+5. **Dispatch.** `self.upstream_registry.get().relay(
+ route=..., auth=..., context=..., body=..., headers=...)`. The
+ namespace→adapter-key mapping (`agenta`→`"mock"` in wave 1 — the wiring
+ block's own comment: `"mock": MockMCPAdapter(), # serves the
+ builtin/agenta mocks (D23)`; `builtin/composio`→`"composio"`; `custom`→`"http"`)
+ is a private implementation detail of this file — `entities.md` names no
+ public function for it on the MCP plane (contrast the LLM plane's
+ `select_upstream`, explicitly named in §7.1). Do not invent a public
+ name for it.
+6. **Record and, for a list method, filter.** `self.policy.record(...)`
+ with the real outcome. When `context.method` is a list operation
+ (`tools/list`), apply the tool policy to the **response body**: "an
+ `INCLUDE` tool policy filters the list result — entries dropped whole,
+ never renamed — while secret death does not filter anything (D18).
+ Policy hides what may never be called; secret state never hides
+ what policy allows" (§8, verbatim). This is the one place `relay`
+ inspects and rewrites the upstream's JSON body rather than relaying it
+ untouched — narrowly scoped to dropping whole tool entries by name, never
+ renaming or editing a surviving entry.
+
+`target` in this pseudocode is service-internal (the resolved row or
+generated entry, plus which namespace answered) and never crosses a layer —
+it is not a DTO in §4 and must not be added to `dtos.py`.
+
+## Contracts this package must honour
+
+- **D18 — a dead secret does not hide tools.** A `custom` OAuth endpoint
+ with `flags.is_valid=False` still lists its tools; only the call
+ fails. Do not let secret health leak into the list-filtering step —
+ only the `tools` filter trims the list.
+- **D19/D20 — an endpoint is a server, not a tool; only custom endpoints are
+ rows.** `list_endpoints` must never persist a generated `agenta`/`builtin`
+ entry; they are computed on every call.
+- **An explicit empty allowlist refuses; an absent one does not** (§4.4):
+ `tools: {"allowlist": []}` refuses every tool, while `tools: {}` constrains
+ nothing. Only a written list narrows.
+- **Every `resolve()` call this package makes uses `mode=SecretMode.PROJECT_ONLY`**
+ (§7.2) — the mode logic itself lives in WP2's resolver, already correct;
+ this package must call it with the right mode, not invent a fallback of
+ its own.
+- **`MCPUpstreamRegistry.get` raises on a miss** (§7.1) — never returns
+ `None` and silently no-ops.
+
+## Missing from the design, needs a ruling
+
+- **No named exception for a registry key miss.** `entities.md` §7.1 says
+ `get()` "raises on a miss" but does not name the exception type for the
+ MCP registry (the LLM plane's analog is likewise unnamed). Do not import
+ `ProviderNotFoundError` from `core/gateway/connections/exceptions.py` —
+ that is a different domain's exception, out of bounds per D15/§4.1. Raise
+ a plain, already-declared `GatewaysError` or `MCPUpstreamError` with a
+ message naming the missing key until a ruling adds a dedicated type; do
+ not invent a new public exception name unilaterally.
+- **No public name for the namespace→adapter-key selector.** Noted above —
+ implement it as a private function, do not add it to `entities.md`'s
+ public surface.
+
+## Test layer
+
+- `MCPUpstreamRegistry.get`/`keys` — **unit**. Trivial, mirrors
+ `ConnectionsGatewayRegistry`'s own tests if any exist, or a fresh minimal
+ test: registering two mock adapters, `get()` returns the right one,
+ `get()` on a missing key raises, `keys()` lists both.
+- `MCPGatewayService`'s CRUD delegation — **unit**, with a mock
+ `MCPEndpointsDAOInterface` (an in-memory dict-backed double, not the real
+ Postgres DAO). Assert each method calls the right DAO verb with the right
+ arguments and returns what the DAO returned.
+- `list_endpoints`'s three-source merge — **unit** with mocks for both
+ the DAO and `ConnectionsService` (a stub returning a canned list of
+ `Connection` rows). Assert: agenta entries appear with `namespace=BUILTIN`
+ and `provider_key="agenta"`
+ and no `id`; builtin entries appear with `namespace=BUILTIN`,
+ `connection_id`/`provider_key`/`integration_key` stamped; custom rows
+ appear with `namespace=CUSTOM`; no generated entry is ever passed to a
+ DAO write.
+- Connection-state derivation — **unit**. A `custom` OAuth endpoint with
+ `secret_id=None` derives `NEEDS_AUTH`; a `custom` OAuth endpoint with
+ `secret_id` set but `flags.is_valid=False` also derives `NEEDS_AUTH`; a
+ `custom` NONE-scheme endpoint derives `READY` unconditionally; a `builtin`
+ entry backed by a mock `Connection` with `is_valid=False` derives
+ `NEEDS_AUTH`.
+- `relay`'s six-step order — **unit**, with a mock `GatewayPolicyService`,
+ mock resolver, mock `MCPUpstreamRegistry`. Assert: a tool outside the
+ policy raises `MCPToolNotAllowedError` **without** the mock resolver or
+ mock adapter ever being called (proves step ordering, not just the final
+ outcome); a policy denial calls `policy.record` before the exception
+ propagates (assert on call order via the mocks' call logs); a `builtin`
+ target never calls the resolver, only `ConnectionsService`.
+- Tool-list filtering — **unit**. A mock adapter returns a canned
+ `tools/list` JSON body with three tools; a target with
+ `tools.allowlist=["a","b"]` filters the response to two entries, unmodified
+ in shape; a target with no filter passes all three through untouched.
+- The full relay path against a real mock MCP server, and the merge against
+ a real Postgres-backed `MCPEndpointsDAO` — **acceptance**, part of
+ C1 (shared with WP8; see `specs-wp8.md`'s acceptance section).
+
+## Executable done test
+
+Plan.md's stated done condition, verbatim: *"a custom server registers and
+resolves, and a built-in one needs no row."* Concretely:
+
+```text
+create_endpoint(project_id=P, user_id=U, endpoint=MCPEndpointCreate(
+ slug="acme-notion", auth_mode=NONE, data=MCPEndpointData(url="https://...")
+))
+ -> a row exists; fetch_endpoint_by_slug(P, "acme-notion") resolves it
+
+list_endpoints(project_id=P)
+ -> contains an entry with namespace=CUSTOM, slug="acme-notion"
+ -> contains entries with namespace=BUILTIN for every active composio
+ connection in P, with NO corresponding row in mcps_endpoints
+```
+
+## Out of scope
+
+- The HTTP surface, `MCPGatewayProxy`, `parse_mcp_call_context`, and the
+ `HttpMCPAdapter` south-port implementation — **WP8**.
+- The management CRUD router and models
+ (`apis/fastapi/gateways/mcps/{router,models}.py`) — **WP10**.
+ `apis/fastapi/gateways/exceptions.py` — **the seed** (R1).
+- `ComposioMCPAdapter` — not owned by any wave-1 package; `builtin` targets
+ are not called until a package for it is scheduled.
+- `core/gateways/mcps/token_storage.py` (`VaultTokenStorage`) and the OAuth
+ client that drives a custom server's connect flow — **WP17/WP18** (wave 3).
+- `dbs/postgres/gateways/mcps/` (DAO implementation, migration) — **WP1**,
+ already landed by IM1.
+- `core/access/permissions/types.py`'s six new members — **WP3**, already
+ landed by IM1.
+
+## Checkpoint
+
+Feeds **C1**, together with WP8 (see `specs-wp8.md`'s acceptance
+section for the shared suite) at the IM2 merge.
+
+## `api/entrypoints/routers.py` diff
+
+This package owns the `MCPGatewayService` and `MCPUpstreamRegistry`
+construction block — the one other packages (WP8's adapter entry, WP10's
+router construction) attach to. Applied at the IM2 merge together with the
+sibling fragments from `specs-wp6.md`/`specs-wp7.md`/`specs-wp8.md`/`specs-wp10.md`:
+
+```diff
++from oss.src.core.gateways.mcps.service import MCPGatewayService
++from oss.src.core.gateways.mcps.registry import MCPUpstreamRegistry
++
++mcp_gateway_service = MCPGatewayService(
++ mcp_endpoints_dao=mcp_endpoints_dao,
++ policy=gateway_policy_service,
++ resolver=secret_resolver,
++ connections_service=connections_service,
++ upstream_registry=MCPUpstreamRegistry(adapters={
++ "http": HttpMCPAdapter(), # custom: MCPDirectAuth (WP8)
++ "composio": ComposioMCPAdapter(), # builtin: MCPBrokeredAuth (not wave 1)
++ "mock": MockMCPAdapter(), # serves the builtin/agenta mocks (D23, WP5)
++ }),
++)
+```
+
+`mcp_endpoints_dao`, `gateway_policy_service` and `secret_resolver` are
+constructed earlier in the file by WP1/WP2/WP3's fragments (already landed
+at IM1); `connections_service` is the pre-existing connections-domain
+instance every other leaf service in the file already receives by
+reference (`entities.md` §8) — this fragment only adds the service and
+registry. As with WP8's fragment, the local variable names above are
+wiring convenience following the naming style `entities.md`'s own wiring
+pseudocode uses (`llm_endpoints_dao = LLMEndpointsDAO(...)`), not symbols
+the design fixes.
+
+**Note the `ComposioMCPAdapter()` line has no owning package in wave 1** —
+either it must be stubbed (raising on every call) until a later package
+implements it, or the dict entry is omitted and `list_endpoints`'s builtin
+branch is the only place `builtin` support exists in wave 1 (listing works,
+calling does not, and would raise via `MCPUpstreamRegistry.get("composio")`
+missing the key). Flag this at the IM2 merge — it is not resolved by this
+spec.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp1.md b/docs/design/gateways-research/v1/workstreams/tasks-wp1.md
new file mode 100644
index 0000000000..60807d2b41
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp1.md
@@ -0,0 +1,158 @@
+# WP1 tasks — Gateway domain and storage
+
+Ordered so each item is one reviewable commit. Depends on the seed commit
+(`core/gateways/{dtos,types}.py`, `core/gateways/policy/{dtos,types,interfaces}.py`,
+`core/gateways/{llms,mcps}/{dtos,types,interfaces}.py`) already existing on the base
+branch, per `workstreams/README.md`.
+
+## Setup
+
+- [x] Verify the current migration head:
+ `ls api/oss/databases/postgres/migrations/core_oss/versions/ | tail -3`. Record the
+ actual latest revision id — do not assume `oss000000020` or `park00000000` from
+ `specs-wp1.md` without re-checking, both may be stale by the time this starts.
+
+## dbas
+
+- [x] `dbs/postgres/gateways/llms/dbas.py`: add `LLMEndpointDBA` —
+ `ProjectScopeDBA, IdentifierDBA, SlugDBA, LifecycleDBA, HeaderDBA, DataDBA,
+ StatusDBA, FlagsDBA, TagsDBA, MetaDBA` plus `provider_key: String,
+ nullable=False`, `deployment: SQLEnum(LLMDeploymentKind,
+ name="llmdeploymentkind_enum"), nullable=False`, `secret_id: UUID,
+ nullable=True`.
+- [x] `dbs/postgres/gateways/mcps/dbas.py`: add `MCPEndpointDBA` — same base mixins as
+ `LLMEndpointDBA`, plus `auth_mode: SQLEnum(GatewayAuthScheme,
+ name="gatewayauthscheme_enum"), nullable=False`, `secret_id: UUID,
+ nullable=True`.
+- [x] Ruff format + check both files; commit.
+
+## dbes
+
+- [x] `dbs/postgres/gateways/llms/dbes.py`: `LLMEndpointDBE` — `__tablename__ =
+ "llms_endpoints"`; `PrimaryKeyConstraint(project_id, id)`; FK
+ `project_id -> projects.id ondelete=CASCADE`; FK `secret_id -> secrets.id
+ ondelete=SET NULL`; `UniqueConstraint(project_id, slug,
+ name="uq_llms_endpoints_project_slug")`;
+ `Index("ix_llms_endpoints_project_provider", project_id, provider_key)`;
+ `Index("ix_llms_endpoints_flags", flags, postgresql_using="gin")`.
+- [x] `dbs/postgres/gateways/mcps/dbes.py`: `MCPEndpointDBE` — `__tablename__ =
+ "mcps_endpoints"`; PK `(project_id, id)`; FK `project_id`
+ `ondelete=CASCADE`; FK `secret_id -> secrets.id ondelete=SET NULL`;
+ `UniqueConstraint(project_id, slug, name="uq_mcps_endpoints_project_slug")`;
+ `Index("ix_mcps_endpoints_flags", flags, postgresql_using="gin")`.
+- [x] Confirm by grep over both `dbes.py` files that no unique constraint or index
+ mentions `url` or `secret_id` alone — deliberate absence (§3).
+- [x] Ruff format + check; commit.
+
+## Verification: models import
+
+- [x] Import both `dbes.py` modules in a throwaway shell / smoke test — confirm the
+ `SQLEnum` types resolve against the seed's `LLMDeploymentKind` and
+ `GatewayAuthScheme` without a circular import (both live in seed-owned
+ `core/gateways/**/dtos.py`, imported here, never redefined).
+
+## mappings
+
+- [x] `dbs/postgres/gateways/llms/mappings.py`: `map_llm_endpoint_create_to_dbe`,
+ `map_llm_endpoint_dbe_to_dto`, `map_llm_endpoint_edit_to_dbe` — follow
+ `dbs/postgres/gateway/connections/mappings.py`'s shape (`model_dump(mode="json",
+ exclude_none=True)` on `data`/`flags` going in, reconstruct the typed model going
+ out). `map_llm_endpoint_dbe_to_dto` stamps `namespace=GatewayEndpointNamespace.CUSTOM`
+ unconditionally.
+- [x] `dbs/postgres/gateways/mcps/mappings.py`: the same three-function set for
+ `MCPEndpoint`.
+- [x] Unit test: instantiate one representative DTO per entity
+ (`LLMEndpointCreate`, `MCPEndpointCreate`), map to DBE and back,
+ assert field-for-field equality modulo server-assigned fields (`id`, timestamps).
+ No database — pure object construction.
+- [x] Ruff format + check; commit.
+
+## dao — llms
+
+- [x] `dbs/postgres/gateways/llms/dao.py`: `LLMEndpointsDAO.__init__(self, *,
+ LLMEndpointDBE: type = LLMEndpointDBE, engine: TransactionsEngine = None)` —
+ mirror `ConnectionsDAO.__init__`'s shape, defaulting `engine` via
+ `get_transactions_engine()`.
+- [x] Implement `create_endpoint`: `@suppress_exceptions(exclude=[EntityCreationConflict])`;
+ map DTO → DBE via `map_llm_endpoint_create_to_dbe`; `session.add` + `commit` +
+ `refresh`; catch `IntegrityError`, inspect `str(e.orig)` for
+ `uq_llms_endpoints_project_slug`, raise `EntityCreationConflict(entity="LLMEndpoint",
+ conflict={"slug": ...})` on match, else re-raise.
+- [x] Implement `fetch_endpoint`, `fetch_endpoint_by_slug` — `@suppress_exceptions(default=None)`,
+ `select(...).filter(project_id==..., id/slug==...).limit(1)`.
+- [x] Implement `edit_endpoint` — `@suppress_exceptions(default=None)`; fetch the row by
+ `(project_id, endpoint_id)`, return `None` if absent; overwrite `name`,
+ `description`, `secret_id`, `data`, `flags`, `meta` wholesale from the `Edit` DTO
+ (full PUT — no partial merge); `flag_modified` on `data`/`flags`; set `updated_at`,
+ `updated_by_id`.
+- [x] Implement `delete_endpoint` — `@suppress_exceptions(default=False)`; `delete(...)`
+ by `(project_id, endpoint_id)`; return `result.rowcount > 0`.
+- [x] Implement `query_endpoints` — `@suppress_exceptions(default=[])`; filter by
+ `provider_key`/`deployment_kind`/`slug` when present on the `LLMEndpointQuery`; apply
+ `windowing` if given, else default ordering by `created_at desc`.
+- [x] Ruff format + check; commit.
+
+## dao — mcps (endpoints)
+
+- [x] `dbs/postgres/gateways/mcps/dao.py`: `MCPEndpointsDAO` — same six methods, same
+ shape, over `mcps_endpoints` / `uq_mcps_endpoints_project_slug`.
+- [x] Ruff format + check; commit.
+
+## migration
+
+- [x] `api/oss/databases/postgres/migrations/core_oss/versions/oss0000000NN_add_gateway_endpoints.py`
+ (NN = verified head + 1 from Setup): header with `revision`, `down_revision` set
+ to the verified head.
+- [x] `upgrade()`: `op.create_table("llms_endpoints", ...)` — every column from
+ `LLMEndpointDBA`/`DBE`, the PK, both FKs, the slug unique constraint.
+- [x] `op.create_index("ix_llms_endpoints_project_provider", ...)`,
+ `op.create_index("ix_llms_endpoints_flags", ..., postgresql_using="gin")`.
+- [x] `op.create_table("mcps_endpoints", ...)`, its PK, both FKs, the slug unique
+ constraint, `op.create_index("ix_mcps_endpoints_flags", ...)`.
+- [x] `downgrade()`: drop every index created above, then
+ `op.drop_table("mcps_endpoints")`, `op.drop_table("llms_endpoints")`
+ — reverse order.
+- [x] Ruff format + check; commit.
+
+## tests — unit (run now)
+
+- [x] `api/oss/tests/pytest/unit/gateways/test_gateways_mappings.py` — the DTO↔DBE
+ round-trip tests from the mappings section above, for both entities. No
+ database.
+
+## tests — integration (write; do not run without a local deployment)
+
+- [x] `api/oss/tests/pytest/integration/gateways/test_gateways_llm_endpoints_dao.py` —
+ create→fetch round-trip; duplicate slug raises `EntityCreationConflict`;
+ `edit_endpoint` replaces `data`/`flags` wholesale (a field omitted from the new
+ `data` is gone after the edit); `delete_endpoint` idempotency (`True` then
+ `False`); `query_endpoints` filters.
+- [x] `api/oss/tests/pytest/integration/gateways/test_gateways_mcp_endpoints_dao.py` —
+ same six assertions against `mcps_endpoints`.
+- [ ] `api/oss/tests/pytest/integration/gateways/test_gateways_migration.py` — deliberately
+ NOT written: the WP1 task brief overrides this item explicitly (`alembic
+ upgrade`/`downgrade` is a by-hand Docker+Postgres check someone else runs at the
+ merge, never a pytest, because a downgrade drops tables — a test would be either
+ destructive or a lie). The migration itself was still verified by hand: module
+ import, `revision`/`down_revision` metadata, and a grep confirming exactly one file
+ in the chain declares each of `revision="oss000000021"` and
+ `down_revision="oss000000020"`.
+- [x] Same file or a sibling: deleting a `secrets` row referenced by an endpoint's
+ `secret_id` leaves the endpoint row with `secret_id = NULL`.
+
+## routers.py diff (hand off at merge, do not commit directly)
+
+- [x] Write the two-line DAO-construction diff from `specs-wp1.md` into this
+ package's PR description / merge notes for IM1 — `LLMEndpointsDAO`,
+ `MCPEndpointsDAO` constructed with `_transactions_engine`.
+
+## Definition of done
+
+Feeds **IM1**, then **C1** via WP6/WP7/WP8/WP9/WP10. Exit condition, verbatim
+from `plan.md`: *"a custom endpoint round-trips, and every DAO verb takes the owner."*
+
+WP1 is done when: the migration applies and downgrades cleanly; a custom endpoint on
+either plane round-trips create→fetch with field-for-field equality; a slug collision on
+create raises `EntityCreationConflict` and nothing else does; and grep over both
+`interfaces.py`-implementing files confirms every method takes `project_id` first,
+keyword-only after `*`.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp10.md b/docs/design/gateways-research/v1/workstreams/tasks-wp10.md
new file mode 100644
index 0000000000..52118255c5
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp10.md
@@ -0,0 +1,173 @@
+# WP10 tasks — Endpoint CRUD API
+
+Ordered so each item is one reviewable commit. Depends on the seed commit
+and on merge IM1 (WP1's DAO implementations, WP3's six new `Permission`
+members) having landed.
+
+## exceptions.py — NOT this package's file any more (R1)
+
+- [ ] **Do not write `apis/fastapi/gateways/exceptions.py`.** R1 moved it to the
+ seed, so it is already on the branch: three packages need
+ `handle_gateway_exceptions()` — this one and both proxies (WP6, WP8) — and
+ the three are siblings in the dependency graph, so no one of them could own
+ it. Import it and verify the mapping matches `specs-wp10.md`; report a
+ mismatch rather than editing a seed file.
+
+## SSRF gate on custom MCP endpoint registration (D28)
+
+- [ ] `from oss.src.core.webhooks.utils import validate_url_format_and_literal_ip`
+ — the **no-DNS** variant. Write no new guard.
+- [ ] Call it on the URL in `MCPEndpointCreateRequest` / `MCPEndpointEditRequest`
+ for the `custom` namespace only. `agenta` and `builtin` URLs are not
+ user-supplied. The precedent to copy exactly is
+ `api/oss/src/core/secrets/dtos.py:140`, which gates `custom_provider.url`
+ the same way and re-raises naming the field.
+- [ ] Surface a rejection as a 400 through the domain-exception path, never a
+ leaked `ValueError`; the message names the field and the reason.
+- [ ] Check whether the LLM `custom` endpoint DTO carries a base URL. If it does,
+ gate it identically; if not, add nothing speculatively.
+- [ ] Unit tests **with `AGENTA_INSECURE_EGRESS_ALLOWED=false` set explicitly** —
+ it defaults to `true`, so a test that omits it passes while proving
+ nothing: create with `http://169.254.169.254/mcp` → 400; with
+ `http://127.0.0.1/mcp` → 400; with `http://10.0.0.1/mcp` → 400; plain
+ `http://` to a public host → 400; `https://` to a public hostname →
+ accepted **without a DNS lookup happening** (that is the whole point of
+ this variant — assert no resolution is attempted).
+- [ ] `ruff format` && `ruff check --fix`; run tests; fix failures.
+- [ ] Commit: "gateways(mcp): SSRF gate on custom endpoint registration".
+
+## llms/models.py
+
+- [ ] `apis/fastapi/gateways/llms/models.py`: add
+ `LLMEndpointCreateRequest`, `LLMEndpointEditRequest`,
+ `LLMEndpointQueryRequest`, `LLMEndpointResponse`,
+ `LLMEndpointsResponse` — field names, types and defaults exactly as
+ `entities.md` §6. `Field(default_factory=list)` for the list default,
+ not bare `[]`.
+- [ ] Unit test: instantiate every class above with representative values.
+- [ ] `ruff format` && `ruff check --fix`; run tests; fix failures.
+- [ ] Commit: "gateways(llm): CRUD wire models".
+
+## mcps/models.py
+
+- [ ] `apis/fastapi/gateways/mcps/models.py`: add `MCPEndpointCreateRequest`,
+ `MCPEndpointEditRequest`, `MCPEndpointQueryRequest`,
+ `MCPEndpointResponse`, `MCPEndpointsResponse`, `MCPConnectRequest`,
+ `MCPConnectResponse` — exactly as `entities.md` §6.
+- [ ] Unit test: instantiate every class above with representative values.
+- [ ] `ruff format` && `ruff check --fix`; run tests; fix failures.
+- [ ] Commit: "gateways(mcp): CRUD + connect wire models".
+
+## llms/router.py
+
+- [ ] `apis/fastapi/gateways/llms/router.py`: `LLMGatewayRouter.__init__(self,
+ *, llm_gateway_service: LLMGatewayService)`, `self.router = APIRouter()`.
+- [ ] Add `async def _check(self, scope: AuthScope, permission: Permission) ->
+ None`, factored (following `TriggersRouter._check`, adapted to take
+ `scope` not `request`), calling `check_action_access(user_uid=str(scope.user_id),
+ project_id=str(scope.project_id), permission=permission)` and raising
+ `FORBIDDEN_EXCEPTION` on denial.
+- [ ] Register the six routes exactly as `entities.md` §9: `POST /endpoints/`
+ (create, `EDIT_LLM_ENDPOINTS`), `GET /endpoints/` (list,
+ `VIEW_LLM_ENDPOINTS`), `POST /endpoints/query` (query,
+ `VIEW_LLM_ENDPOINTS`), `GET /endpoints/{endpoint_id}` (fetch,
+ `VIEW_LLM_ENDPOINTS`), `PUT /endpoints/{endpoint_id}` (edit,
+ `EDIT_LLM_ENDPOINTS`), `DELETE /endpoints/{endpoint_id}` (delete,
+ `EDIT_LLM_ENDPOINTS`). Every route: `operation_id` matching the
+ table, `response_model_exclude_none=True` (except delete, which
+ returns no body per the tools/triggers delete precedent).
+- [ ] Implement each handler: `get_auth_scope()`, `self._check(...)`,
+ service call, envelope. `fetch_endpoint`/`edit_endpoint` raise
+ `LLMEndpointNotFoundError` on a `None` service return;
+ `delete_endpoint` raises it on `False`.
+- [ ] Decorate every handler `@intercept_exceptions()` then
+ `@handle_gateway_exceptions()`.
+- [ ] `ruff format` && `ruff check --fix`; fix all errors.
+- [ ] Commit: "gateways(llm): LLMGatewayRouter CRUD".
+
+## llms/router.py tests (unit)
+
+- [ ] TestClient + mock `LLMGatewayService` + mockd `get_auth_scope()` /
+ `check_action_access()`: each of the six routes reaches the right
+ handler with the right operation_id/method/path.
+- [ ] A denied `_check` short-circuits before the mock service is called —
+ assert the mock's call count is zero.
+- [ ] `None` from `fetch_endpoint`/`edit_endpoint` → 404; `False` from
+ `delete_endpoint` → 404.
+- [ ] `ruff format` && `ruff check --fix`; run tests; fix failures.
+- [ ] Commit: "gateways(llm): LLMGatewayRouter tests".
+
+## mcps/router.py
+
+- [ ] `apis/fastapi/gateways/mcps/router.py`: `MCPGatewayRouter.__init__(self,
+ *, mcp_gateway_service: MCPGatewayService)`, `self.router = APIRouter()`,
+ its own `_check(self, scope, permission)` helper (do not share one
+ instance between the two router classes).
+- [ ] Register the same six endpoint-CRUD routes as the LLM router, with
+ `VIEW_MCP_ENDPOINTS`/`EDIT_MCP_ENDPOINTS`.
+- [ ] Do NOT register `POST /endpoints/{endpoint_id}/connect` or
+ `GET /connect/callback` — tagged `(WP18)`, out of scope for this
+ package.
+- [ ] Decorate every handler `@intercept_exceptions()` then
+ `@handle_gateway_exceptions()`.
+- [ ] `ruff format` && `ruff check --fix`; fix all errors.
+- [ ] Commit: "gateways(mcp): MCPGatewayRouter CRUD".
+
+## mcps/router.py tests (unit)
+
+- [ ] TestClient + mock `MCPGatewayService`: each of the six routes reaches
+ the right handler.
+- [ ] Confirm `POST /endpoints/{id}/connect` and `GET /connect/callback`
+ are NOT registered on this router (a 404 from FastAPI's own routing,
+ not a handled response) — a deliberate absence test, not just an
+ omission.
+- [ ] A denied `_check` short-circuits before the mock service is called.
+- [ ] `ruff format` && `ruff check --fix`; run tests; fix failures.
+- [ ] Commit: "gateways(mcp): MCPGatewayRouter tests".
+
+## entrypoint wiring (coordinate at IM2)
+
+- [ ] Add `llm_gateway_router = LLMGatewayRouter(llm_gateway_service=llm_gateway_service)`
+ and `mcp_gateway_router = MCPGatewayRouter(mcp_gateway_service=mcp_gateway_service)`
+ to `api/entrypoints/routers.py` as a diff fragment — coordinate with
+ WP7's and WP9's service-construction fragments landing first, or
+ raise at the merge if they have not.
+- [ ] Add the two `app.include_router(...)` mounts
+ (`prefix="/gateways/llms", tags=["Gateway: LLM"]` and
+ `prefix="/gateways/mcps", tags=["Gateway: MCP"]`).
+- [ ] Raise the `exceptions.py` cross-dependency (WP6/WP8 importing
+ `handle_gateway_exceptions` from this package's file) explicitly at
+ the merge — confirm both proxies' imports resolve cleanly against
+ what this package actually wrote, not just the documented signature
+ they coded against.
+- [ ] At the IM2 merge: apply this fragment together with WP6's, WP7's,
+ WP8's and WP9's. Verify with `git diff` that the combined edit
+ contains exactly the expected lines.
+- [ ] `ruff format` && `ruff check --fix` on the merged `routers.py`.
+- [ ] Commit (at the merge, not before): "gateways: wire WP6/7/8/9/10 into
+ entrypoints/routers.py" (shared commit — one commit for the whole
+ merged file).
+
+## C1 verification (acceptance, after IM2 deploy)
+
+- [ ] Deploy the merged stack.
+- [ ] `POST /gateways/mcps/endpoints/` with a NONE-scheme custom endpoint
+ returns 200 with a UUID `id`.
+- [ ] `DELETE /gateways/mcps/endpoints/{that id}` removes the row; a
+ subsequent `GET` on it returns 404.
+- [ ] `PUT /gateways/mcps/endpoints/{any UUID not present in
+ mcps_endpoints}` returns 404 — confirming no request can
+ reach a generated (builtin/agenta) entry through this router.
+- [ ] Repeat the create/delete/edit-404 sequence for the LLM router against
+ `/gateways/llms/endpoints/`.
+- [ ] File any acceptance-test failure as a finding.
+
+## Definition of done
+
+Feeds **C1**. Plan.md's stated done condition, verbatim: *"a
+custom endpoint can be created and deleted, and a standard one cannot be
+edited."* WP10 is done when: every wire model instantiates; every mapped
+exception produces the right status and body shape; both routers' routes
+dispatch correctly against mocks with no real database; the `(WP18)`-tagged
+routes are absent by construction; and the C1 acceptance
+assertions above pass against the deployed stack.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp12.md b/docs/design/gateways-research/v1/workstreams/tasks-wp12.md
new file mode 100644
index 0000000000..b599427aed
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp12.md
@@ -0,0 +1,56 @@
+# WP12 — tasks
+
+Read [`specs-wp12.md`](specs-wp12.md) first. Branch from the wave-2 seed commit.
+
+## models.py — what a resolved connection now carries
+
+- [ ] Read `connections/models.py` end to end before editing. `ResolvedConnection`'s
+ validators encode rules this package must keep, not route around.
+- [ ] Confirm the seed's gateway-credentials field is present and materialized (D36). If it
+ is missing, stop — every downstream package inherits it and it is not this package's
+ to invent.
+- [ ] Extend the validator: `credential_mode == "none"` with the gateway-credentials field
+ set is now a legal combination, and is the normal one.
+- [ ] Unit: the combination validates; a provider secret in `credentials` alongside the
+ gateway field does not.
+
+## resolve.py — the route
+
+- [ ] `platform/resolve.py::resolve_connection`: build the base URL as
+ `{gateway_base}/gateways/llms/{namespace}/{name}` from D30's grammar —
+ `standard/{provider_key}` for a generated endpoint, `custom/{slug}` for a row.
+- [ ] The protocol path stays the harness's: the base URL ends at the endpoint, with no
+ `/v1/...` suffix.
+- [ ] Where the gateway base URL comes from is configuration, through the shared `env`
+ object (`api/AGENTS.md`), never `os.getenv` at the call site.
+- [ ] Set `credential_mode="none"`, leave `credentials` empty, fill the gateway-credentials
+ field from the minted token (D13's signer, unchanged).
+- [ ] A target whose protocol has no front door raises, naming target and protocol. No
+ fallback to a direct connection — a silent bypass of the gateway is the one outcome
+ worse than an error.
+
+## resolver.py — the two resolvers
+
+- [ ] `EnvConnectionResolver` and `StaticConnectionResolver` both keep working. They are the
+ local and test paths; if either can no longer express a connection, that is a finding
+ to report, not a shape to change here.
+- [ ] Unit: each resolver's existing tests pass, or a changed expectation is listed in this
+ file with its reason.
+
+## Tests
+
+- [ ] Unit: one resolved connection per (provider, deployment) pair the resolver supports —
+ base URL is the gateway's, `credentials` empty, `credential_mode` `none`, gateway
+ field populated.
+- [ ] Unit, structural: `model_dump_json()` carries no upstream secret, for every pair.
+ Assert on the dump, not on named fields — a field added later must fail this test.
+- [ ] Unit: loopback http passes the validator; non-loopback http still fails (D37).
+- [ ] Unit: a target with no front door raises.
+- [ ] `ruff format` && `ruff check --fix` in `sdks/python`; run the SDK unit tests.
+- [ ] Commit: "gateways(sdk): resolve connections to the gateway route".
+
+## Definition of done
+
+- No resolved connection carries an upstream secret, for any provider or deployment.
+- Every capability the resolver had, it still has.
+- WP13, WP14 and WP15 can branch from this and read one shape.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp13.md b/docs/design/gateways-research/v1/workstreams/tasks-wp13.md
new file mode 100644
index 0000000000..dc6ea7d030
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp13.md
@@ -0,0 +1,51 @@
+# WP13 — tasks
+
+Read [`specs-wp13.md`](specs-wp13.md) first. Branch from WP12's merge (IM4).
+
+## Phase 0 — the harness matrix, before any code
+
+- [ ] For Claude Code, OpenCode and Codex, at the release actually in use: does it send a
+ custom header on model requests, and does pointing its base URL at us preserve that
+ behaviour? Record per harness and per version (OD14).
+- [ ] A harness that fails is a finding. Do not work around it here — the fallback is the
+ local-agent shape and it is a separate package.
+- [ ] Commit the matrix into `open-designs.md` OD14.
+
+## Phase 1 — the wire's consumer side
+
+- [ ] Read the gateway-credentials field's declaration in the seed. Re-validate it in the
+ runner rather than trusting it, following the existing pattern.
+- [ ] A `ModelConnection` with `credentialMode: "none"` and the gateway field is legal and is
+ the normal case; one with both a provider secret and the gateway field is not.
+- [ ] Unit: validation accepts the first and rejects the second.
+
+## Phase 2 — the harness writers
+
+- [ ] `pi-model-config.ts`, `codex-assets.ts` and the equivalent per harness: write the
+ gateway header into that harness's own configuration mechanism.
+- [ ] Base URL points at the gateway route from `endpoint.baseUrl`. Do not append a protocol
+ path — the harness owns that.
+- [ ] Unit per harness: a gateway connection produces a configuration carrying the header and
+ no provider secret.
+
+## Phase 3 — what must shrink
+
+- [ ] `daytona-secret-plan.ts`: remove entries that exist only for provider keys the gateway
+ now holds. Every entry that stays gets a reason in the file.
+- [ ] The redaction set shrinks with it. If it does not, the secrets did not leave — stop and
+ find out why.
+- [ ] Unit: the secret plan for a gateway connection is empty, or each entry is justified.
+
+## Tests
+
+- [ ] Acceptance: a run completes with no provider secret in the sandbox environment, on the
+ local sandbox and on Daytona. Assert by inspecting the sandbox, not the resolver.
+- [ ] Acceptance: the run's model calls appear as audit events (WP4) with the right
+ principal.
+- [ ] Commit: "gateways(runner): carry a gateway route instead of provider secrets".
+
+## Definition of done
+
+- No provider secret reaches a sandbox, proven from inside the sandbox.
+- Each harness sends our credentials header, verified on the release in use.
+- The secret plan and the redaction set are both smaller.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp14.md b/docs/design/gateways-research/v1/workstreams/tasks-wp14.md
new file mode 100644
index 0000000000..4979778adb
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp14.md
@@ -0,0 +1,39 @@
+# WP14 — tasks
+
+Read [`specs-wp14.md`](specs-wp14.md) first. Branch from WP12's merge (IM4).
+
+## Read first
+
+- [ ] `services/oss/src/agent/secrets.py` — what it does today is the list of what must stop
+ happening.
+- [ ] Confirm which protocol the agent speaks. If it is not Chat Completions, this package
+ depends on WP23's matching door and says so before starting.
+
+## The change
+
+- [ ] Resolve through WP12's `resolve_connection`; use the gateway route and our credentials.
+- [ ] Delete the provider-secret path rather than leaving it unused. Code that can read a
+ secret is one deployment mistake from reading one.
+- [ ] No direct-provider fallback when the gateway is unreachable. Fail, and let the failure
+ be visible.
+
+## Errors
+
+- [ ] A gateway refusal surfaces with its `code` and, where the caller must change something,
+ its `next_step` (`api/AGENTS.md`). Do not flatten a 403 `model_not_allowed` into a
+ generic upstream error — a small model cannot act on that.
+- [ ] Unit: each refusal shape reaches the agent with its code intact.
+
+## Tests
+
+- [ ] Unit: the resolved connection carries the gateway route and no provider secret.
+- [ ] Unit: no code path reads a provider secret — a grep-style guard is legitimate, the same
+ way WP24 guards its body-parse invariant.
+- [ ] Acceptance: an agent run completes through the gateway and appears as audit events.
+- [ ] `ruff format` && `ruff check --fix`; run the service unit tests.
+- [ ] Commit: "gateways(agent): route model calls through the gateway".
+
+## Definition of done
+
+- The agent holds no provider secret and has no path that could.
+- Gateway refusals are actionable where the agent can act on them.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp15.md b/docs/design/gateways-research/v1/workstreams/tasks-wp15.md
new file mode 100644
index 0000000000..93493b8cfb
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp15.md
@@ -0,0 +1,47 @@
+# WP15 — tasks
+
+Read [`specs-wp15.md`](specs-wp15.md) first. Branch from WP13's wire commit, not from IM4 —
+the two packages share `protocol.ts` and editing it in parallel is how a stack scrambles.
+
+## Phase 0 — the reachable set (OD17)
+
+- [ ] For each server this package targets, read whether it answers a plain stateless `POST`
+ with no session minted, and whether it needs the SSE leg for ordinary calls. Source it
+ from the server's own documentation or a probe, never from assumption.
+- [ ] Record the per-server reading in the package's findings, including the servers that
+ fail and why.
+- [ ] If most targeted servers are still on a session revision, **stop and report** — that is
+ a D8 decision, not a wiring problem.
+
+## The route
+
+- [ ] Build `connection.url` as `{gateway_base}/gateways/mcps/{namespace}/...` from D30's
+ grammar: `builtin/{provider}/{integration}/{connection}`, `builtin/agenta/{slug}`, or
+ `custom/{slug}`. The MCP protocol POSTs to that URL directly — nothing is appended.
+- [ ] Gateway base URL from the shared `env` object, never `os.getenv` at the call site.
+
+## The credentials
+
+- [ ] `connection.credentials` carries our credentials in the gateway header, using the
+ existing `{ kind: "header", name }` binding. No new binding is needed on this side.
+- [ ] The upstream server's own token does not appear. It is the gateway's now.
+- [ ] Unit: a gateway-routed server config has our header and no upstream token.
+
+## The policy
+
+- [ ] `policy.tools` passes through unchanged. The gateway's filter and the runner's policy
+ are two enforcement points and both stay — do not remove either because the other
+ covers it.
+- [ ] Unit: `policy.tools` survives resolution unchanged.
+
+## Tests
+
+- [ ] Unit: tool names, schemas and errors are untouched end to end (D16).
+- [ ] Acceptance: a run's tool calls reach a server through the gateway with no server token
+ in the sandbox, and appear as audit events (WP4).
+- [ ] Commit: "gateways(runner): point MCP servers at the gateway".
+
+## Definition of done
+
+- No upstream server token reaches a sandbox, proven from inside it.
+- A gateway-routed server behaves identically to a direct one from the agent's view.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp16.md b/docs/design/gateways-research/v1/workstreams/tasks-wp16.md
new file mode 100644
index 0000000000..5623f5fe3b
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp16.md
@@ -0,0 +1,44 @@
+# WP16 — tasks
+
+Read [`specs-wp16.md`](specs-wp16.md) first. Branch from `feat/gateways` (C3's predecessor).
+
+## Phase 0 — locate the machinery
+
+- [ ] Find the kind enum, the settings DTOs (using `sso_provider`'s as the shape precedent), the
+ union member list, and the validator's dispatch branch. Confirm whether EE mirrors any of
+ it or imports from OSS, and whether the SDK mirrors the enum.
+- [ ] Note the enum's current tail so the new members append cleanly. Do not touch existing
+ members' order or formatting.
+
+## Phase 1 — the two kinds
+
+- [ ] Append `oauth_provider` and `oauth_grant` to the kind enum, after the existing members.
+- [ ] Add `OAuthProviderSecretSettings` (client id, client secret, issuer URL, scopes) and its
+ wrapper, following the `sso_provider` settings DTO's shape exactly.
+- [ ] Add `OAuthGrantSecretSettings` (access token, refresh token, expiry, granted scopes, server
+ identifier) and its wrapper.
+- [ ] Add both wrapped settings types to the union member list on the secret DTO.
+- [ ] Add the two dispatch branches to the kind validator's `model_validator(mode="before")`.
+- [ ] If EE mirrors this machinery rather than importing it, apply the same four edits there.
+- [ ] If the SDK mirrors the kind enum, append the same two members there in the same commit.
+
+## Phase 2 — tests
+
+- [ ] Unit: each new settings DTO accepts a valid payload and rejects one missing a required
+ field.
+- [ ] Unit: the kind validator accepts each new kind paired with its own settings type and
+ rejects it paired with a mismatched settings type.
+- [ ] Unit: a regression guard asserting every pre-existing enum member is still present,
+ unchanged, in its original order.
+- [ ] `ruff format` && `ruff check --fix` in `api/`; run the API unit tests.
+- [ ] Commit: "gateways(secrets): add oauth_provider and oauth_grant kinds".
+
+## Definition of done
+
+- `oauth_provider` and `oauth_grant` exist in the kind enum, appended after the existing
+ members, with no reordering.
+- Both kinds have a settings DTO, a union arm, and a validator branch, following the
+ `sso_provider` precedent.
+- No client, storage adapter, route or resolution logic was added — that is WP17 and later.
+- Existing enum members are untouched; the new work merges cleanly alongside the parallel
+ kind-adding branch.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp17.md b/docs/design/gateways-research/v1/workstreams/tasks-wp17.md
new file mode 100644
index 0000000000..2491f58d87
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp17.md
@@ -0,0 +1,87 @@
+# WP17 — tasks
+
+Read [`specs-wp17.md`](specs-wp17.md) first. Branch from WP16 (`feat/gateways-wp16`).
+
+## Phase 0 — dependency
+
+- [ ] Add `mcp` (official Model Context Protocol Python SDK) to `api/pyproject.toml`, pinned;
+ regenerate `uv.lock`. Verify `mcp.client.auth.oauth2.TokenStorage` and `mcp.shared.auth`'s
+ DTOs import cleanly under `uv run python -c "..."`.
+- [ ] Commit: "gateways(mcp): add the mcp SDK dependency".
+
+## Phase 1 — the storage adapter
+
+- [ ] `core/gateways/mcps/oauth/storage.py`: `SecretsTokenStorage`, constructed with
+ `vault_service`, `project_id`, `server_url`. Implements `get_tokens`/`set_tokens`/
+ `get_client_info`/`set_client_info` per specs-wp17.md's "Keys" section — list+filter,
+ get-or-create against `VaultService`, no new DAO method.
+- [ ] Unit: fresh scope returns `None` from both getters; set-then-get round-trips both kinds; a
+ second `set_tokens` updates in place; two `server_url`s under one project never collide.
+- [ ] Commit: "gateways(mcp): oauth storage adapter over the secrets vault".
+
+## Phase 2 — the state token
+
+- [ ] `core/gateways/mcps/oauth/state.py`: `make_state`/`decode_state`, HMAC-signed, 1-hour TTL,
+ carrying `project_id`, `user_id`, `server_url`, `code_verifier`, `scopes`, `nonce`, `ts`.
+ Deliberately not imported from `core/gateway/connections/utils.py` — see specs-wp17.md's
+ "target: custom only" note.
+- [ ] Unit: round-trip carries every field; a tampered byte is rejected; an expired token is
+ rejected.
+- [ ] Commit: "gateways(mcp): oauth state token".
+
+## Phase 3 — the discovery + registration + token-exchange client
+
+- [ ] `core/gateways/mcps/oauth/client.py`: `MCPOAuthClient(transport: Optional[httpx.BaseTransport]
+ = None)`. Three methods: `discover(server_url) -> MCPOAuthDiscovery` (protected-resource
+ metadata, falling back through the well-known URIs per SEP-985/RFC 9728, then
+ authorization-server metadata per RFC 8414/OIDC discovery); `register(*, authorization_server,
+ redirect_uri) -> OAuthClientInformationFull` (RFC 7591 dynamic registration, skipped when
+ storage already has `client_info`); `exchange_token(*, token_endpoint, code, code_verifier,
+ redirect_uri, client_info) -> OAuthToken`.
+- [ ] Built from `mcp.shared.auth`'s DTOs and `mcp.client.auth.oauth2.PKCEParameters` for parsing
+ and PKCE generation — never from `OAuthClientProvider.async_auth_flow` (specs-wp17.md's "Why
+ not OAuthClientProvider").
+- [ ] `core/gateways/mcps/oauth/types.py`: typed exceptions — discovery failure, registration
+ failure, token-exchange failure — each wrapping the underlying `httpx`/validation error
+ rather than letting it escape raw.
+- [ ] Unit, `httpx.MockTransport` throughout: discovery success and 404-everywhere failure;
+ registration fires when no client_info stored, skipped when it is; token exchange success
+ and an error response from the mock token endpoint.
+- [ ] Commit: "gateways(mcp): oauth discovery, registration and token exchange".
+
+## Phase 4 — the two-phase connect service
+
+- [ ] `core/gateways/mcps/oauth/dtos.py`: `MCPOAuthDiscovery`, `MCPOAuthAuthorizationStart
+ {authorization_url, state}`, `MCPOAuthCompletion {project_id, server_url, secret_id}`.
+- [ ] `core/gateways/mcps/oauth/service.py`: `MCPOAuthConnectService(vault_service, client)` with
+ `discover`, `begin`, `complete` exactly as specs-wp17.md's "The two-phase connect service"
+ signatures. Fixed redirect URI built from `env.agenta.api_url` +
+ `/gateways/mcps/connect/callback`. `complete()` never calls `edit_endpoint` — returns the
+ `secret_id` for the caller to wire.
+- [ ] Unit: `begin()`'s authorization_url contains the fixed redirect_uri, a code_challenge, and
+ the requested scopes; its state decodes to the right project/server/verifier. `complete()`
+ with a valid code+state against the mock token endpoint writes an `oauth_grant` secret and
+ returns its id; a tampered/expired state raises before any HTTP call; a token-endpoint error
+ raises a typed exception.
+- [ ] Unit: step-up shape — calling `begin()` a second time for the same `server_url` with a
+ narrower `scopes` list, then `complete()`, updates the existing `oauth_grant` row rather than
+ creating a second one (proves the WP19 seam works without WP19 existing).
+- [ ] Commit: "gateways(mcp): the oauth connect service".
+
+## Phase 5 — close out
+
+- [ ] `ruff format` && `ruff check --fix` in `api/`.
+- [ ] Run the full API unit test suite; confirm no regression outside this package.
+- [ ] Re-read specs-wp17.md's "What WP18, WP19 and WP20 each consume" section once more against
+ the code as written — fix drift between prose and signatures before committing.
+- [ ] Commit: "gateways(docs): close WP17 with the storage adapter and connect service".
+
+## Definition of done
+
+- `SecretsTokenStorage` satisfies `mcp.client.auth.oauth2.TokenStorage` structurally, backed by
+ `VaultService`, with no new `SecretsDAOInterface` method.
+- `MCPOAuthConnectService.begin()`/`complete()` are the only entry points WP18's two routes need;
+ neither touches `MCPEndpoint` rows.
+- The callback URL is the fixed `{AGENTA_API_URL}/gateways/mcps/connect/callback`, disambiguated
+ by `state`, never by a per-flow query string baked into the redirect URI.
+- No test reaches a real authorization server, a real MCP server, or the network.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp18.md b/docs/design/gateways-research/v1/workstreams/tasks-wp18.md
new file mode 100644
index 0000000000..989286c91c
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp18.md
@@ -0,0 +1,87 @@
+# WP18 — tasks
+
+Read [`specs-wp18.md`](specs-wp18.md) first. Branch from WP17 (`feat/gateways-wp17`).
+
+## Phase 0 — backend: wire the two routes
+
+- [ ] `apis/fastapi/gateways/exceptions.py`: map the OAuth exceptions
+ (`MCPOAuthDiscoveryError`, `MCPOAuthRegistrationError`, `MCPOAuthTokenExchangeError`
+ → 424; `MCPOAuthStateInvalidError` → 400; `MCPOAuthClientNotRegisteredError` → 409),
+ each carrying the exception's own message, never a generic one.
+- [ ] `apis/fastapi/gateways/mcps/models.py`: `MCPConnectRequest.scopes` becomes
+ `Optional[List[str]] = None` (absent = discover step); `MCPConnectResponse` gains
+ `scopes_offered: List[str] = []`.
+- [ ] `apis/fastapi/gateways/mcps/router.py`: `MCPGatewayRouter.__init__` takes
+ `oauth_connect_service: "MCPOAuthConnectService"` (TYPE_CHECKING import, matching
+ the existing `mcp_gateway_service` forward-ref style). Register
+ `POST /endpoints/{endpoint_id}/connect` → `connect_endpoint` and
+ `GET /connect/callback` → `connect_callback`.
+- [ ] `connect_endpoint`: fetch the endpoint, 404/400-guard (must be `custom` + `oauth`),
+ branch on `body.scopes is None` (discover, cache onto `data.oauth` via
+ `edit_endpoint`) vs. present (begin, return `redirect_url`).
+- [ ] `connect_callback`: unauthenticated `HTMLResponse`. Decode `state` for `user_id`
+ (read-only, second decode — `complete()` already validated it once); call
+ `complete()`; resolve the endpoint by `query_endpoints` + `base_url` match;
+ `edit_endpoint(secret_id=...)`; render the success/failure card with the
+ `mcp:oauth:connected` postMessage payload, mirroring
+ `tools/router.py::_oauth_card`/`callback_connection`.
+- [ ] `entrypoints/routers.py`: construct `MCPOAuthClient()` and
+ `MCPOAuthConnectService(vault_service=vault_service, client=..., api_url=env.agenta.api_url,
+ secret_key=env.agenta.crypt_key)`; pass into `MCPGatewayRouter(...,
+ oauth_connect_service=...)`.
+- [ ] Update `test_gateways_mcp_router.py`: extend `EXPECTED_ROUTES` with the two new
+ entries; replace the two "route is not registered (WP18)" tests (now false) with
+ real coverage per specs-wp18.md's test list; give the fixture a mock
+ `MCPOAuthConnectService`.
+- [ ] Update `test_gateways_ssrf_registration_gate.py`'s `MCPGatewayRouter(...)`
+ construction to pass a stub `oauth_connect_service` (unused by the SSRF-only
+ tests in that file).
+- [ ] Commit: "gateways(mcp): wire the connect and callback routes".
+
+## Phase 1 — backend tests
+
+- [ ] Router tests per specs-wp18.md's list (discover step, begin step, 404/400/403,
+ discovery-failure message passthrough).
+- [ ] Callback tests per specs-wp18.md's list (success, no-matching-endpoint,
+ `error` query param, tampered/expired state).
+- [ ] `ruff format` && `ruff check --fix` in `api/`.
+- [ ] Commit: "gateways(mcp): connect and callback route tests".
+
+## Phase 2 — frontend: the dashboard surface
+
+- [ ] `web/oss/src/components/pages/settings/MCPEndpoints/api.ts`: raw-axios calls
+ (list/create/edit/delete/connect) — see specs-wp18.md's "Deliberate, not an
+ oversight" note on why not the Fern client yet.
+- [ ] `MCPEndpoints.tsx`: list page, "Register server" → `MCPEndpointDrawer`, "Connect"
+ on unconnected `oauth` rows → `MCPConnectDialog`.
+- [ ] `MCPEndpointDrawer.tsx`: create/edit form (slug, name, `base_url`, `auth_mode`),
+ `EnhancedModal`/`ModalContent`/`ModalFooter` from `@agenta/ui`.
+- [ ] `MCPConnectDialog.tsx`: step 1 (discover, render scope checkboxes, all pre-checked)
+ → step 2 (begin, popup via `window.open`, same-tab fallback when blocked) →
+ `postMessage` listener for `mcp:oauth:connected` with origin check, `popup.closed`
+ poll fallback — `ConnectDrawer.tsx`'s own pattern, reused not reinvented.
+- [ ] Mount the page in the settings nav alongside `Tools`/`Webhooks`/`Triggers`.
+- [ ] `pnpm lint-fix` in `web/`.
+- [ ] Commit: "gateways(mcp): the consent-flow dashboard surface".
+
+## Phase 3 — frontend tests
+
+- [ ] vitest: `api.ts` call shapes; `MCPConnectDialog`'s two-step state machine with
+ `window.open`/`postMessage` mocked, including the untrusted-origin-ignored case.
+- [ ] Commit: "gateways(mcp): consent-flow frontend tests".
+
+## Phase 4 — close out
+
+- [ ] Re-read specs-wp18.md once more against the code as written; fix drift.
+- [ ] Commit: "gateways(docs): close WP18 with the consent flow".
+
+## Definition of done
+
+- `POST /endpoints/{endpoint_id}/connect` and `GET /connect/callback` are wired,
+ reachable, and covered by unit tests with no live network.
+- A user can register a `custom` OAuth MCP server from the dashboard, pick scopes from
+ the server's own discovered list, and complete the grant — the endpoint's `secret_id`
+ ends up pointing at a live `oauth_grant` secret via `edit_endpoint`, never a direct
+ write to the row from this package's OAuth code.
+- WP26's stopgap has a precise, named target to repoint at (specs-wp18.md's "What WP26
+ should repoint at").
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp19.md b/docs/design/gateways-research/v1/workstreams/tasks-wp19.md
new file mode 100644
index 0000000000..40b0809ca6
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp19.md
@@ -0,0 +1,34 @@
+# WP19 — tasks
+
+1. **Backend: raise the scope challenge.**
+ `core/gateways/mcps/types.py::MCPScopeInsufficientError` gains optional `endpoint_id`.
+ `core/gateways/mcps/service.py::relay` gains `_parse_scope_challenge` (RFC 6750
+ `WWW-Authenticate` parse) and the step 5b detection branch: `custom` + `OAUTH` + `403` +
+ an `insufficient_scope` challenge raises, everything else passes through unchanged.
+
+2. **Backend: the connect affordance.**
+ `apis/fastapi/gateways/mcps/proxy.py::_map_gateway_exception`'s `scope_insufficient`
+ branch attaches `data.connect` when `endpoint_id` is present, pointing at WP18's connect
+ route's discover step.
+
+3. **Frontend: repoint the MCP branch.**
+ `useGatewayConnectFlow.ts`: `resolveCustomMcpEndpoint`, wire `MCPConnectDialog` into
+ `runConnect`/settle for a resolved `custom` endpoint, keep the catalog-drawer fallback for
+ an unresolved (`builtin`) target. `GatewayConnectToolWidget.tsx`: mount `MCPConnectDialog`
+ alongside the existing `ProviderDrawer` branch.
+
+4. **Tests.**
+ - `test_gateways_mcp_service.py`: 4 new cases (scope+list, scope+empty, no-challenge
+ passthrough, none-scheme passthrough).
+ - `test_gateways_mcp_proxy.py`: 2 new cases (no `endpoint_id` → no `connect`; with →
+ `connect` present).
+ - `gateway-error-harness-formats.test.ts`: `scope_insufficient` added to `MCP_REFUSALS`.
+ - `useGatewayConnectFlow.test.ts`: `resolveCustomMcpEndpoint` cases.
+
+5. **Docs.** `specs-wp19.md` (this package's scope, written before code, per procedure) +
+ this file.
+
+## Explicitly not built (see specs-wp19.md "Out of scope")
+
+- Header-first AS discovery, `MCPAuthRequiredError` wiring, a new `AgentErrorDetail`
+ frontend consumer, any LLM-plane scope concept.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp2.md b/docs/design/gateways-research/v1/workstreams/tasks-wp2.md
new file mode 100644
index 0000000000..f97b00c264
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp2.md
@@ -0,0 +1,113 @@
+# WP2 tasks — Secret resolution
+
+Ordered so each item is one reviewable commit. Depends on the seed commit
+(`core/gateways/policy/{dtos,types,interfaces}.py`) already existing on the base branch.
+Depends on nothing else — WP2 can start immediately alongside WP1 and WP3, since it
+consumes only `VaultService` (already landed) and a mock of it for tests.
+
+## Setup
+
+- [x] Read `sdks/python/agenta/sdk/agents/platform/connections.py` in full, focusing on
+ `_provider_key_candidate`, `_custom_provider_candidate`, and `_catalog` — this is
+ the precedent `ProviderKeyRef` resolution must match, not re-derive.
+- [x] Read `core/secrets/services.py` (`VaultService`) and `core/secrets/context.py`
+ (`set_data_encryption_key`) in full — confirm whether `VaultService`'s own methods
+ already open the encryption context internally (they do, as of this spec being
+ written; re-verify, since a change there would change whether `resolution.py` needs
+ to wrap its own calls).
+
+## resolution.py — skeleton
+
+- [x] `core/gateways/policy/resolution.py`: `SecretsResolver.__init__(self, *,
+ vault_service: VaultService) -> None`, implementing `SecretsResolverInterface`.
+- [x] `async def resolve(self, *, scope: AuthScope, ref: SecretRef, mode:
+ SecretMode) -> ResolvedSecret`: dispatch on `type(ref)` /
+ `isinstance(ref, ...)` to one private method per arm
+ (`_resolve_provider_key`, `_resolve_bound_secret`). Every branch
+ raises `SecretNotFoundError` or `SecretInvalidError` on failure — no bare
+ `return None` anywhere in this file, and no branch that falls through without
+ either returning a `ResolvedSecret` or raising.
+
+## resolution.py — BoundSecretRef
+
+- [x] Implement `_resolve_bound_secret`: call `vault_service.get_secret_by_id(ref.secret_id,
+ project_id=scope.project_id)` positionally (not by keyword — confirm the real
+ signature during Setup). `None` → `SecretNotFoundError(mode=mode,
+ missing=SecretOwnerKind.PROJECT, target=f"secret:{ref.secret_id}")`. Otherwise
+ wrap `ResolvedSecret(secret=..., owner=SecretOwner(kind=PROJECT),
+ origin=SecretOrigin.VAULT)`.
+- [x] Write the mode dispatch explicitly for this arm even though all three modes
+ currently produce identical behavior (per specs-wp2.md's "mode table" section) —
+ do not collapse into one code path with a comment saying "modes are equivalent for
+ now."
+- [x] No endpoint- or OAuth-specific branch needed: an OAuth MCP endpoint resolves
+ through this same arm, called with `BoundSecretRef(secret_id=endpoint.secret_id)`
+ at `mode=PROJECT_ONLY` by WP9 — this package does not need to know that.
+
+## resolution.py — ProviderKeyRef
+
+- [x] Implement `_resolve_provider_key`: `secrets = await
+ vault_service.list_secrets(project_id=scope.project_id)`; match
+ `kind == SecretKind.PROVIDER_KEY and secret.data.kind == ref.provider_key` first;
+ if none, match `kind == SecretKind.CUSTOM_PROVIDER and secret.data.kind ==
+ ref.provider_key`; if none, raise `SecretNotFoundError(mode=mode,
+ missing=SecretOwnerKind.PROJECT, target=f"provider:{ref.provider_key}")`.
+- [x] Same explicit-mode-dispatch note as the `BoundSecretRef` branch.
+- [x] Same explicit `origin=SecretOrigin.VAULT` construction.
+
+## resolution.py — available_provider_keys (R2, added at kickoff)
+
+- [x] Implement `available_provider_keys(self, *, scope) -> Set[str]` over the
+ same `list_secrets` scan `_resolve_provider_key` uses, returning the set
+ of provider names found across `PROVIDER_KEY` and `CUSTOM_PROVIDER`.
+- [x] Return names only — never a secret value, never a `ResolvedSecret`.
+- [x] **Never raise for "none found."** The empty set is the correct answer; only
+ `resolve()` raises, because a caller resolving has already committed to
+ needing a secret. WP7 calls this to decide which generated endpoints
+ exist (D20) and an empty project is an ordinary state, not an error.
+- [x] Unit test: a project with an OpenAI `provider_key` and an Azure
+ `custom_provider` returns exactly `{"openai", "azure"}`; a project with no
+ secrets returns an empty set without raising.
+
+## Ruff
+
+- [x] Ruff format then ruff check `resolution.py`; fix all errors.
+- [x] Commit: "core/gateways: implement SecretsResolver".
+
+## tests — unit (run now)
+
+- [x] `api/oss/tests/pytest/unit/gateways/test_gateways_resolution.py`: build a minimal
+ mock `VaultService` (in-memory dict of `secret_id -> SecretResponseDTO`, a mock
+ `list_secrets`/`get_secret_by_id` pair — do not subclass the real `VaultService`,
+ implement only what `SecretsResolver` calls).
+- [x] `BoundSecretRef`: secret exists → resolves with `owner.kind == PROJECT`.
+- [x] `BoundSecretRef`: secret missing → `SecretNotFoundError(missing=PROJECT)`.
+- [x] `BoundSecretRef` at each of the three `SecretMode` values, secret exists → resolves
+ it with `owner.kind == PROJECT` in every case (no ref arm has a live per-user
+ secret in this scope) — the test that catches a mode dispatch collapsed into one
+ code path.
+- [x] `ProviderKeyRef`: `provider_key`-kind match → resolves it.
+- [x] `ProviderKeyRef`: no `provider_key`-kind match, `custom_provider`-kind match
+ exists → resolves the `custom_provider` one.
+- [x] `ProviderKeyRef`: both kinds match → resolves the `provider_key`-kind one.
+- [x] `ProviderKeyRef`: no match → `SecretNotFoundError(missing=PROJECT)`.
+- [x] Every raised `SecretNotFoundError`/`SecretInvalidError` across the above:
+ assert `target` is non-empty and format-stable for a given input `ref`.
+- [x] Ruff format + check; commit.
+
+## routers.py diff (hand off at merge, do not commit directly)
+
+- [x] Write the `SecretsResolver(vault_service=vault_service)` construction line from
+ `specs-wp2.md` into this package's PR description for the IM1 merge.
+
+## Definition of done
+
+Feeds **IM1**, then **C1** through WP6 and WP8 (both call `resolve()` on the
+relay path). Exit condition, verbatim from `plan.md`: *"each resolution mode behaves as
+specified and no path silently returns no secret."*
+
+WP2 is done when: every unit test above passes; grep over `resolution.py` confirms no
+`return None` and no unreachable branch that neither returns a `ResolvedSecret` nor
+raises; and the `USER_REQUIRED` no-fallback dispatch structure is visibly present (by
+code inspection) on both ref arms, even though neither has a live per-user secret in this
+scope.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp20.md b/docs/design/gateways-research/v1/workstreams/tasks-wp20.md
new file mode 100644
index 0000000000..42dac0f9f7
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp20.md
@@ -0,0 +1,67 @@
+# WP20 — tasks
+
+Read [`specs-wp20.md`](specs-wp20.md) first. Branch from WP17 (`feat/gateways-wp17`).
+
+## Phase 1 — the detector and the identity document
+
+- [ ] `core/gateways/mcps/oauth/registration.py`: `client_metadata_url`,
+ `client_metadata_document`, `identity_document_client_info`,
+ `is_publicly_resolvable(api_url, *, resolve=...)` per specs-wp20.md's "The
+ detector" — conservative `all()`, never raises, `https`-only.
+- [ ] Unit: public address resolvable; private not; mixed public+private not;
+ resolution failure/empty answer/http-scheme all not; document carries no
+ `client_secret`; `identity_document_client_info()` deterministic.
+- [ ] Commit: "gateways(mcp): the client registration detector and identity document".
+
+## Phase 2 — the route
+
+- [ ] `apis/fastapi/gateways/mcps/oauth_router.py`: `MCPOAuthClientMetadataRouter`,
+ `GET /oauth/client-metadata.json`, no auth, no path parameter.
+- [ ] Wire into `api/entrypoints/routers.py` under the existing `/gateways/mcps` prefix.
+- [ ] Add the path (both `/gateways/...` and `/api/gateways/...` forms) to
+ `middlewares/auth.py`'s `_PUBLIC_ENDPOINTS`.
+- [ ] Unit: `TestClient` against a bare app carrying only this router — serves the
+ document, no `client_id` field inside it, `redirect_uris` matches
+ `callback_redirect_uri()`.
+- [ ] Commit: "gateways(mcp): serve the oauth client identity document".
+
+## Phase 3 — the strategy swap
+
+- [ ] `core/gateways/mcps/oauth/state.py`: add `strategy: "document" | "outbound"` to
+ `MCPOAuthStatePayload` and `make_state()`, defaulting existing callers to
+ `"outbound"`.
+- [ ] `core/gateways/mcps/oauth/service.py`: `MCPOAuthConnectService` gains an optional
+ `resolve` constructor param; `_resolve_client_info()` implements the three-step
+ order in specs-wp20.md ("The strategy, in order"); `begin()` records the chosen
+ strategy in `state`; `complete()` reads it back and branches — never re-probes.
+- [ ] Update `test_gateways_mcp_oauth_service.py`'s `_service()` helper to inject a
+ private-address `resolve` by default, so WP17's existing tests keep exercising the
+ outbound path unchanged and none of them touches real DNS.
+- [ ] Unit (new file): `begin()` prefers the document when resolvable, no registration
+ call, no `oauth_provider` row; `begin()` falls back to outbound when not
+ resolvable; a second `begin()` on the same server keeps using the document; `complete()`
+ via the document needs nothing stored beforehand.
+- [ ] Unit — wrong in each direction: a test pinning that a public-classified address is
+ treated as resolvable regardless of true reachability (direction 1, documented
+ blind spot); a test proving a misdetected-as-internal domain still completes a full
+ authorization via the outbound path (direction 2, harmless).
+- [ ] Commit: "gateways(mcp): swap client registration for the two-strategy version".
+
+## Phase 4 — close out
+
+- [ ] `ruff format` && `ruff check --fix` in `api/`.
+- [ ] Run the full API unit test suite; confirm no regression outside this package,
+ and confirm no test in the suite performs a real DNS lookup or network call.
+- [ ] Commit: "gateways(docs): close WP20 with the registration fallback".
+
+## Definition of done
+
+- A deployment whose `AGENTA_API_URL` is not publicly resolvable completes a full MCP
+ OAuth authorization via the outbound path, exactly as WP17 already did, with no
+ configuration flag involved in reaching that path.
+- A deployment whose `AGENTA_API_URL` is publicly resolvable completes one via the
+ client identity document instead, with no outbound registration call made.
+- `complete()` never re-evaluates `is_publicly_resolvable()`; the strategy travels in
+ `state`.
+- No test reaches a real authorization server, a real MCP server, or performs a real DNS
+ lookup.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp23.md b/docs/design/gateways-research/v1/workstreams/tasks-wp23.md
new file mode 100644
index 0000000000..2298f22f2b
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp23.md
@@ -0,0 +1,53 @@
+# WP23 — tasks
+
+Read [`specs-wp23.md`](specs-wp23.md) first. Branch from C1.
+
+## utils.py — one parser per protocol
+
+- [ ] Read `parse_llm_call_context` (`apis/fastapi/gateways/llms/utils.py:13`) first. It is
+ the pattern: a minimal parse for two fields, tolerant of a body it cannot read.
+- [ ] Add `parse_responses_call_context` and `parse_messages_call_context` beside it. Each
+ reads its own protocol's model and stream fields and returns the same
+ `LLMCallContext`. Three small functions, not one clever one.
+- [ ] Add the per-protocol ceiling field names: Chat Completions `max_tokens` /
+ `max_completion_tokens`, Responses `max_output_tokens`, Messages `max_tokens`. The
+ endpoint's config key stays `settings.max_output_tokens`.
+- [ ] Unit: each parser reads its protocol's fields; each returns a usable context from a
+ body it cannot parse rather than raising into the relay.
+
+## proxy.py — the routes
+
+- [ ] Register `/{namespace}/{name}/v1/responses` and `/{namespace}/{name}/v1/messages` for
+ both `standard` and `custom`, POST only, with explicit `operation_id`s following the
+ existing naming (`llm_gateway__`).
+- [ ] Handlers stay thin: read the body, parse the context with that door's parser, delegate
+ to the service. No branching on protocol below the handler.
+- [ ] `/v1/models` is untouched.
+- [ ] A door addressed on an endpoint whose upstream does not speak it returns 404 naming
+ both, using the frozen exceptions table — no new error codes.
+
+## service.py — the ceiling, and nothing else
+
+- [ ] `_check_ceilings` learns which request field to read from the context's protocol
+ rather than trying all three names. This is the one service change; if a second is
+ needed, report it before making it.
+- [ ] Unit: the ceiling binds per protocol, rejects above and passes at or below (D25).
+
+## Tests
+
+- [ ] Unit per door: route reaches handler, context is right, body passes through untouched.
+- [ ] Unit per door: usage extracted from that protocol's response and its final streaming
+ frame.
+- [ ] Unit: allowlist refusal happens on every door before any secret is touched.
+- [ ] Unit: the full route table matches the design exactly, the way the wave-1 router tests
+ do — a door added without a test is a door nobody knows about.
+- [ ] Acceptance: a request in each protocol relays byte for byte against a mock speaking it;
+ compare bytes.
+- [ ] `ruff format` && `ruff check --fix` in `api/`; run the API unit tests.
+- [ ] Commit: "gateways(llm): responses and messages front doors".
+
+## Definition of done
+
+- Three doors, each byte-for-byte in both directions.
+- Nothing below the handler knows which protocol it is serving.
+- WP24 can scope OD16 against the full door set.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp24.md b/docs/design/gateways-research/v1/workstreams/tasks-wp24.md
new file mode 100644
index 0000000000..6992f5a7f6
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp24.md
@@ -0,0 +1,56 @@
+# WP24 — tasks
+
+Read [`specs-wp24.md`](specs-wp24.md) first. Branch from WP23.
+
+## Phase 0 — verification, before any code
+
+- [ ] For each of Azure, Bedrock, SageMaker, Vertex, and each `direct` provider currently in
+ `_DIRECT_TRANSLATED_PROVIDERS`, answer OD16's three questions from the provider's own
+ request schema. Not from what the current adapter does.
+- [ ] Write the answers into `open-designs.md` OD16 and close it. A provider that fails is
+ recorded as unreachable, with the protocol it would need.
+- [ ] Commit: "gateways(docs): close OD16 with the per-provider answers".
+
+**If this phase says most providers fail, stop and report.** The package's shape assumes
+most pass; if they do not, the honest outcome is a smaller reachable set, and that is a
+scope conversation rather than something to code around.
+
+## Phase 1 — strategies
+
+- [ ] Split what the adapters do into a routing strategy (build the URL from route fields)
+ and an authentication strategy (present the secret). One pair per deployment kind.
+- [ ] Move Azure onto routing (base URL + deployment + api-version) and header auth
+ (`api-key`), with no body parse.
+- [ ] Move the cloud resellers OD16 cleared onto routing plus their signing strategy.
+- [ ] `select_upstream` chooses a strategy pair, not an adapter. Keep it pure — no I/O — so
+ the table stays reviewable on its own, as it is today.
+- [ ] Unit: URL composition per deployment; auth presentation per deployment; a caller's own
+ auth survives when no secret resolved.
+
+## Phase 2 — deletion
+
+- [ ] Delete `core/gateways/llms/providers/translated/`. Not deprecated, not left unwired —
+ a converting path that still exists is a path something will use.
+- [ ] Keep litellm for cost arithmetic and for signing. Remove every other use.
+- [ ] Unit: the only request-body `json.loads` in `core/gateways/llms/` is the policy parse.
+ A guard test, deliberately — this is the invariant a future edit breaks most quietly.
+- [ ] Unit: byte-for-byte relay per cleared deployment, streamed and not.
+- [ ] Unit: an unreachable provider raises, naming the protocol it needs.
+
+## Phase 3 — the column
+
+- [ ] Move the mock's selection off `provider_key == "mock"` onto the registry's wiring or a
+ deployment kind. A test double must not be why a column is required.
+- [ ] Migration: `llms_endpoints.provider_key` becomes nullable. Keep the column and its
+ index — `query_endpoints` filters on it.
+- [ ] Update the DTOs, mappings and the endpoint document in `entities.md` §2.4.
+- [ ] Verify the migration by hand against a real database, upgrade and downgrade
+ (`api/AGENTS.md` — no migration tests in pytest).
+- [ ] `ruff format` && `ruff check --fix`; run the API unit tests.
+- [ ] Commit: "gateways(llm): one relay, no conversion".
+
+## Definition of done
+
+- `TranslatedLLMAdapter` does not exist.
+- No request body is parsed except for the policy fields, enforced by a test.
+- OD16 is closed with per-provider answers, including the failures.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp25.md b/docs/design/gateways-research/v1/workstreams/tasks-wp25.md
new file mode 100644
index 0000000000..a6cb887495
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp25.md
@@ -0,0 +1,133 @@
+# WP25 — tasks
+
+Read [`specs-wp25.md`](specs-wp25.md) first. Branch from C2.
+
+## Phase 0 — the harness error-body matrix, before any code
+
+- [ ] `pnpm install` in `services/runner` so the pinned harness SDKs are readable source, not
+ just version pins.
+- [ ] Pi (`@earendil-works/pi-ai`): read `utils/error-body.js` and confirm it is wired into the
+ OpenAI-shaped client (`api/openai-completions.js`); read the Anthropic-shaped client's
+ dependency (`@anthropic-ai/sdk`'s `core/error.js` `APIError.makeMessage`) and confirm the
+ `JSON.stringify(errorResponse)` fallback fires for a body with no top-level `message`.
+- [ ] Claude Code (`@agentclientprotocol/claude-agent-acp`): read `dist/acp-agent.js`'s
+ `is_error` branch and confirm it forwards the CLI's `result` string unmodified. Confirm
+ the CLI itself (`@anthropic-ai/claude-agent-sdk`) is a compiled binary with no bundled
+ source — record this as the boundary of what is verifiable, not as a pass.
+- [ ] Codex (`@agentclientprotocol/codex-acp` + `@openai/codex`, not an npm dependency of this
+ package): fetch `codex-rs/protocol/src/error.rs` at the tag matching the pinned npm
+ version and read `UnexpectedResponseError::extract_error_message`. Confirm it keeps only
+ `error.message`, discarding `code`/`type`.
+- [ ] Write all three findings into `open-designs.md` OD18 with file/line evidence per harness —
+ Codex's fail is a finding, not a defect to fix by parsing harder.
+- [ ] Commit: "gateways(docs): close OD18 with the per-harness error-body matrix".
+
+## Phase 1 — the marker: close Codex's gap on the gateway's side
+
+Recording Codex's failure is not sufficient — WP19's step-up interaction is built on this
+channel, and a refusal without a code cannot be acted on. The fix: since `error.message`
+survives on every harness examined, including Codex, put a machine-readable marker inside it.
+
+- [ ] `api/oss/src/apis/fastapi/gateways/utils.py` (shared by both proxies already, for
+ `response_headers`): add `CODE_MARKER_OPEN`/`CODE_MARKER_CLOSE` and `with_code_marker
+ (message, code)` HERE, not in `llms/proxy.py` — the MCP plane needs the identical helper
+ and a second copy is the drift CU12 spent this wave proving is expensive.
+- [ ] `api/oss/src/apis/fastapi/gateways/llms/proxy.py`: enumerate the actual five refusals by
+ their exception (`SecretNotFoundError` → `secret_missing`, `SecretInvalidError` →
+ `secret_invalid`, `LLMEndpointNotFoundError` → `endpoint_not_found`,
+ `LLMModelNotAllowedError` → `model_not_allowed`, `GatewayEndpointInactiveError` →
+ `endpoint_inactive`). While enumerating, confirm `SecretInvalidError` (the real "rejected
+ credential") is actually mapped and actually caught — it was neither, before this task.
+ Add both: a `_map_domain_exception` branch (409, `secret_invalid`) and the exception to
+ `_DOMAIN_EXCEPTIONS`.
+- [ ] Add a `marked` flag on `_openai_error` (default `True`), rendering
+ `with_code_marker(message, code)` for every typed refusal. Pass `marked=False` for the
+ `LLMUpstreamError`/`upstream_error` branch — D16 forbids injecting into the upstream's own
+ forwarded detail.
+- [ ] Unit (`api`): every typed code's rendered message ends with its marker; `upstream_error`'s
+ never contains one; `SecretInvalidError` reaches the caller as `secret_invalid` rather than
+ an unhandled 500 (parametrize the existing `_DENIAL_CASES` table rather than duplicating
+ it).
+- [ ] Commit: "gateways(llm-proxy): render a machine-readable code marker on every typed refusal,
+ wire the missing SecretInvalidError mapping".
+
+## Phase 1b — the same marker, the same audit, on the MCP plane
+
+WP26 (an agent requesting a missing connection) needs this plane's version of the channel
+specifically, and D35's second consequence is dead on Codex without it, same as the LLM case.
+
+- [ ] `api/oss/src/apis/fastapi/gateways/mcps/proxy.py`: add a `marked` flag to `_protocol_error`
+ (default `True`), rendering `with_code_marker(message, cause)` for every cause. Pass
+ `marked=False` for the `MCPUpstreamError`/`upstream_error` branch — same D16 reasoning,
+ same exclusion, this plane too.
+- [ ] Run the SAME audit Phase 1 ran on the LLM plane: `grep -rn "raise [A-Z]"` across
+ `core/gateways/mcps/service.py` and `registry.py`; confirm every raised exception has a
+ branch in `_map_gateway_exception` AND is listed in `_MAPPED_EXCEPTIONS`. Report the
+ result either way in OD18 — a clean audit is still worth recording, since the LLM side's
+ wasn't.
+- [ ] Unit (`api`): every mapped cause's rendered `message` ends with its marker (parametrize the
+ existing per-cause table in `test_gateways_mcp_proxy.py`); `upstream_error`'s never does.
+- [ ] Commit: "gateways(mcp-proxy): apply the shared code marker; audit finds no mapping gap".
+
+## Phase 2 — the marker fallback in the runner, both planes
+
+- [ ] `services/runner/src/gateway-error.ts`: add `CODE_MARKER_RE` matching
+ `⟦agenta_code:([a-z_]+)⟧`, and `parseFromMarker` — matches the marker, strips it from the
+ text for a clean `message`, returns `{code, message, retryable: false}` with no
+ `next_step`/`details` (never backfilled from `NEXT_STEPS`).
+- [ ] Restructure `parseGatewayErrorDetail` to try the existing body scan first
+ (`parseFromBody`), then `parseFromMarker` as fallback. No change to the body scan itself.
+- [ ] Add `secret_invalid` to `NEXT_STEPS` (used only on the body path, where `next_step` is
+ populated from it).
+- [ ] Unit (`tests/unit/gateway-error-harness-formats.test.ts`): per LLM refusal code, two cases
+ — the Pi/Anthropic-SDK shape (marker riding inside the JSON-embedded body) recovering the
+ full envelope via the body path with `message` UNCHANGED (marker included, since the body
+ scan doesn't know to strip it); Codex's shape
+ (`"unexpected status {n}: {message} ⟦agenta_code:{code}⟧"`) recovering `code` alone via
+ the marker path, `message` marker-stripped, `next_step`/`details` asserted absent.
+- [ ] Unit, same file: per MCP refusal cause, two more cases proving the marker is the ONLY
+ channel on this plane (not merely a fallback) — the full JSON-RPC body embedded verbatim
+ (`{"jsonrpc":"2.0","error":{"code":-32000,"message":"","data":{"cause":...}}}`,
+ still only the marker recovers it, because `error.code` is a number there, not the LLM
+ plane's string) and Codex's stripped-to-`message` shape.
+- [ ] `pnpm test` and `pnpm run typecheck` in `services/runner`; confirm no new failures beyond
+ the ~19 pre-existing ones on `origin/main`.
+- [ ] Commit: "gateways(runner): recover code from the marker when the gateway body is gone".
+
+## Phase 3 — close the agent-service gap
+
+- [ ] `sdks/python/agenta/sdk/agents/adapters/vercel/stream.py`: `_error_parts` reads
+ `getattr(error, "error_detail", None)` and, when truthy, adds it as `data["errorDetail"]`
+ on the `data-agent-error` part. `code`/`errorText` unchanged.
+- [ ] Confirm both call sites (`agent_run_to_vercel_parts`'s dev-only `except` branch and
+ `agent_stream_to_vercel_stream`'s live `except` branch) pick it up for free, since both
+ already call `_error_parts(..., error=exc)`.
+- [ ] Unit: a mock `AgentRunFailed`-shaped exception with `error_detail` set produces a
+ `data-agent-error` part carrying it; one without `error_detail` produces a part with no
+ `errorDetail` key.
+- [ ] Unit: each of the five refusal codes (the corrected mapping above, `secret_invalid` not
+ `upstream_error`), built as a `result_from_wire`-shaped
+ `{"ok": false, "error": ..., "errorDetail": {...}}` dict, raised through `AgentRunFailed`,
+ reaches the vercel stream's `data-agent-error.data.errorDetail` with its `code` intact.
+- [ ] `ruff format` && `ruff check --fix` in both `api` and `sdks/python`; run the SDK's unit
+ tests (`cd sdks/python && py-run-tests`, or the narrower agents test path if the full
+ suite is slow) and the API's gateway unit tests (`cd api && py-run-tests`, or
+ `oss/tests/pytest/unit/gateways/`).
+- [ ] Commit: "gateways(agent-service): surface errorDetail on the vercel stream".
+
+## Definition of done
+
+- OD18 is closed with per-harness evidence, including Codex's failure on the body path, and
+ extended to record that the MCP plane never uses the body path at all (shape mismatch).
+- Every refusal reaches the caller carrying its cause, proven per harness AND per plane — the
+ body path where it survives (LLM only), the marker everywhere else (Codex on the LLM plane,
+ every harness on the MCP plane).
+- `SecretInvalidError` ("rejected credential") is an actual reachable refusal on the LLM plane,
+ not an unhandled 500 — a gap that predated this package, closed as part of enumerating the
+ refusals. The same audit on the MCP plane found no equivalent gap, reported either way.
+- One `with_code_marker` implementation (`gateways/utils.py`), applied identically by both
+ proxies — not two copies that can drift.
+- `stream.py` carries `errorDetail` from a caught `AgentRunFailed` onto the vercel
+ `data-agent-error` part, proven for all five refusal codes.
+- No regression: `error`/`errorText` unchanged for every caller reading only those fields; the
+ marker never appears in `upstream_error`'s forwarded detail, on either plane.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp26.md b/docs/design/gateways-research/v1/workstreams/tasks-wp26.md
new file mode 100644
index 0000000000..d70e8a23d1
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp26.md
@@ -0,0 +1,58 @@
+# WP26 — tasks
+
+Read [`specs-wp26.md`](specs-wp26.md) first. Branch from `feat/gateways-c2`.
+
+## Phase 1 — widen the tool contract
+
+- [ ] `static_catalog.py`'s `_client_tool_revision()`: add the `target` object
+ (`plane: "llm"|"mcp"`, `name`) to `input_schema.properties`; drop `required` from
+ `["integration"]` to `[]`; update the tool and workflow descriptions to name both
+ paths and the "exactly one of" rule.
+- [ ] Keep `integration`, `slug`, `mode` byte-identical in shape — only their descriptions
+ gain a one-line note about being ignored for a gateway target.
+- [ ] Unit: the widened schema still coerces through `coerce_tool_config` to a
+ `ClientToolConfig` (mirrors the existing embed-resolution test), for both an
+ `integration`-only call and a `target`-only call.
+- [ ] Unit: schema shape assertions — `target.required == ["plane", "name"]`,
+ `target.properties.plane.enum == ["llm", "mcp"]`, top-level `required == []`.
+
+## Phase 2 — the browser widget's gateway path
+
+- [ ] Add `useGatewayConnectFlow` (new file, sibling to `useConnectFlow.ts`): reads
+ `meta.input.target`, exposes the same shape of surface (`phase`, `outcome`, `runConnect`
+ equivalents) `ConnectToolWidget` already consumes from `useConnectFlow`, but backed by
+ `ProviderDrawer` (llm) or the shared `toolCatalogDrawerOpenAtom` (mcp) instead of the
+ OAuth popup machinery.
+- [ ] `ConnectToolWidget`: branch at the top on `input.target` presence — gateway path routes
+ through the new hook; existing branch is otherwise unchanged (no behavior change for
+ `integration` calls).
+- [ ] LLM settle: `ProviderDrawer.onSaved` → `{connected: true, target}`; close without save →
+ `{connected: false, reason: "cancelled"}`.
+- [ ] MCP settle: catalog-drawer open→close transition → `{connected: true, target}}` — see
+ spec's "Settle semantics" for why this is optimistic-but-safe.
+- [ ] "Not now" settles `{connected: false, reason: "declined"}` before anything opens, both
+ planes — same as the existing integration path.
+- [ ] Unit (vitest, no real backend, no real drawer render): the pure classification helper
+ (`target` present → gateway path; `integration` present → existing path; neither →
+ the existing malformed-call handling) and the settle-output shape builders, mirroring
+ `useConnectFlow.test.ts`'s style (pure functions extracted and tested directly, not
+ through full component render).
+
+## Phase 3 — wire-through check
+
+- [ ] Confirm no other layer hard-requires `integration` on this tool's input: runner
+ (`services/runner/src/tools/*`, `protocol.ts`) treats client-tool `input` as opaque
+ passthrough — verify by grep, no code change expected there.
+- [ ] `ruff format` && `ruff check --fix` (api); `pnpm lint-fix` (web) if touched files need it.
+- [ ] Commit: "gateways(workflows): request_connection also asks for a gateway target".
+
+## Definition of done
+
+- An `integration`-only `request_connection` call behaves exactly as before wave 3 (existing
+ tests green, unchanged).
+- A `target`-only call pauses the same way, and the widget opens the LLM or MCP plane's
+ existing registration surface based on `target.plane`.
+- The parked call settles on save (llm) or drawer-close (mcp), and the run resumes.
+- Neither `integration` nor `target` is required by the schema in isolation; a call with
+ neither is handled the same way a malformed call always was (no crash, no unhandled
+ branch).
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp27.md b/docs/design/gateways-research/v1/workstreams/tasks-wp27.md
new file mode 100644
index 0000000000..116bc0a133
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp27.md
@@ -0,0 +1,83 @@
+# WP27 — tasks
+
+Read [`specs-wp27.md`](specs-wp27.md) first. Branch from C2.
+
+## Phase 0 — verification
+
+Already closed before this package started (D40, `specs-wp27.md`): Bedrock rejects a body
+that still carries `model`; Vertex has no attestation either way and drops it regardless.
+Both operations ship. No live vendor call — nothing to do here but confirm the record in
+`decisions.md` D40 stands. Do not repeat it.
+
+## Phase 1 — the table and the applying function
+
+- [ ] Add `LLMStaticFieldRewrite` (`fields_added: Dict[str, Any]`, `fields_removed:
+ List[str]`) and `STATIC_FIELD_REWRITES: Dict[LLMDeploymentKind,
+ LLMStaticFieldRewrite]` to a new `core/gateways/llms/providers/passthrough/
+ static_fields.py`, with exactly the two entries D40 specifies.
+- [ ] Add `apply_static_fields(*, deployment_kind, protocol, body) -> bytes`: a no-op unless
+ `protocol == LLMProtocol.MESSAGES` and `deployment_kind` is in the table; otherwise
+ `json.loads` the body, `pop()` each `fields_removed` name, `setdefault()` each
+ `fields_added` pair, `json.dumps` back to bytes. On a non-dict or unparsable body,
+ return it unchanged rather than raising — a malformed body is a policy-parse failure
+ elsewhere, not this function's problem.
+- [ ] Wire it into `RelayLLMAdapter.relay_chat_completion` (`providers/passthrough/
+ adapter.py`): call it on `body` before building the outbound `httpx` request, after
+ routing/auth are resolved.
+- [ ] Unit: `BEDROCK` adds `anthropic_version: "bedrock-2023-05-31"` and removes `model`.
+- [ ] Unit: `VERTEX` adds `anthropic_version: "vertex-2023-10-16"` and removes `model`.
+- [ ] Unit: a body already carrying `anthropic_version` keeps its own value on both.
+- [ ] Unit: a non-`MESSAGES` protocol leaves a `BEDROCK`/`VERTEX` body untouched.
+- [ ] Unit: every other `deployment_kind` is untouched byte for byte on the Messages door.
+
+## Phase 2 — proving the table can't read the request
+
+- [ ] Unit: walk `STATIC_FIELD_REWRITES` and assert every `fields_added` value and every
+ `fields_removed` entry is a literal (`str`/`int`/`float`/`bool`/`None`) — never a
+ callable, never derived.
+- [ ] Unit: `inspect.signature(apply_static_fields)` carries exactly `deployment_kind`,
+ `protocol`, `body` — no parameter through which request semantics could enter beyond
+ the raw bytes it patches generically.
+- [ ] Add `static_fields.py` to `test_gateways_llm_no_body_conversion.py`'s `_ALLOWED` set,
+ with the reason (D40's carve-out, table-driven, gated to `MESSAGES`).
+
+## Phase 3 — the URL half (the pair the body half needs)
+
+Removing `model` from the body is only correct if `route.model` actually reaches the
+upstream some other way. `_bedrock_url`/`_vertex_url` in `routing.py` compose each vendor's
+OpenAI-compatible door and never put the model id in the path — that is a different wire
+from the one D40 is about, and leaving it as the Messages door's route would send a request
+with `model` in neither the body nor the URL.
+
+- [ ] Add `_bedrock_messages_url(route, *, stream) -> str`: `{base_url or
+ https://bedrock-runtime.{region}.amazonaws.com}/model/{route.model}/{invoke |
+ invoke-with-response-stream}`. Raise `_no_route` naming the provider, before any I/O,
+ when `route.model` is missing.
+- [ ] Add `_vertex_messages_url(route, *, stream) -> str`: `{base_url or
+ https://{region}-aiplatform.googleapis.com/v1/projects/{project}/locations/{region}}
+ /publishers/anthropic/models/{route.model}:{rawPredict | streamRawPredict}`. Same
+ missing-model guard.
+- [ ] `build_url` gains a `stream: bool = False` keyword and, when `protocol ==
+ LLMProtocol.MESSAGES`, checks a `_MESSAGES_ROUTING` table for `BEDROCK`/`VERTEX` before
+ falling through to the existing `_ROUTING` table — CHAT_COMPLETIONS/RESPONSES for those
+ two kinds keep composing the OpenAI-compatible door exactly as before.
+- [ ] `RelayLLMAdapter` passes `stream=context.stream` into `build_url` — the same field
+ D33's policy parse already carries, so no new body read.
+- [ ] Unit: Bedrock/Vertex Messages URL composition, non-streaming and streaming, plus the
+ missing-model failure, plus a test proving CHAT_COMPLETIONS/RESPONSES for the same two
+ kinds are byte-identical to before this phase.
+- [ ] Unit, the pairing test: one test per kind, through `RelayLLMAdapter`, asserting both
+ halves together — outbound URL contains `route.model`, outbound body does not — so the
+ two halves are checked in the same assertion and cannot silently drift apart again.
+- [ ] `ruff format` && `ruff check --fix`; run the API unit tests.
+- [ ] Commit: "gateways(llm): static field rewrite for resold Anthropic wires (D40)".
+
+## Definition of done
+
+- The two deployments' table entries exist and are applied on the Messages front door.
+- The Messages door's URL for both kinds is the real InvokeModel/rawPredict shape, with
+ `route.model` in the path; CHAT_COMPLETIONS/RESPONSES for the same two kinds are unchanged.
+- A test proves no table entry or the applying function reads the request.
+- A test proves the URL and body halves together: model out of the body, into the URL.
+- Every other deployment still relays byte for byte; Bedrock and Vertex are named as the
+ exemption, not folded into a weakened universal assertion.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp3.md b/docs/design/gateways-research/v1/workstreams/tasks-wp3.md
new file mode 100644
index 0000000000..12d30bffe0
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp3.md
@@ -0,0 +1,140 @@
+# WP3 tasks — Policy core
+
+Ordered so each item is one reviewable commit. Depends on the seed commit
+(`core/gateways/policy/{dtos,types,interfaces}.py`) already existing on the base branch.
+Depends on nothing else — WP3 can start immediately alongside WP1 and WP2.
+
+## Setup
+
+- [x] Read `core/access/permissions/service.py::check_action_access` and
+ `check_project_has_role_or_permission` in full — confirm the parameter types
+ (`user_uid: str`, `project_id: Optional[str]`) and the existing `Flag.RBAC`
+ plan-gate behavior folded inside it. That gate lives inside the permission call and
+ is not an entitlement check this package adds — WP3 adds none at all (D29).
+
+## `core/access/permissions/types.py` — Permission enum
+
+- [x] Add the six new `Permission` members after `USE_MOUNTS` (or wherever the gateway
+ block reads best, but do not scatter them across the file): `VIEW_LLM_ENDPOINTS`,
+ `EDIT_LLM_ENDPOINTS`, `USE_LLM_ENDPOINTS`, `VIEW_MCP_ENDPOINTS`,
+ `EDIT_MCP_ENDPOINTS`, `USE_MCP_ENDPOINTS` — values are the lower-snake-case string
+ form of each name, exactly as `entities.md` §9 lists.
+- [x] In `default_permissions()`, add `cls.VIEW_LLM_ENDPOINTS`, `cls.VIEW_MCP_ENDPOINTS`
+ to `VIEWER_PERMISSIONS`.
+- [x] Add `cls.USE_LLM_ENDPOINTS`, `cls.USE_MCP_ENDPOINTS` to `ANNOTATOR_PERMISSIONS`
+ (built as `VIEWER_PERMISSIONS + [...]` — do not also re-add the `VIEW_*` pair
+ here).
+- [x] Add `cls.EDIT_LLM_ENDPOINTS`, `cls.EDIT_MCP_ENDPOINTS` to `EDITOR_PERMISSIONS`
+ (built as `ANNOTATOR_PERMISSIONS + [...]`).
+- [x] Confirm by reading the method body that `DEVELOPER_PERMISSIONS`, `ADMIN_PERMISSIONS`
+ and `OWNER`'s `[p for p in cls]` all pick up the six new members automatically
+ through the superset chain — do not add them a second time anywhere.
+- [x] Ruff format + check; commit: "core/access: add gateway endpoint permissions".
+ Landed as `3014da465c`.
+
+## `core/gateways/policy/service.py` — skeleton
+
+- [x] `GatewayPolicyService.__init__(self, *, resolver: SecretsResolverInterface) ->
+ None`: store `self.resolver = resolver`. Nothing else in the constructor.
+
+## `authorize()`
+
+- [x] Implement `authorize(self, *, scope: AuthScope, permission: Permission, target:
+ GatewayTarget) -> PolicyDecision`: call `check_action_access(user_uid=str(scope.user_id),
+ project_id=str(scope.project_id), permission=permission)`. `False` → return
+ `PolicyDecision(allowed=False, permission=permission, reason="permission_denied")`.
+- [x] `True` → return `PolicyDecision(allowed=True, permission=permission, reason=None)`.
+ One check, not two (D29).
+- [x] Confirm no `try/except` around the `check_action_access` call swallows an exception
+ into `allowed=True` — permission is fail-closed; let an unexpected exception from
+ `check_action_access` propagate (or explicitly convert to `allowed=False` — pick
+ one, document the choice in a one-line comment, and make the unit test assert the
+ chosen behavior). **Chosen: propagate.** No `try/except` at all — reproduces
+ `specs-wp3.md`'s own snippet verbatim, so "raises nothing" describes only this
+ method's *own* return contract (it never manufactures a decision on error), not a
+ mandate to catch its dependency's exceptions. A one-line comment in the code marks
+ the choice; `test_authorize_propagates_when_check_action_access_raises` pins it.
+
+## No `_check_entitlement()` — removed by ruling (D29)
+
+- [x] Write **no** entitlement method and **no** placeholder key. Every user has both
+ gateways, so the check would ask a question with one answer; what entitlements will
+ express here are limits, which cannot be enforced before anything is measured. It
+ ships with usage metering and billing (D29, closing R5).
+- [x] `EntitlementDeniedError` and `reason="entitlement_denied"` stay declared in the seed
+ and mapped at the boundary — nothing in wave 1 raises either. Do not delete them, and
+ do not add a call that always permits: a later reader mistakes it for enforcement.
+
+## `record()` — the wave-1 stub
+
+- [x] Implement `record(self, *, scope: AuthScope, target: GatewayTarget, decision:
+ PolicyDecision, outcome: GatewayOutcome) -> None`: accept the full signature, do
+ nothing beyond an optional `log.debug` noting the stub was invoked, `return`. No
+ `publish_event` call, no partial audit attribute building — that is WP4's file
+ (`policy/audit.py`), which does not exist yet.
+- [x] Confirm by reading the diff that this method cannot raise under any input —
+ `entities.md` §8's contract ("never raises — the caller's response must not depend
+ on the stream") applies from wave 1 even though the body is empty.
+
+## Ruff
+
+- [x] Ruff format then ruff check `core/gateways/policy/service.py` and
+ `core/access/permissions/types.py`; fix all errors.
+- [x] Commit: "core/gateways: implement GatewayPolicyService". Landed as `a834f13bfc`.
+
+## tests — unit (run now)
+
+- [x] `api/oss/tests/pytest/unit/gateways/test_gateways_policy_service.py`: mock
+ `check_action_access` at the module boundary. Nothing else to mock — `authorize()`
+ has one dependency.
+- [x] `check_action_access` → `True`: `authorize()` returns
+ `allowed=True, reason=None`.
+- [x] `check_action_access` → `False`: `authorize()` returns `allowed=False,
+ reason="permission_denied"`.
+- [x] `check_action_access` raises: assert the documented behavior (no exception escapes
+ `authorize()` — confirm which of "propagates" vs "caught and treated as denied" was
+ chosen in the implementation task above, and pin it here). Propagates; asserted with
+ `pytest.raises(RuntimeError)`.
+- [x] `record()` called with representative arguments returns `None`, raises nothing, and
+ (via a patch on `publish_event` or equivalent) is confirmed to call **no** publish
+ path.
+- [x] `Permission.default_permissions(DefaultRole.VIEWER)` contains
+ `VIEW_LLM_ENDPOINTS` and `VIEW_MCP_ENDPOINTS`.
+- [x] `Permission.default_permissions(DefaultRole.ANNOTATOR)` contains those two plus
+ `USE_LLM_ENDPOINTS`, `USE_MCP_ENDPOINTS`.
+- [x] `Permission.default_permissions(DefaultRole.EDITOR)` contains all of the above plus
+ `EDIT_LLM_ENDPOINTS`, `EDIT_MCP_ENDPOINTS`.
+- [x] `Permission.default_permissions(DefaultRole.ADMIN)` and
+ `.default_permissions(DefaultRole.OWNER)` both contain all six — the superset
+ propagation check. `DEVELOPER` is checked too (same superset chain), parametrized
+ alongside `ADMIN`/`OWNER`.
+- [x] Ruff format + check; commit. Landed as `ba1ec49b83` (10 tests, all passing).
+
+## `api/entrypoints/routers.py` diff (hand off at merge, do not commit directly)
+
+- [x] Write the `GatewayPolicyService(resolver=secret_resolver)` construction line
+ from `specs-wp3.md` into this package's PR description for the IM1 merge — ordered
+ after WP2's `secret_resolver` construction line. Recorded below for the merge to
+ apply; this package does not touch `api/entrypoints/routers.py` itself.
+
+```python
+# api/entrypoints/routers.py — add after WP2's secret_resolver construction line
+from oss.src.core.gateways.policy.service import GatewayPolicyService
+
+gateway_policy_service = GatewayPolicyService(resolver=secret_resolver)
+```
+
+## Definition of done
+
+Feeds **IM1**, then **C1** through every plane service and management router
+that calls `authorize()`. Exit condition, verbatim from `plan.md`: *"a caller without
+permission on an endpoint is refused before any upstream call."*
+
+WP3 is done when: the unit suite above passes in full; the six `Permission` members are
+correctly wired through `VIEWER`/`ANNOTATOR`/`EDITOR` and propagate to
+`DEVELOPER`/`ADMIN`/`OWNER`; `authorize()` never raises and never returns anything but a
+`PolicyDecision`; and `record()` is safely callable with the full signature and produces
+no observable side effect in wave 1. Note in the IM1/IM2 merge notes that "every member is
+checked by a named route" (the `RUN_TRIGGERS` lesson) cannot be fully verified from this
+package alone — it depends on WP6/WP8/WP10 actually calling `authorize()` with each of
+the six permissions, and should be grepped for at the C1 merge, not assumed.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp4.md b/docs/design/gateways-research/v1/workstreams/tasks-wp4.md
new file mode 100644
index 0000000000..41d42e40ee
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp4.md
@@ -0,0 +1,42 @@
+# WP4 — tasks
+
+Read [`specs-wp4.md`](specs-wp4.md) first. Branch from C1; no seed dependency.
+
+## audit.py — the attribute builder
+
+- [ ] Read `core/events/utils.py`'s `build_trace_fetched_attributes` /
+ `publish_trace_fetched` pair before writing anything. This package copies that shape;
+ it does not invent one.
+- [ ] `core/gateways/policy/audit.py`: `build_gateway_call_attributes(*, scope, target,
+ decision, outcome) -> Dict[str, Any]`. Flat mapping, no nesting.
+- [ ] Carry: principal (including `organization_id`), `target.plane`, `namespace`, `name`,
+ `endpoint_id`, `model` when present, allowed/denied plus the reason, status code, and
+ `secret_origin` when a secret resolved.
+- [ ] Carry nothing else. No prompt, no completion, no secret value, no header values.
+
+## service.py — filling the stub
+
+- [ ] Replace `record()`'s body with build + publish. **Do not touch its signature** — every
+ relay in wave 1 already calls it, and R4 exists so that this is a body change.
+- [ ] Wrap the publish so it cannot raise, the way `_safe_publish` already does. `record()`
+ is called on the deny path, where an exception turns a 403 into a 500.
+
+## Tests
+
+- [ ] Unit: one event per relay on both planes, with the right principal, target, decision
+ and outcome. Mock publisher; nothing running.
+- [ ] Unit: a denied call records one event carrying the reason, and `PolicyDeniedError` is
+ still raised afterwards.
+- [ ] Unit: a publisher that raises does not propagate — assert the relay's result is
+ unchanged, not merely that no exception escaped.
+- [ ] Unit: a pass-through call records with `secret_origin` unset, which is what
+ distinguishes a call we funded from one the caller did.
+- [ ] Unit: the attributes contain no value from the request or response body.
+- [ ] `ruff format` && `ruff check --fix` in `api/`; run the API unit tests.
+- [ ] Commit: "gateways(policy): emit one audit event per call".
+
+## Definition of done
+
+- Every relay, allowed or denied, on either plane, leaves exactly one event.
+- The events are queryable through the existing surface with no new code.
+- `record()`'s signature is byte-identical to wave 1's.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp5.md b/docs/design/gateways-research/v1/workstreams/tasks-wp5.md
new file mode 100644
index 0000000000..825759e544
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp5.md
@@ -0,0 +1,214 @@
+# WP5 tasks — Test doubles
+
+Ordered so each item is one reviewable commit. Depends on nothing — branches from the seed
+commit and starts immediately; does not wait on WP1/WP2/WP3. Run `ruff format` then `ruff check
+--fix` (from the repo root) before every commit and fix all errors, per `api/AGENTS.md`.
+
+## Phase 1 — `MockLLMAdapter` (in-process)
+
+- [x] `core/gateways/llms/providers/mock/__init__.py`.
+- [x] `core/gateways/llms/providers/mock/adapter.py`: `MockLLMAdapter(LLMUpstreamInterface)`,
+ no constructor arguments, implementing `relay_chat_completion(self, *, route, secret,
+ context, body, headers) -> LLMRelayResult` per `entities.md` §7.1 exactly.
+- [x] Implement the `mock/echo` default path: build an OpenAI-shaped chat-completion response
+ echoing the last message in the parsed request body; non-streaming returns one `body`
+ chunk.
+- [x] Implement `context.stream=True` for `mock/echo`: yield 2–3 SSE-framed chunks over `body`,
+ terminated by `data: [DONE]\n\n`.
+- [x] Implement `mock/error`: raise `LLMUpstreamError(provider_key="mock", status_code=500,
+ detail="forced by mock/error")` before producing any `body`.
+- [x] Implement `mock/slow-{seconds}`: parse the integer suffix, `await asyncio.sleep(seconds)`,
+ then return the `mock/echo` response.
+- [x] Populate `GatewayUsage` (`calls=1`, `input_tokens`/`output_tokens` from a word-count
+ approximation, `cost=0.0`) on `LLMRelayResult.usage`, set once `body` is exhausted per the
+ dataclass's own docstring.
+- [x] Ruff format + check; run and fix.
+- [x] Unit tests (`test_mock_llm_adapter.py`): `mock/echo` returns a well-formed
+ `LLMRelayResult`; `mock/error` raises `LLMUpstreamError`; `mock/slow-1` takes ≥1s wall
+ clock; streaming yields >1 chunk ending `[DONE]`; `usage` is non-`None` after exhaustion.
+- [x] Commit: "wp5: mock LLM adapter (in-process)". — `46f6466ea5`
+
+## Phase 2 — `MockMCPAdapter` (in-process)
+
+- [x] `core/gateways/mcps/providers/mock/__init__.py`.
+- [x] `core/gateways/mcps/providers/mock/adapter.py`: `MockMCPAdapter(MCPUpstreamInterface)`,
+ implementing `relay(self, *, route, auth, context, body, headers) -> MCPRelayResult` per
+ `entities.md` §7.1 exactly.
+- [x] Implement `initialize` and `tools/list`, returning the three tools (`echo`, `fail`, `slow`)
+ with their input schemas.
+- [x] Implement `tools/call` dispatch on `params.name`: `echo` returns `params.arguments` as tool
+ result content; `fail` returns a JSON-RPC **result** with `isError: true` (never raises);
+ `slow` sleeps `params.arguments.seconds` (default 5) then returns a fixed result.
+- [x] Implement the notification path: any `notifications/*` method returns `status_code=202`
+ with an empty `body`, matching the runner's internal MCP server's own 202-for-notification
+ shape (`services/runner/src/tools/tool-mcp-http.ts`).
+- [x] Implement the fallback: any other `method` raises `MCPUpstreamError(target=..., status_code=501)`.
+- [x] Ruff format + check; run and fix.
+- [x] Unit tests (`test_mock_mcp_adapter.py`): `tools/list` returns all three tools; `echo`
+ echoes; `fail` returns `isError: true` without raising; `slow` with `seconds=1` takes ≥1s;
+ an unknown method raises `MCPUpstreamError(status_code=501)`.
+- [x] Commit: "wp5: mock MCP adapter (in-process)". — `9064436bcd`
+
+ Judgment call: `tools/call` with a `name` outside the three declared tools returns a JSON-RPC
+ result with `isError: true` ("unknown tool: {name}") rather than an unhandled crash — not
+ spec'd explicitly, chosen because an unknown tool is a protocol-level failure (D16 pass-through),
+ not a transport failure, matching the treatment `fail` already gets.
+
+## Phase 3 — Contract tests
+
+- [x] `api/oss/tests/pytest/unit/gateways/test_mock_adapters_contract.py`: a parametrized
+ fixture that both `MockLLMAdapter` and (via an import guard, skipped until it exists)
+ `PassthroughLLMAdapter`/`TranslatedLLMAdapter` must pass — asserts
+ `relay_chat_completion` always returns `LLMRelayResult`, never a raw dict, for
+ `mock/echo`.
+- [x] Same shape for `relay` returning `MCPRelayResult` across `{initialize, tools/list,
+ tools/call}`.
+- [x] Ruff format + check; run and fix.
+- [x] Commit: "wp5: adapter interface contract tests". — `83807511c8`
+
+ Also parametrized in `HttpMCPAdapter`/`ComposioMCPAdapter` (skip-until-exists) on the MCP
+ side, since the spec text only names the LLM pair explicitly but the MCP plane has the
+ same two-real-adapters shape (`http`/`composio`, entities.md §0) — extending the guard to
+ both keeps the file from needing an edit when either lands.
+
+## Phase 4 — Deployable mock LLM server
+
+- [x] `core/gateways/llms/providers/mock/app.py`: a FastAPI app, `GET /health` returning 200.
+- [x] `POST /v1/chat/completions`: parse the OpenAI-shaped request body, dispatch on `"model"`
+ using the identical `mock/echo` / `mock/error` / `mock/slow-{n}` convention as
+ `MockLLMAdapter` — same behavior, standalone process.
+- [x] Streaming: `"stream": true` returns `Content-Type: text/event-stream` with real SSE framing
+ over the wire.
+- [x] Verify by hand (`uvicorn core.gateways.llms.providers.mock.app:app --port 9091` locally,
+ `curl`) before wiring into compose — this step needs nothing running beyond the process
+ itself, not the stack. Ran locally on port 19091 (avoiding a collision with any deployed
+ stack): `/health` 200; `mock/echo` 200 with the echoed content; `mock/error` a real HTTP
+ 500 with the OpenAI error envelope; `stream:true` produced `Content-Type:
+ text/event-stream` with 3 SSE frames ending `data: [DONE]` on the wire; `mock/slow-30`
+ with `curl -m 2` cut the connection at 2s (curl exit 28, a genuine socket timeout, not an
+ in-process await).
+- [x] Ruff format + check; run and fix.
+- [x] Commit: "wp5: deployable mock LLM server". — `ab9ecab033`
+
+ Implementation delegates every request to `MockLLMAdapter` directly (constructs
+ `LLMCallContext`/`LLMResolvedRoute`, calls `relay_chat_completion`) rather than
+ reimplementing the echo/streaming/error logic a second time — this is what makes "same
+ control convention on both tiers" true by construction instead of by discipline.
+
+## Phase 5 — Deployable mock MCP server
+
+- [x] `core/gateways/mcps/providers/mock/app.py`: `GET /health` returning 200.
+- [x] `POST /` (root): stateless-JSON-mode MCP Streamable HTTP — one JSON-RPC request in, one
+ `application/json` response out, `202` with empty body for a notification, matching
+ `tool-mcp-http.ts`'s framing.
+- [x] `GET /` and `DELETE /`: `405`.
+- [x] Same three tools (`echo`, `fail`, `slow`) as `MockMCPAdapter`, same dispatch convention.
+- [x] Verify by hand (`uvicorn ... --port 9092`, `curl -X POST` with a `tools/list` body) before
+ wiring into compose. Ran locally on port 19092: `/health` 200; `tools/list` returned all
+ three tools; `tools/call name=echo` echoed `{"a": 1}`; `name=fail` returned `isError:
+ true` at HTTP 200 (not an exception); `notifications/initialized` returned 202 with an
+ empty body; `GET /` and `DELETE /` both 405; an unrecognized method returned a real HTTP
+ 501.
+- [x] Ruff format + check; run and fix.
+- [x] Commit: "wp5: deployable mock MCP server". — `406c0aa6d8`
+
+ Same delegation choice as Phase 4: the app parses only enough of the body to build
+ `MCPCallContext.method` for the DTO, then hands the raw body to `MockMCPAdapter.relay`,
+ which does the real parsing. `GET`/`DELETE` handlers are explicit rather than relying on
+ Starlette's automatic 405-on-path-match-wrong-method behavior, so the 405 is asserted by an
+ actual handler rather than a framework default a future refactor could silently change.
+
+## Phase 6 — Compose wiring
+
+- [x] `api/oss/src/utils/env.py`: add `MockGatewaysConfig` (`llm_url`, `mcp_url`, defaults
+ pointing at the compose service names) and register it on `EnvironSettings`, following
+ `ComposioConfig`'s shape exactly (lines 685–704).
+- [x] `hosting/docker-compose/oss/docker-compose.dev.yml`: add `mock-llm-gateway` and
+ `mock-mcp-gateway` services, reusing `agenta-oss-dev-api:latest` with an overridden
+ `command`, always-on (no profile gate), healthchecks on `/health`.
+- [x] `hosting/docker-compose/ee/docker-compose.dev.yml`: same two services (not a license-gated
+ feature).
+- [x] Verify the healthcheck config against the existing profile-gated service blocks'
+ indentation and section-comment style (`# === ACTIVATION`, `# === IMAGE`, etc.) so the new
+ blocks read like the rest of the file.
+- [x] Ruff format + check (no Python touched here, but re-run to confirm the phase-4/5 files
+ still pass after any last edit); fix.
+- [x] Commit: "wp5: wire mocks into the local compose stack".
+
+ Judgment calls: (1) `EnvironSettings.mock_gateways` is the attribute name — the file's own
+ convention is strict alphabetical ordering by attribute name (not "next to composio" as the
+ spec's prose suggested; that prose predates several intervening alphabetical insertions), so
+ it landed between `docker` and `identity`, not next to `composio`. (2) The EE compose block
+ uses `agenta-ee-dev-api:latest`, not the `agenta-oss-dev-api:latest` the spec's snippet shows
+ literally in both places — EE's own `.api` anchor builds that image, and it already carries
+ `api/oss/src` mounted the same way (`api/ee/docker/Dockerfile.dev` copies both `api/ee` and
+ `api/oss`), so the mock apps import identically from either image. (3) Compose service
+ placement: inserted as an always-on block immediately before the profile-gated `composio`
+ block in both files (after `supertokens` in OSS, after `stripe` in EE) — grouped with the
+ other unconditional infrastructure rather than alphabetically, matching the file's existing
+ service-ordering convention (app layer, then infra, then satellite processes).
+
+## Phase 7 — `routers.py` diff and acceptance verification
+
+- [x] `api/entrypoints/routers.py` is owned by nobody (cross-package operating rule: every WP5–
+ WP9 worktree lands here, so no single package edits it directly to avoid five worktrees
+ fighting over one file). **Not edited.** The two import lines are recorded below as the
+ diff for whoever performs the IM1 merge to apply, alongside WP7's/WP9's own registry-dict
+ edits in the same wiring block:
+
+ ```diff
+ +from oss.src.core.gateways.llms.providers.mock.adapter import MockLLMAdapter
+ +from oss.src.core.gateways.mcps.providers.mock.adapter import MockMCPAdapter
+ ```
+
+ Landing spot: alongside the other gateway-adapter imports at the block currently reading
+ (as of this branch):
+
+ ```python
+ # GATEWAYS: core/gateways/ (entities.md). DAOs, services and routers land with
+ # their owning work packages (WP1 dbs; WP6/WP7 llms; WP8/WP9 mcps).
+ # from oss.src.dbs.postgres.gateways.llms.dao import LLMEndpointsDAO
+ # from oss.src.dbs.postgres.gateways.mcps.dao import MCPEndpointsDAO
+ # from oss.src.core.gateways.policy.resolution import SecretsResolver
+ # from oss.src.core.gateways.policy.service import GatewayPolicyService
+ # from oss.src.core.gateways.llms.service import LLMGatewayService
+ # from oss.src.core.gateways.mcps.service import MCPGatewayService
+ # from oss.src.apis.fastapi.gateways.llms.router import LLMGatewayRouter # WP10
+ # from oss.src.apis.fastapi.gateways.llms.proxy import LLMGatewayProxy # WP6
+ # from oss.src.apis.fastapi.gateways.mcps.router import MCPGatewayRouter # WP10
+ ```
+
+ The two new `MockLLMAdapter`/`MockMCPAdapter` import lines are additive to this comment
+ block (uncommented, live imports), not a replacement of it — the rest stays commented
+ until its owning package lands.
+- [x] Acceptance verification: needs the compose stack up, so **written, not run** here
+ (api/AGENTS.md test-layer rule — a check that needs the stack running is
+ integration/acceptance, not unit). `oss/tests/pytest/integration/gateways/test_mock_upstreams.py`
+ covers every item below; it addresses both mocks by compose service name, so it runs
+ inside the network (neither mock is published to the host):
+ - [x] Deploy the local stack and confirm both new services report healthy —
+ `test_both_healthchecks_answer`.
+ - [x] `mock-llm-gateway:9091/health` and `mock-mcp-gateway:9092/health` from inside
+ the compose network; both 200 — same test.
+ - [x] Forced failure end to end: `POST mock-llm-gateway:9091/v1/chat/completions` with
+ `"model": "mock/error"` returns 500; `POST mock-mcp-gateway:9092/` with a
+ `tools/call` body naming `fail` returns a JSON-RPC result with `isError: true` —
+ `test_error_model_returns_500` and `test_failing_tool_returns_is_error_at_http_200`.
+ - [x] Slow path with a short client timeout, confirming the connection is genuinely cut,
+ not just an in-process `await` — `test_slow_model_hangs_past_a_short_client_timeout`
+ (`httpx` timeout, asserts `httpx.TimeoutException`).
+ - [x] Bonus, not in the original checklist but in specs-wp5.md's acceptance list:
+ streaming produces multiple real SSE frames ending `data: [DONE]`, and MCP
+ `GET`/`DELETE` both 405 — `test_echo_model_streams_sse_frames_ending_done` and
+ `test_tools_list_returns_three_tools_and_get_delete_are_405`.
+- [x] Ruff format + check; fix. (No Python touched in this phase — `routers.py` was not
+ edited; re-ran to confirm the tree is still clean.)
+- [x] Commit: "wp5: acceptance verification tests + routers.py diff recorded (not applied)".
+
+## Definition of done
+
+Matches `plan.md` WP5 verbatim: *"both mocks run in the local stack and can be driven to fail on
+demand."* Concretely: `MockLLMAdapter`/`MockMCPAdapter` pass their unit and contract tests with
+nothing running; `mock-llm-gateway`/`mock-mcp-gateway` are healthy in the local docker-compose
+stack; each can be made to return a 500 (LLM) / `isError: true` (MCP) and to hang past a short
+client timeout, on demand, from outside the process (a real HTTP client, not a mocked one).
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp6.md b/docs/design/gateways-research/v1/workstreams/tasks-wp6.md
new file mode 100644
index 0000000000..4bb1704e57
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp6.md
@@ -0,0 +1,260 @@
+# WP6 tasks — LLM ingress and relay
+
+Ordered so each item is one reviewable commit. Depends on merge **IM1** (WP1 + WP2 + WP3 landed on
+the base branch) — branch from IM1, not from the seed commit directly, so `LLMGatewayService`'s
+constructor and `SecretsResolverInterface` are real rather than raising
+`NotImplementedError`. Run `ruff format` then `ruff check --fix` (from the repo root) before every
+commit and fix all errors, per `api/AGENTS.md`.
+
+## Phase 1 — `parse_llm_call_context`
+
+- [x] `apis/fastapi/gateways/llms/utils.py`: implement `parse_llm_call_context(*, body: bytes) ->
+ LLMCallContext` — `json.loads(body)`, extract `model`/`stream`, raise `ValueError` when
+ `model` is absent. No other parsing, no re-serialization.
+- [x] Ruff format + check; run and fix.
+- [x] Unit tests: representative bodies (streaming, non-streaming, missing model) — the missing-
+ model case asserts `ValueError`, not a swallowed default. Also: malformed JSON (asserts
+ `ValueError`, free — `json.JSONDecodeError` subclasses it) and a bytes-identity check that
+ parsing does not mutate or re-wrap the input.
+- [x] Commit: "wp6: parse_llm_call_context" (b4b91acb98).
+
+## Phase 2 — `PassthroughLLMAdapter`
+
+- [x] `core/gateways/llms/providers/passthrough/__init__.py`.
+- [x] `core/gateways/llms/providers/passthrough/adapter.py`: `PassthroughLLMAdapter(
+ LLMUpstreamInterface)`, implementing `relay_chat_completion` per `entities.md` §7.1's exact
+ signature.
+- [x] Build the outbound URL: `route.base_url` + `/chat/completions`, merging `route.headers`
+ (non-secret routing headers) into the outbound header set.
+- [x] Dispatch secret injection on `secret.secret.kind`: `SecretKind.PROVIDER_KEY` →
+ `Authorization: Bearer {secret.secret.data.provider.key}`
+ (`core/secrets/dtos.py::StandardProviderDTO`); `SecretKind.CUSTOM_PROVIDER` → the same header
+ from `secret.secret.data.provider.key`, with `provider.extras` merged into outbound
+ headers only (never the body) (`CustomProviderDTO`). `secret=None` sends no
+ `Authorization` header. (The checklist named the dispatch `StandardProviderKind` /
+ `CustomProviderKind` — those are the inner *provider-family* enums, e.g. `openai`; the
+ outer dispatch that actually selects which `SecretDTO` union member is present is
+ `SecretResponseDTO.kind: SecretKind`, so the code branches on `SecretKind.PROVIDER_KEY` /
+ `SecretKind.CUSTOM_PROVIDER` — same two cases the checklist meant, precise names.)
+- [x] Enforce a per-call timeout from `route.config.timeout_seconds`, falling back to this
+ package's own default constant (documented inline) when `None`. On timeout, raise
+ `LLMUpstreamError(provider_key=route.provider_key, status_code=None, detail="upstream timed
+ out")`.
+- [x] Relay `body` untouched — no `json.loads`/`json.dumps` round trip anywhere in this method;
+ forward the exact `bytes` object. (The upstream *response* body is separately parsed once,
+ read-only, to lift `usage` for the audit record — the bytes handed back to the caller are
+ never reconstructed from that parse; see the `usage` bullet below.)
+- [x] On any non-timeout transport failure, raise `LLMUpstreamError` carrying the upstream's own
+ status code when one was received. Extended one step further than the checklist's literal
+ wording: a *received* 5xx response (not just a transport-level failure) also raises
+ `LLMUpstreamError` carrying that status code — matching the Tests bullet below ("a
+ non-timeout 5xx raises `LLMUpstreamError`") and the 424/502 split
+ `apis/fastapi/gateways/exceptions.py` already encodes. 2xx/3xx/4xx respond as an ordinary
+ `LLMRelayResult`, passed through untouched (the upstream's own client-error body is not our
+ failure to report).
+- [x] Populate `LLMRelayResult.usage` from the upstream's own usage field when present in a
+ non-streaming response body, else leave `None` (never guess). Streaming responses always
+ leave `usage=None` here too — the trailing SSE usage frame is not reassembled — per §7.1's
+ "usage is populated ... once `body` is exhausted", which the streaming leg does not do.
+- [x] Ruff format + check; run and fix.
+- [x] Unit tests against `httpx.MockTransport` (no real socket): auth header injection for both
+ secret kinds and for `secret=None`; outbound URL construction; timeout raises
+ `LLMUpstreamError`; a non-timeout 5xx raises `LLMUpstreamError` carrying that status code;
+ the request body bytes reaching the transport are identical (`==`) to the input `body`. Also:
+ inbound `Authorization` is not forwarded; a connection failure (not just a timeout) also
+ raises `LLMUpstreamError`; a 4xx passes through untouched as a normal result; route headers
+ merge into the outbound set; a missing `route.base_url` raises `LLMUpstreamError` rather than
+ crashing on `None + str`; the configured timeout (and the default, when unset) reach the
+ built `httpx.Request`'s extensions. 16 tests, all passing.
+- [x] Commit: "wp6: PassthroughLLMAdapter" (94eab6baa8).
+
+## Phase 3 — Contract test extension
+
+- [x] Extend WP5's `test_mock_adapters_contract.py` fixture to include `PassthroughLLMAdapter`
+ against `httpx.MockTransport`, asserting `relay_chat_completion` returns `LLMRelayResult` for
+ every case exercised in Phase 2. The adapter needs real network I/O, so — unlike the file's
+ generic `_optional_instance()` no-arg construction — it is built through a dedicated
+ `_passthrough_llm_adapter()` helper that wires an `httpx.MockTransport`-backed client;
+ `test_relay_chat_completion_returns_llm_relay_result`'s shared `route` fixture gained a
+ `base_url` (inert for `MockLLMAdapter`, required for `PassthroughLLMAdapter`'s URL builder).
+- [x] Ruff format + check; run and fix.
+- [x] Commit: "wp6: passthrough adapter joins the south-port contract suite" (6760fc5949).
+
+## Phase 4 — `LLMGatewayProxy`
+
+- [x] `apis/fastapi/gateways/llms/proxy.py`: `LLMGatewayProxy.__init__(self, *,
+ llm_gateway_service: LLMGatewayService)`, four routes exactly as `entities.md` §9
+ (`llm_gateway_chat_completions_builtin`, `..._custom`, `llm_gateway_list_models_builtin`,
+ `..._custom`), no wire models. `LLMGatewayService` itself is WP7's and does not exist on this
+ branch yet, so the parameter is typed against a `TYPE_CHECKING`-only import — real at type-
+ check time once WP7 lands, harmless (never executed) until then.
+- [x] Implement `chat_completions_builtin`/`chat_completions_custom`: `get_auth_scope()`, read
+ `await request.body()`, strip inbound authorization headers, call
+ `self.service.relay_chat_completion(scope=..., namespace=..., name=..., body=..., headers=...)`.
+- [x] Non-streaming path: `chunk = await anext(result.body)`, return a plain `Response` with
+ `result.status_code`/`result.headers`.
+- [x] Streaming path (`context.stream` from `parse_llm_call_context`): `StreamingResponse(
+ result.body, status_code=result.status_code, headers=result.headers,
+ media_type="text/event-stream")`. No local `try/finally` calling into policy —
+ `LLMGatewayProxy` holds only `llm_gateway_service`, confirmed by its constructor signature.
+- [x] Implement the OpenAI-shaped error envelope (`{"error": {"message", "type", "code"}}`) for
+ the domain exceptions already declared in `core/gateways/llms/types.py` and
+ `core/gateways/policy/types.py` (seed-owned, real today): `PolicyDeniedError` /
+ `EntitlementDeniedError` → 403 `policy_denied`; `LLMModelNotAllowedError` → 403
+ `model_not_allowed`; `CeilingExceededError` → 400 `ceiling_exceeded` (body names the
+ ceiling, requested, allowed per D25); `SecretNotFoundError` /
+ `LLMEndpointNotFoundError` → 404 `secret_missing` / `endpoint_not_found`;
+ `LLMUpstreamError` → 424, or 502 when `status_code >= 500`. Built as this file's own mapping
+ function (`_map_domain_exception`), NOT via the seed's `handle_gateway_exceptions()` —
+ that decorator collapses causes sharing one HTTP status into a bare `detail` string (e.g.
+ `LLMEndpointNotFoundError` and `SecretNotFoundError` both land on 404), which cannot
+ reproduce the two distinct `code` values this surface's contract requires. Judgment call;
+ flagged in the package report. Also added: `ValueError` from `parse_llm_call_context`
+ (missing/invalid model) → 400 `invalid_request` (the utils.py docstring's "surface's own
+ invalid-request error shape", not itself one of the four named codes but required to keep
+ that promise); and a response-header filter (`_response_headers`) stripping
+ `content-length`/`content-encoding`/`transfer-encoding`/`connection`/`keep-alive` from the
+ upstream's headers before they reach our own `Response`/`StreamingResponse` — ASGI computes
+ its own framing headers, and forwarding the upstream's stale `content-length` verbatim (wrong
+ once httpx has decoded the body) would corrupt the response. Not in the original checklist;
+ added because it is required for HTTP correctness, flagged in the package report.
+- [x] `list_models_builtin`/`list_models_custom`: **unblocked (R3)** — call
+ `self.service.list_models(scope=..., namespace=..., name=...)`, which returns
+ `List[str]`, and shape the OpenAI list body inline (`{"object": "list", "data":
+ [{"id": s, "object": "model"} for s in slugs]}`). No wire model — the data plane
+ has none (§6). WP7 owns the method; code against its declaration.
+- [x] Ruff format + check; run and fix.
+- [x] Unit tests: `chat_completions_custom`/`_builtin` and `list_models_*` against a hand-written
+ mock `LLMGatewayService` (not WP5's fixture — this is testing the proxy in isolation), driven
+ through a `starlette.requests.Request` built from a raw ASGI scope (no HTTP server): every
+ documented domain exception maps to its status code and `code` string (parametrized, 9
+ cases incl. both 5xx→502/4xx-and-None→424 `LLMUpstreamError` splits); a successful
+ non-streaming call returns the single chunk verbatim; a successful streaming call passes
+ `result.body` through `StreamingResponse` untouched; namespace/name routing per route;
+ inbound `Authorization` never reaches the service; the missing-model `ValueError` path never
+ calls the service at all; the response-header filter drops a stale upstream `content-length`.
+ 20 tests, all passing.
+- [x] Commit: "wp6: LLMGatewayProxy" (0b507d58e9).
+
+## Phase 5 — Wiring
+
+**Not applied by this package.** The orchestrating brief for this worktree overrides this
+phase's original instruction to edit the file directly: *"`api/entrypoints/routers.py` is
+owned by nobody... Write your additions as a diff inside `tasks-wp6.md`; do NOT edit that
+file."* That instruction is also the only workable one here — `llm_gateway_service` does
+not exist as a variable in `routers.py` yet (WP7's `core/gateways/llms/service.py` is not
+on this branch), so `LLMGatewayProxy(llm_gateway_service=llm_gateway_service)` cannot
+actually be constructed today. The diff below is this package's contribution to the IM2
+merge, to be applied once WP7's service lands (by WP7, or whoever resolves the merge) —
+not a commit made in this worktree.
+
+- [x] Diff drafted and recorded below (this file). Not applied to `api/entrypoints/routers.py`.
+- [x] Nothing to ruff/commit for this phase — no source file changed.
+
+```diff
+--- a/api/entrypoints/routers.py
++++ b/api/entrypoints/routers.py
+@@ -166,7 +166,8 @@
+ # GATEWAYS: core/gateways/ (entities.md). The planes' services and routers land with
+ # their owning work packages (WP6/WP7 llms; WP8/WP9 mcps; WP10 CRUD).
+ from oss.src.dbs.postgres.gateways.llms.dao import LLMEndpointsDAO
+ from oss.src.dbs.postgres.gateways.mcps.dao import MCPEndpointsDAO
+ from oss.src.core.gateways.policy.resolution import SecretsResolver
+ from oss.src.core.gateways.policy.service import GatewayPolicyService
++from oss.src.core.gateways.llms.providers.passthrough.adapter import PassthroughLLMAdapter
+
+ # The mock adapters (WP5) are registered into the plane registries, which WP7 and WP9
+ # own and which do not exist yet — so their imports land with those, not here.
+ # from oss.src.core.gateways.llms.providers.mock.adapter import MockLLMAdapter
+ # from oss.src.core.gateways.mcps.providers.mock.adapter import MockMCPAdapter
+-# from oss.src.core.gateways.llms.service import LLMGatewayService
++from oss.src.core.gateways.llms.service import LLMGatewayService # WP7
+ # from oss.src.core.gateways.mcps.service import MCPGatewayService
+ # from oss.src.apis.fastapi.gateways.llms.router import LLMGatewayRouter # WP10
+-# from oss.src.apis.fastapi.gateways.llms.proxy import LLMGatewayProxy # WP6
++from oss.src.apis.fastapi.gateways.llms.proxy import LLMGatewayProxy # WP6
+ # from oss.src.apis.fastapi.gateways.mcps.router import MCPGatewayRouter # WP10
+ # from oss.src.apis.fastapi.gateways.mcps.proxy import MCPGatewayProxy # WP8
+@@ -1085,6 +1087,14 @@
+ gateway_policy_service = GatewayPolicyService(resolver=secret_resolver)
+
++# WP7's construction line (shown for context — not this package's edit):
++# llm_gateway_service = LLMGatewayService(
++# endpoints_dao=llm_endpoints_dao,
++# resolver=secret_resolver,
++# policy=gateway_policy_service,
++# upstream_registry=LLMUpstreamRegistry(adapters={"passthrough": PassthroughLLMAdapter(), ...}),
++# )
++llm_gateway_proxy = LLMGatewayProxy(llm_gateway_service=llm_gateway_service)
++
+ simple_traces = SimpleTracesRouter(
+ simple_traces_service=simple_traces_service,
+ )
+@@ -1514,7 +1524,7 @@
+ # GATEWAYS: nothing mounted yet — each line lands with its owning package
+ # (entities.md §9 "Wiring"). Two router OBJECTS per plane, not one with two
+ # attributes: management CRUD and the data plane are separate (§1).
+ # app.include_router(router=llm_gateway.router, prefix="/gateways/llms", tags=["Gateway: LLM"])
+-# app.include_router(router=llm_gateway.proxy, prefix="/gateways/llms", include_in_schema=False)
++app.include_router(router=llm_gateway_proxy.router, prefix="/gateways/llms", include_in_schema=False)
+ # app.include_router(router=mcp_gateway.router, prefix="/gateways/mcps", tags=["Gateway: MCP"])
+ # app.include_router(router=mcp_gateway.proxy, prefix="/gateways/mcps", include_in_schema=False)
+```
+
+Notes for whoever applies this at the merge:
+- The `upstream_registry=LLMUpstreamRegistry(adapters={"passthrough": PassthroughLLMAdapter(), ...})`
+ entry is WP7's edit inside its own `LLMGatewayService(...)` construction call, shown above only
+ as context (commented) — WP6 contributes the `PassthroughLLMAdapter` import and the registry
+ *value*, not the line that constructs the registry or the service.
+- `llm_gateway_proxy.router` replaces the placeholder `llm_gateway.proxy` name from the original
+ comment — `entities.md` §9's own snippet names the combined object `llm_gateway` with `.router`/
+ `.proxy` attributes; this package's actual classes are two separate objects
+ (`LLMGatewayRouter`/`LLMGatewayProxy`, per the "two router OBJECTS per plane, not one with two
+ attributes" comment already in the file), so the mount line uses `llm_gateway_proxy.router`.
+ WP10's `LLMGatewayRouter` mount is a separate, WP10-owned line, not part of this diff.
+
+## Phase 6 — Acceptance (post-IM2, once WP5/WP7 are merged)
+
+Per this worktree's own task brief, rule 5: acceptance tests need a running deployment, which
+this worktree does not have — write them, do not run them. `oss/tests/pytest/acceptance/gateways/
+test_llm_gateway_proxy_acceptance.py` written accordingly (collection verified locally; execution
+needs the full IM2 deployment WP7/WP10 complete):
+
+- [x] Deploy the local stack with WP1/WP2/WP3/WP5/WP7 all merged — documented in the test
+ module's docstring as the manual run instructions; not performed by this package.
+- [x] Seed a custom LLM endpoint pointing at `mock-llm-gateway`'s URL — `mock_llm_endpoint`
+ fixture (class-scoped), POSTing `LLMEndpointCreateRequest`'s wire shape (§6) at
+ `POST /gateways/llms/endpoints/` (WP10's route, not yet built either — written against its
+ declared shape).
+- [x] Streamed request round-trips byte for byte (diff the SSE bytes, not a re-decoded
+ equivalence) — `test_streaming_round_trips_sse_bytes_unmodified`, asserting on
+ `response.content` directly (the raw bytes), not a JSON-decoded reconstruction.
+- [x] `mock/slow-30` with a short `config.timeout_seconds` returns before 30s elapse —
+ `test_slow_upstream_times_out_inside_the_configured_window_not_at_30s`, timed with
+ `time.monotonic()`, asserting `elapsed < 30` and an `upstream_error` response rather than a
+ hang.
+- [x] An unauthenticated request never reaches the mock — `test_unauthenticated_request_never_reaches_the_mock`,
+ asserting the auth middleware's 401 (D13: rejected before any router runs); this suite has
+ no direct handle on the mock's own request log, so it asserts the platform boundary instead,
+ noted inline as the precision this test can actually offer.
+- [x] A model outside the allowlist is refused with `model_not_allowed` before any secret is
+ resolved — `test_model_outside_allowlist_is_refused_with_model_not_allowed`. (Secret-
+ resolution-order is WP7's `relay_chat_completion` body, §8 — not independently observable
+ from this HTTP-only suite; the test asserts the outcome the ordering guarantees.)
+- [x] Extra, beyond the checklist: `test_non_streaming_call_returns_the_mocks_completion_body`
+ (the non-streaming leg) and `test_list_models_answers_the_endpoints_allowlist` (`GET
+ .../v1/models`, R3) — both named in specs-wp6.md's contract but not called out as separate
+ Phase 6 bullets.
+- [x] Ruff format + check; fix.
+- [x] Commit: "wp6: write acceptance tests against the mock (not run)".
+
+## Definition of done
+
+Matches `plan.md` WP6 verbatim: *"a streamed response is relayed unmodified and a hung upstream
+times out rather than hanging the gateway."* Concretely: `PassthroughLLMAdapter`'s unit and
+contract tests pass with nothing running (16 + 1 tests); `LLMGatewayProxy`'s unit tests pass
+against a stubbed service (20 tests); and, once WP5/WP7/WP10 are merged and deployed, the written
+(not yet run) acceptance suite exercises a real streamed SSE response from the mock reaching a
+client byte-identical to what the mock sent, and a `mock/slow-N` request beyond the endpoint's
+configured timeout returning an error inside that timeout window rather than hanging the gateway
+process.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp7.md b/docs/design/gateways-research/v1/workstreams/tasks-wp7.md
new file mode 100644
index 0000000000..591f354374
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp7.md
@@ -0,0 +1,320 @@
+# WP7 tasks — LLM routing and model allowlist
+
+Ordered so each item is one reviewable commit. Depends on merge **IM1** — branch from IM1, not the
+seed commit directly. Run `ruff format` then `ruff check --fix` (from the repo root) before every
+commit and fix all errors, per `api/AGENTS.md`.
+
+## Phase 1 — `catalog.py`
+
+- [x] `core/gateways/llms/catalog.py`: implement `standard_llm_endpoint(*, provider_key: str) ->
+ Optional[LLMEndpoint]` reading `sdks/python/agenta/sdk/utils/assets.py::supported_llm_models`
+ — `namespace=BUILTIN`, `slug=provider_key`, `deployment=DIRECT`, `data.models.allowlist` from
+ the map, `data.config` at code defaults, no `id`, no `Lifecycle`. Return `None` for a
+ provider absent from the map.
+- [x] Implement `standard_llm_endpoints() -> List[LLMEndpoint]`: all eleven, calling
+ `standard_llm_endpoint` per key in `supported_llm_models`, unfiltered by existence.
+- [x] Ruff format + check; run and fix.
+- [x] Unit tests: `standard_llm_endpoint("openai")` matches the catalogue's `openai` model list
+ exactly; the three `StandardProviderKind` members with no catalogue entry (`anyscale`,
+ `alephalpha`, `mistralai`) and an arbitrary unknown string all return `None`;
+ `standard_llm_endpoints()` returns exactly eleven entries.
+- [x] Commit: "wp7: catalog.py — generated standard endpoints".
+
+## Phase 2 — `registry.py`
+
+- [x] `core/gateways/llms/registry.py`: `LLMUpstreamRegistry.__init__(self, *, adapters:
+ Dict[str, LLMUpstreamInterface])`, `.get(key) -> LLMUpstreamInterface` (raises on a miss —
+ define and raise a typed exception, following `ProviderNotFoundError`'s shape from
+ `core/gateway/connections/exceptions.py`, but in this domain's own `types.py` — do not
+ import the integrations domain's exception), `.keys() -> list[str]`.
+- [x] Implement `select_upstream(provider_key: str, deployment: LLMDeploymentKind) -> str` per
+ the classification table in `specs-wp7.md`: `AZURE`/`BEDROCK`/`SAGEMAKER`/`VERTEX` →
+ `"translated"`; `CUSTOM` → `"passthrough"`; `DIRECT` split by the six-vs-six provider table.
+ Pure function — no DAO, no vault, no I/O.
+- [x] Ruff format + check; run and fix.
+- [x] Unit tests: every `(provider, deployment)` pair in the classification table returns the
+ documented key; `.get` on an unregistered key raises; `select_upstream` never imports
+ anything beyond `LLMDeploymentKind` (grep-check: no `httpx`/`litellm`/DAO import in
+ `registry.py`).
+- [x] Commit: "wp7: registry.py — LLMUpstreamRegistry + select_upstream".
+
+**Deviation, disclosed:** `select_upstream` also special-cases `provider_key == "mock"` →
+`"mock"`, ahead of the deployment split. Neither `specs-wp7.md`'s table nor this phase's own
+bullets state that branch; it was added mid-implementation after the coordinator flagged that
+WP5's `MockLLMAdapter` import is WP7's to uncomment and register at the composition root (see the
+Phase 7 diff below) — without it, nothing in the documented `select_upstream` table ever routes to
+the `"mock"` registry key, which would make the mocks unreachable through the relay path in every
+environment, not just contradict "registered always; reachable only via a seeded endpoint" from
+`entities.md` §9's wiring comment. `core/gateways/llms/types.py` also gained one new exception,
+`LLMAdapterNotFoundError` (`registry.get`'s miss) — additive only, but it is a `types.py` edit,
+which rule 1 of the top-level brief lists as off-limits; this phase's own bullet explicitly
+directs adding it there ("but in this domain's own `types.py`"), so the more specific instruction
+was followed and the tension is flagged here rather than resolved silently.
+
+## Phase 3 — `TranslatedLLMAdapter`
+
+- [x] `core/gateways/llms/providers/translated/__init__.py`.
+- [x] `core/gateways/llms/providers/translated/adapter.py`: `TranslatedLLMAdapter(
+ LLMUpstreamInterface)`, implementing `relay_chat_completion` per the exact interface
+ signature (same as WP6's `PassthroughLLMAdapter`).
+- [x] Model-string prefixing per `route.deployment`: `"azure/{model}"`, `"bedrock/{model}"`,
+ `"sagemaker/{model}"`, `"vertex_ai/{model}"`; `DIRECT` non-OpenAI-shaped providers use
+ `route.model` as-is (already prefixed by the catalogue).
+- [x] Secret kwargs: dispatch on `secret.secret.kind`
+ (`StandardProviderKind`/`CustomProviderKind`), mirroring
+ `sdks/python/agenta/sdk/managers/secrets.py::get_provider_settings` STEP 4 exactly — merge
+ `CustomProviderDTO.provider.extras` into the litellm kwargs dict; pull `api_version` from
+ `route.api_version` (Azure) and `region` from `route.region` (Bedrock/Vertex), never from
+ the secret.
+- [x] Call `litellm.acompletion(model=..., **kwargs, stream=context.stream)`; wrap the response
+ (or async stream) into an `AsyncIterator[bytes]` of OpenAI-shaped SSE-framed chunks for
+ `LLMRelayResult.body`.
+- [x] Populate `GatewayUsage` from `response.usage` and a cost figure (`litellm.cost_calculator`
+ or the response's own hidden cost param) — never leave `cost=None` on a successful call.
+- [x] Catch litellm's exceptions and re-raise `LLMUpstreamError(provider_key=...,
+ status_code=, detail=str(exc))`.
+- [x] Ruff format + check; run and fix.
+- [x] Unit tests, `litellm.acompletion` monkeypatched (no real network): `StandardProviderDTO`
+ secret passes `api_key`; `CustomProviderDTO` secret merges `extras`; `AZURE` prefix
+ + `api_version` passed; `BEDROCK`/`VERTEX` pass `region`; a raised exception from the mock
+ becomes `LLMUpstreamError`; usage/cost populated on a successful mocked response.
+- [x] Commit: "wp7: TranslatedLLMAdapter".
+
+**Judgment calls:** litellm kwarg names for region are not specified anywhere in the design set —
+used `aws_region_name` (Bedrock) and `vertex_location` (Vertex), litellm's own parameter names.
+Streaming usage is requested via `stream_options={"include_usage": True}` (not mentioned in the
+spec) because without it litellm reports no usage at all on a streamed call, which would leave
+`GatewayUsage` permanently empty for every streaming call through this adapter — the interface
+docstring's "the translated adapter reports the library's count" only holds with this kwarg set.
+
+## Phase 4 — Contract test extension
+
+- [x] Extend WP5's `test_mock_adapters_contract.py` fixture to include `TranslatedLLMAdapter`
+ (litellm mocked), asserting `relay_chat_completion` returns `LLMRelayResult`.
+- [x] Ruff format + check; run and fix.
+- [x] Commit: "wp7: translated adapter joins the south-port contract suite".
+
+**Note:** the fixture already parametrized `TranslatedLLMAdapter` in via `_optional_instance`
+(WP5 wrote it that way so this package's landing needs no edit to the parametrize list itself).
+The only gap was that the shared `test_relay_chat_completion_returns_llm_relay_result` body calls
+`relay_chat_completion` unconditionally for every adapter in the list, and once
+`providers/translated/adapter.py` existed that meant a real `litellm.acompletion` call with
+`secret=None` unless mocked — added one `autouse` fixture that monkeypatches
+`litellm.acompletion` at `translated.adapter`'s own import site (a no-op for every other adapter
+since none of them import litellm).
+
+## Phase 5 — `LLMGatewayService` management surface
+
+- [x] `core/gateways/llms/service.py`: `LLMGatewayService.__init__(self, *, llm_endpoints_dao,
+ policy, resolver, upstream_registry)` — **exactly this, unchanged**. R2 settled the
+ vault-access gap by adding `available_provider_keys` to the resolver port instead of a
+ dependency here; do not add `vault_service`.
+- [x] Implement `create_endpoint`, `fetch_endpoint`, `edit_endpoint`, `delete_endpoint`,
+ `query_endpoints` as thin delegations to `llm_endpoints_dao`.
+- [x] Implement `list_endpoints`: intersect `standard_llm_endpoints()` with
+ `await self.resolver.available_provider_keys(scope=scope)` (WP2 implements it; it returns
+ names only and never raises for an empty project), plus
+ `query_endpoints(project_id=project_id)`'s full result — no duplicates, no `builtin` entry
+ for a provider with no key.
+- [x] Ruff format + check; run and fix.
+- [x] Unit tests: `list_endpoints` with a stubbed resolver whose `available_provider_keys`
+ returns two provider keys yields exactly those two `builtin` entries plus every custom
+ row; an empty set yields custom rows only, with no exception; `create_endpoint`/
+ `fetch_endpoint`/`edit_endpoint`/`delete_endpoint`/`query_endpoints` each delegate to the
+ stubbed DAO with the arguments unchanged.
+- [x] The resolver test double in this package's tests must implement **both** port methods —
+ `resolve` and `available_provider_keys`.
+- [x] Commit: "wp7: LLMGatewayService management surface".
+
+**Judgment call — `list_endpoints`'s missing scope.** `available_provider_keys(*, scope:
+AuthScope)` needs a full `AuthScope` (org/workspace/project/user, all required, frozen), but
+`list_endpoints(self, *, project_id)`'s signature — fixed verbatim by both `entities.md` §8 and
+this spec — carries only `project_id`. `policy/resolution.py`'s implementation reads only
+`scope.project_id`, so `list_endpoints` builds a placeholder `AuthScope` with a nil UUID
+(`UUID(int=0)`) for `organization_id`/`workspace_id`/`user_id`, documented inline. This is a real
+gap in both design documents, not invented behavior; flagging it rather than silently widening the
+signature to add `scope`, which the checklist's own "exactly this, unchanged" line forbids.
+
+## Phase 5b — `list_models` (R3)
+
+- [x] Implement `async def list_models(self, *, scope, namespace, name) -> List[str]`:
+ `_resolve_target` as the relay does, `policy.authorize` with
+ `Permission.USE_LLM_ENDPOINTS`, then return the target's allowlist — the static
+ catalogue's for `builtin`, the row's for `custom`.
+- [x] Resolve no secret and call no upstream. It answers from the allowlist, so a harness
+ that lists before calling sees exactly what policy will allow.
+- [x] Return `List[str]`; invent no response DTO — WP6's proxy shapes the OpenAI body inline
+ because the data plane has no wire models (§6).
+- [x] Unit tests: a `custom` endpoint with `models.allowlist: ["a", "b"]` returns exactly those; a
+ `builtin` provider returns the catalogue's slugs verbatim (litellm prefixes intact, not
+ re-derived); an unknown name raises `LLMEndpointNotFoundError`; a denied decision raises
+ `PolicyDeniedError` before any slug is read.
+- [x] Commit: "wp7: LLMGatewayService.list_models".
+
+## Phase 6 — `LLMGatewayService.relay_chat_completion`
+
+- [x] Implement `_resolve_target`: look up a row via `fetch_endpoint_by_slug` for `CUSTOM`, or
+ `catalog.standard_llm_endpoint` for `BUILTIN`; raise `LLMEndpointNotFoundError` when
+ neither answers.
+- [x] Implement the allowlist check (`_check_allowlist`): a `CUSTOM` target refuses a `model` not
+ in `data.models.allowlist` (including the explicit-empty-list-refuses case, D20); a `BUILTIN`
+ target refuses a `model` not in the catalogue's allowlist for that provider. Raise
+ `LLMModelNotAllowedError` before any secret lookup.
+- [x] Implement the ceiling check (`_check_ceilings`): compare the request's
+ `max_output_tokens` (if present in the body) against `target.config.max_output_tokens`;
+ raise `CeilingExceededError(ceiling="max_output_tokens", requested=..., allowed=...,
+ target=...)` on a breach — reject, never clamp (D25).
+- [x] Wire `self.policy.authorize(scope=..., permission=Permission.USE_LLM_ENDPOINTS,
+ target=...)`; on `not decision.allowed`, call `self.policy.record(...)` **then** raise
+ `PolicyDeniedError` — denial recorded before the exception leaves.
+- [x] Wire `self.resolver.resolve(scope=..., ref=target.secret_ref(),
+ mode=SecretMode.PROJECT_ONLY)`, skipped for `GatewayAuthScheme.NONE` targets (the
+ mocks).
+- [x] Wire adapter selection: `self.upstream_registry.get(select_upstream(target.provider_key,
+ target.deployment)).relay_chat_completion(...)`.
+- [x] Implement the streaming-aware audit wrapper: wrap a streaming `LLMRelayResult.body` in a
+ generator whose `finally` calls `self.policy.record(...)` with the usage read off the
+ exhausted adapter result; call `policy.record` directly (not wrapped) for a non-streaming
+ result.
+- [x] Ruff format + check; run and fix.
+- [x] Unit tests against stubbed DAO/policy/resolver/registry (no real adapters, no compose):
+ allowlist check runs and raises before `resolver.resolve` is called (assert the resolver
+ stub's call count is 0 on a rejected model); a policy denial calls `policy.record` exactly
+ once before the exception propagates; a ceiling breach names all three values; a
+ successful streaming call's `policy.record` fires only once the returned iterator is fully
+ consumed (assert via a spy with an interleaved partial read).
+- [x] Commit: "wp7: LLMGatewayService.relay_chat_completion".
+
+**Consolidation, disclosed.** Phases 5, 5b and 6 landed as **one commit** covering the whole of
+`service.py` plus one test file (`test_gateways_llm_service.py`) exercising all three surfaces,
+rather than three. The three phases build one class with shared private helpers
+(`_resolve_target`, the `_ResolvedLlmTarget` dataclass) that `list_models` and
+`relay_chat_completion` both depend on; writing genuine `NotImplementedError` stubs for two of the
+three phases and filling them in across two more commits would have meant re-touching the same
+file three times with no independent reviewable state in between (a partially-stubbed
+`service.py` is not runnable on its own). Each phase's checklist items above are still checked off
+individually so the mapping from item to code is traceable in one diff.
+
+**Two more judgment calls, in `relay_chat_completion`:**
+- `_check_ceilings` reads `body: bytes` directly (via a private `json.loads`), not `context:
+ LLMCallContext` as `entities.md`'s illustrative pseudocode signature shows — `LLMCallContext`
+ only carries `model`/`stream` (§4.3), so there is no way to read `max_output_tokens` off it. This
+ package's own task bullet above already says "compare the request's `max_output_tokens` (if
+ present in **the body**)", which only body access satisfies; treated as the more specific,
+ correct instruction over the pseudocode's compressed argument list.
+- The pseudocode's `context = parse_call_context(body)` line names WP6's
+ `apis/fastapi/gateways/llms/utils.py::parse_llm_call_context` — a file this package must not
+ write (WP6 owns it) and, more fundamentally, one `core/` must not import (`api/AGENTS.md`'s
+ layering rule: core does not import the api layer). `service.py` instead carries a private
+ `_parse_call_context`, doing the same two-field extraction, so WP6's proxy and this service each
+ own their own copy rather than one importing the other's file.
+
+## Phase 7 — Wiring
+
+- [x] `api/entrypoints/routers.py`: construct `llm_gateway_service = LLMGatewayService(...)` per
+ the diff in `specs-wp7.md`, with the `upstream_registry` dict entries for `"passthrough"`,
+ `"translated"`, `"mock"` — coordinate with WP5's and WP6's import lines landing in the same
+ block at the IM1→IM2 merge.
+- [x] If the litellm-as-direct-dependency question (flagged in "Missing from the design") is
+ resolved in favor of adding it: `api/pyproject.toml` gets the `litellm` line, matching the
+ SDK's own pin (`litellm>=1,<2`).
+- [x] Ruff format + check; run and fix.
+- [x] Commit: "wp7: wire LLMGatewayService into the entrypoint".
+
+**`api/pyproject.toml` — already done, no action needed (R9).** `litellm>=1.92,<2` is already a
+direct dependency on this branch (line 38) — someone resolved the "missing from the design"
+question before this package started. Confirmed importable (`litellm.acompletion` used directly
+by `providers/translated/adapter.py` since Phase 3).
+
+**`api/entrypoints/routers.py` — diff only, not applied here.** Per rule 6 of the top-level brief,
+this file is nobody's to edit directly mid-wave; the diff below is what should land at the
+IM1→IM2 merge, once WP6's `PassthroughLLMAdapter` exists on the integration branch (it does not
+exist on this worktree, so applying this diff here would break the import). Two things beyond
+`specs-wp7.md`'s own diff, both flagged by the coordinator mid-task: the `MockLLMAdapter` import
+uncomments (WP5 left it commented, deliberately, for whichever of WP7/WP9 builds the first plane
+registry — that is WP7 here), and it is registered under `"mock"`, the key `select_upstream`
+returns for `provider_key == "mock"` (see Phase 2's disclosed deviation above) — without both
+halves the mocks are unreachable through the relay path in every environment, including the local
+compose stack, since nothing in the documented classification table itself ever selects `"mock"`.
+
+```diff
+--- a/api/entrypoints/routers.py
++++ b/api/entrypoints/routers.py
+@@
+ from oss.src.dbs.postgres.gateways.llms.dao import LLMEndpointsDAO
+ from oss.src.dbs.postgres.gateways.mcps.dao import MCPEndpointsDAO
+ from oss.src.core.gateways.policy.resolution import SecretsResolver
+ from oss.src.core.gateways.policy.service import GatewayPolicyService
+
+-# The mock adapters (WP5) are registered into the plane registries, which WP7 and WP9
+-# own and which do not exist yet — so their imports land with those, not here.
+-# from oss.src.core.gateways.llms.providers.mock.adapter import MockLLMAdapter
++from oss.src.core.gateways.llms.providers.mock.adapter import MockLLMAdapter
++from oss.src.core.gateways.llms.providers.translated.adapter import TranslatedLLMAdapter
++from oss.src.core.gateways.llms.registry import LLMUpstreamRegistry
++from oss.src.core.gateways.llms.service import LLMGatewayService
++# from oss.src.core.gateways.llms.providers.passthrough.adapter import PassthroughLLMAdapter # WP6
+ # from oss.src.core.gateways.mcps.providers.mock.adapter import MockMCPAdapter
+-# from oss.src.core.gateways.llms.service import LLMGatewayService
+ # from oss.src.core.gateways.mcps.service import MCPGatewayService
+ # from oss.src.apis.fastapi.gateways.llms.router import LLMGatewayRouter # WP10
+ # from oss.src.apis.fastapi.gateways.llms.proxy import LLMGatewayProxy # WP6
+ # from oss.src.apis.fastapi.gateways.mcps.router import MCPGatewayRouter # WP10
+ # from oss.src.apis.fastapi.gateways.mcps.proxy import MCPGatewayProxy # WP8
+@@
+ gateway_policy_service = GatewayPolicyService(resolver=secret_resolver)
+
++llm_gateway_service = LLMGatewayService(
++ llm_endpoints_dao=llm_endpoints_dao,
++ policy=gateway_policy_service,
++ resolver=secret_resolver,
++ upstream_registry=LLMUpstreamRegistry(
++ adapters={
++ "passthrough": PassthroughLLMAdapter(), # WP6's import, added at that merge
++ "translated": TranslatedLLMAdapter(),
++ "mock": MockLLMAdapter(),
++ }
++ ),
++)
++
+ simple_traces = SimpleTracesRouter(
+ simple_traces_service=simple_traces_service,
+ )
+```
+
+The `# from ... import PassthroughLLMAdapter # WP6` line stays commented in this diff — WP6
+uncomments it (and drops the comment marker) at the same merge, per `specs-wp7.md`'s own note
+that "WP6 contributes the import and the proxy mount only." Until then this diff, applied alone,
+does not import-error: the construction block references `PassthroughLLMAdapter` by name, so it
+must land together with WP6's uncomment, not before — same ordering constraint the seed's own
+comment block already documented for `MockLLMAdapter`.
+
+## Phase 8 — Acceptance (post-IM2, once WP1/WP5/WP6 are merged)
+
+- [ ] Deploy the local stack with WP1/WP2/WP3/WP5/WP6/WP7 all merged. **Not done here** — this
+ worktree has no compose deployment and only WP1/WP2/WP3/WP5 are merged onto this branch
+ (no WP6); per the top-level brief's rule 5, integration/acceptance needing a deployment are
+ written, not run, by this package.
+- [ ] Seed a custom endpoint with a narrow `models.allowlist`; confirm a request for a model
+ outside it is refused `model_not_allowed` with no upstream call made (verifiable against
+ WP5's mock — the mock sees no inbound request at all). **Not run** — needs the deployment
+ above. The `curl` procedure in `specs-wp7.md`'s "Done test" section is the script to run
+ once WP6 is merged and the stack is up; nothing further to add here.
+- [x] Confirm every `DIRECT` provider in `supported_llm_models` maps to the documented adapter key
+ via `select_upstream` (a scripted check, not a real call — CI has no provider keys). **Run,
+ passing**: `test_every_catalogued_direct_provider_maps_to_the_documented_adapter_key` in
+ `test_gateways_llm_registry.py` — needs nothing running, so it landed as a real unit test
+ rather than a manual script. Checks a subset, not equality: `mistralai` is in the
+ classification table but not in `supported_llm_models` (see the test's own docstring).
+- [x] Ruff format + check; fix.
+- [x] Commit: "wp7: acceptance verification".
+
+## Definition of done
+
+Matches `plan.md` WP7 verbatim: *"every provider and deployment pair reachable today is reachable
+through the gateway, including the cloud-reseller shapes, and a model outside a custom endpoint's
+list is refused."* Concretely: `catalog.py`, `registry.py`, `TranslatedLLMAdapter` and
+`LLMGatewayService.relay_chat_completion` all pass their unit and contract tests with nothing
+running; `select_upstream` classifies every provider/deployment pair in the design's known set;
+and, once WP1/WP5/WP6 are available, a request outside a custom endpoint's allowlist is
+refused before any secret is resolved or any upstream call is attempted.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp8.md b/docs/design/gateways-research/v1/workstreams/tasks-wp8.md
new file mode 100644
index 0000000000..9452f9c945
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp8.md
@@ -0,0 +1,319 @@
+# WP8 tasks — MCP ingress and proxy
+
+Ordered so each item is one reviewable commit. Depends on the seed commit
+(`core/gateways/{dtos,types}.py`, `core/gateways/mcps/{dtos,types,interfaces}.py`)
+already existing on the base branch, and on merge IM1 (WP1 domain/storage,
+WP2 secret resolution, WP3 policy core, WP5 mocks) having landed.
+
+## south port
+
+- [x] `core/gateways/mcps/providers/http/adapter.py`: add `HttpMCPAdapter(MCPUpstreamInterface)`
+ with `async def relay(self, *, route: MCPResolvedRoute, auth: MCPRelayAuth,
+ context: MCPCallContext, body: bytes, headers: Dict[str, str]) -> MCPRelayResult`,
+ signature copied verbatim from `entities.md` §7.1. Added a keyword-only
+ `__init__(self, *, transport: Optional[httpx.BaseTransport] = None)` beyond the
+ interface's bare signature — an injectable seam for `httpx.MockTransport` in unit
+ tests; `HttpMCPAdapter()` (zero-arg, per the wiring diff below) is unaffected.
+- [x] Implement the POST: send `body` untouched to `route.url`; merge
+ `route.headers` under the caller's forwarded `headers`. Judgment call: "merged
+ under" is implemented as `{**route.headers, **headers}` — the caller's header wins
+ on a name collision (test:
+ `test_caller_header_wins_on_collision_with_route_header`, docstring states the
+ choice). The caller's own `Host` header is always dropped (it named this gateway,
+ not the upstream); a fresh `Host` is set only when the guard pins to a literal IP.
+- [x] Implement the `auth` branch: `isinstance(auth, MCPBrokeredAuth)` — raises
+ `TypeError` (this adapter is only ever reached via `custom`, by construction of
+ WP9's registry routing, not by a namespace check inside this class). When
+ `auth.secret` is `None`, no authorization header is added. When present,
+ `Authorization: {token_type} {access_token}` is built from the resolved grant —
+ read via `getattr` on `auth.secret.secret.data.grant`, NOT imported, because
+ `OAuthGrantSettingsDTO` (entities.md §4.5) is WP16 seed / wave 3 and does not exist
+ in this codebase yet; this needs no change once it lands.
+- [x] Map a transport failure (connection refused, timeout, DNS failure) to
+ `MCPUpstreamError`; do NOT raise on a non-2xx HTTP status or a
+ JSON-RPC error body — return it as `MCPRelayResult` untouched (D16
+ pass-through rule).
+- [x] **SSRF guard, before the POST (D28).** `from oss.src.core.webhooks.utils
+ import resolve_validated_webhook_ip`; call it on `route.url` for `custom`
+ targets only. Write no new guard — that module is the one three other
+ call sites already use. Implemented as unconditional inside `HttpMCPAdapter.relay`
+ (no namespace parameter exists on the port to branch on — see §7.1's frozen
+ signature); correctness rests on WP9's registry routing only `custom` to the
+ `"http"` adapter key, per specs-wp8.md's own framing of this adapter.
+- [x] Translate its `ValueError` into `MCPUpstreamError`, keeping the two
+ messages distinct: a "could not be resolved" DNS failure must not read as
+ a security rejection (the runner's guard makes the same distinction,
+ `services/runner/src/engines/sandbox_agent/mcp.ts:191`).
+- [x] **Connect to the returned literal IP, not the hostname** — the whole
+ reason the function returns a value. Copy the pinning from
+ `api/oss/src/core/webhooks/delivery.py::send_webhook_request`: literal IP
+ in the URL (bracket IPv6, keep an explicit port), `Host` header set back
+ to the original authority, `extensions={"sni_hostname": parsed.hostname}`
+ so TLS validates against the real name. Re-resolving in the client
+ reopens the rebind window.
+- [x] Add the host-allowlist escape hatch to `api/oss/src/utils/env.py` and read
+ it through the shared `env` object — never `os.getenv` in feature code
+ (`api/AGENTS.md`). Mirrors the runner's `AGENTA_AGENT_MCPS_HOST_ALLOWLIST`,
+ so a self-hoster can permit one internal server without disabling the
+ guard globally. Landed as `MCPGatewayConfig.host_allowlist` (env var
+ `AGENTA_MCP_GATEWAY_HOST_ALLOWLIST`, comma-separated), field `mcp_gateway` on
+ `EnvironSettings` — alphabetically between `loops` and `mounts`, per the file's
+ ordering. A listed host bypasses the guard entirely (both the range block and the
+ literal-IP pin), mirroring the runner's `if (allowed) return undefined;`.
+- [x] `ruff format` && `ruff check --fix` from the repo root; fix all
+ errors.
+- [x] Commit: "gateways(mcp): HttpMCPAdapter south-port implementation".
+
+## south port tests (unit)
+
+- [x] Unit test: body passed through byte-for-byte to the mock upstream
+ (assert on what the mock received, not just what came back).
+- [x] Unit test: `route.headers` present in the outbound request; caller
+ `headers` also present; no collision case needs resolving since
+ `entities.md` does not specify one — assert whichever ordering the
+ implementation picks and note it in the test docstring.
+- [x] Unit test: `auth.secret is None` → no `Authorization` header sent.
+- [x] Unit test: `auth.secret` present (a mock `ResolvedSecret`
+ wrapping an `OAuthGrantSettingsDTO`) → `Authorization: Bearer `
+ (or the configured `token_type`) sent. Since `OAuthGrantSettingsDTO` doesn't exist
+ yet, the mock secret is a `types.SimpleNamespace` shaped like its future
+ `.secret.data.grant.{access_token,token_type}`, injected via
+ `MCPDirectAuth.model_construct(secret=...)` to bypass pydantic validation.
+- [x] Unit test: mock upstream refuses the connection → `MCPUpstreamError`
+ raised, carrying `target` and no false `status_code`.
+- [x] Unit test: mock upstream returns HTTP 200 with a JSON-RPC `error`
+ object in the body → `MCPRelayResult` returned with that body intact,
+ no exception.
+- [x] **SSRF unit tests, all with `AGENTA_INSECURE_EGRESS_ALLOWED=false` set
+ explicitly** — it defaults to `true`, so a test that omits it passes while
+ proving nothing: a `custom` route at `http://169.254.169.254/` is refused;
+ at `http://127.0.0.1/` refused; at `http://10.0.0.1/` refused; a plain
+ `http://` public host refused; an unresolvable hostname produces the
+ resolution message, not the blocked-range one. Implemented by monkeypatching
+ `oss.src.core.webhooks.utils._WEBHOOK_ALLOW_INSECURE = False` directly (the same
+ technique `unit/webhooks/test_webhooks_utils.py` already uses, since the flag
+ resolves once at import time into that module constant) — this repo's whole test
+ suite additionally pins this constant `False` by default via an autouse fixture
+ (`oss/tests/pytest/utils/egress.py`), so these cases are secure-by-default even
+ without the module-local fixture; the local fixture makes that explicit rather than
+ relying on the suite-wide default.
+- [x] Unit test: with a public hostname resolving to a public IP (patch the
+ resolver, as `api/oss/tests/pytest/unit/webhooks/test_webhooks_utils.py`
+ does), the outbound request goes to the **literal IP** while the `Host`
+ header carries the hostname.
+- [x] Unit test: an `agenta` route to a private address is NOT refused — the
+ guard is namespace-scoped, and WP5's mocks live on a compose host. Implemented
+ against `MockMCPAdapter` (WP5, read-only import) directly, since `agenta` routes to
+ that adapter, not to `HttpMCPAdapter` — the guard lives only on the latter, so the
+ former never runs it regardless of `route.url`.
+- [x] `ruff format` && `ruff check --fix`; run the new unit tests; fix
+ failures.
+- [x] Commit: "gateways(mcp): HttpMCPAdapter unit tests".
+
+**Finding, not fixed here (out of WP8's file ownership):** the pre-existing WP5 contract
+test `oss/tests/pytest/unit/gateways/test_mock_adapters_contract.py::test_relay_returns_mcp_relay_result[*-HttpMCPAdapter]`
+now fails now that `HttpMCPAdapter` exists. It builds a zero-arg `HttpMCPAdapter()` (no
+`MockTransport`) and calls `.relay()` against `route=MCPResolvedRoute(url="http://mock-mcp-gateway:9092/")`
+— plain `http`, a compose-only hostname. This repo's test suite pins
+`AGENTA_INSECURE_EGRESS_ALLOWED` secure-by-default for every test
+(`oss/tests/pytest/utils/egress.py`'s autouse `secure_egress_by_default` fixture, opt out
+via `@pytest.mark.allow_insecure_env`), so the guard now correctly rejects the bare-`http`
+URL with "must use https" before any DNS lookup — this is D28's guard working as
+designed, not a defect in `HttpMCPAdapter`. Any conforming implementation of the guard
+would reject this exact call under this suite's default posture. The contract test needs
+one of: the `allow_insecure_env` marker plus an actually-reachable `https` mock-gateway
+target, an injected `MockTransport`, or a host-allowlist entry — a call for whoever owns
+that file (WP5) or the IM2 merge coordinator, not WP8.
+
+## utils.py
+
+- [x] `apis/fastapi/gateways/mcps/utils.py`: implement
+ `parse_mcp_call_context(*, headers: Dict[str, str]) -> MCPCallContext`,
+ reading the method and target routing headers per the 2026-07-28
+ MCP revision (`mcp.md`). Pin the exact header names in this file's
+ module docstring, since `entities.md` explicitly defers the choice
+ here. Pinned against `docs/design/gateways-research/v1/raw/mcp-2026-07-28.md`
+ ("Header-based routing"): `MCP-Method` (required) and `MCP-Name` (target for
+ `tools/call`/`resources/read`/`prompts/get`; absent for target-less methods).
+ Lookup is case-insensitive.
+- [x] Raise a typed, documented error (do not invent a new exception class
+ not in `entities.md` — reuse an existing domain exception or a plain
+ `ValueError` translated at the proxy boundary) when a required header
+ is missing or malformed. Implemented as a plain `ValueError`; `proxy.py`'s
+ `_relay` catches it and raises `HTTPException(400)` inline (the "Example 2" inline
+ pattern in `api/AGENTS.md`), since `handle_gateway_exceptions()` doesn't cover it
+ and mustn't be extended for a non-domain, request-shape error.
+- [x] Unit test: representative header sets (both present; target absent
+ for a method that does not need one; method missing entirely) each
+ produce the expected `MCPCallContext` or the expected raise.
+- [x] `ruff format` && `ruff check --fix`; run tests; fix failures.
+- [x] Commit: "gateways(mcp): parse_mcp_call_context".
+
+## proxy.py
+
+- [x] `apis/fastapi/gateways/mcps/proxy.py`: `MCPGatewayProxy.__init__(self,
+ *, mcp_gateway_service: MCPGatewayService)`, `self.router = APIRouter()`.
+ `MCPGatewayService` is not on this branch yet (WP9 unmerged) — imported only under
+ `TYPE_CHECKING` with `from __future__ import annotations`, so the module loads with
+ no runtime dependency on WP9 and picks up the real type the moment it lands.
+- [x] Register the two POST routes exactly as in `entities.md` §9:
+ `/builtin/{provider}/{rest:path}` → `relay_builtin`, operation_id
+ `mcp_gateway_relay_builtin`; `/custom/{slug}` → `relay_custom`,
+ operation_id `mcp_gateway_relay_custom`. Builtin's tail is a catch-all
+ because the arity differs per provider — `split_builtin_path` divides
+ it; a plain component silently breaks nested agenta identifiers.
+- [x] Register `reject_stream_verbs` on the same two paths for `GET` and
+ `DELETE`, `include_in_schema=False`, returning 405.
+- [x] Implement `relay_builtin`/`relay_custom`: each calls
+ `get_auth_scope()`, calls `parse_mcp_call_context(headers=...)`, reads
+ the raw request body, and delegates to
+ `self.service.relay(scope=..., namespace=..., name=..., provider=...,
+ integration=..., context=..., body=..., headers=...)` — `namespace`
+ is the literal `GatewayEndpointNamespace` matching the route;
+ `provider`/`integration` are set only in `relay_builtin`. Judgment call: the
+ proxy strips its own `Authorization` header (the caller's platform token) from
+ `headers` before it reaches `parse_mcp_call_context`/`service.relay` — an upstream
+ `custom` server must never see the secret that authenticated the caller to us;
+ `interfaces.py`'s docstring says `headers` arrive at the adapter "already stripped
+ of authorization" but does not say which layer strips them, and the proxy is the
+ first code to hold the raw `request.headers`.
+- [x] Translate the returned `MCPRelayResult` into a raw `Response` with the
+ relayed `status_code`, `headers`, and `body` — no wrapping envelope
+ (§6: the data plane has no wire models).
+- [x] **Correction, applied after the coordinator caught it mid-package:**
+ handlers are decorated with `@intercept_exceptions()` only.
+ `handle_gateway_exceptions()` (seed, R1, `apis/fastapi/gateways/exceptions.py`)
+ is right for WP10's CRUD routers, which speak the house wire, and wrong for a
+ proxy: it raises a plain `HTTPException(status, detail=str)`, which collapses
+ every cause sharing a status into one indistinguishable message —
+ `MCPEndpointNotFoundError` and `SecretNotFoundError` both become an opaque
+ `HTTPException` a caller cannot tell apart. §9 requires the opposite: *"the MCP
+ proxy answers protocol-shaped errors at the transport status the relay produced,
+ and gateway-authored refusals as the protocol's error result with the same
+ stable causes in the error data."* `proxy.py` therefore keeps its own mapping,
+ `_map_gateway_exception`, producing a JSON-RPC error result (`id: null`, since
+ this proxy never parses the body and so never has a real id to echo) with a
+ stable snake_case `cause` in `error.data` — never the house
+ `{code,message,retryable,...}` envelope, which must not leak onto this surface.
+ The HTTP status per cause is unchanged from `handle_gateway_exceptions()`'s
+ table (404 not-found, 403 policy/entitlement/tool-denied, 400
+ ceiling-exceeded/invalid-request, 409 auth-required/scope-insufficient/
+ secret-missing/invalid, 424-or-502 upstream). The missing-`MCP-Method`
+ `ValueError` folds into the same mapping (`cause: "invalid_request"`) rather
+ than being a bespoke `HTTPException(400)` special case — `handle_gateway_exceptions`
+ is not imported into this file at all. `apis/fastapi/gateways/exceptions.py`
+ itself is untouched — not copied, not edited — and stays what WP10 uses.
+- [x] `ruff format` && `ruff check --fix`; fix all errors.
+- [x] Commit: "gateways(mcp): MCPGatewayProxy routes".
+
+## proxy.py tests (unit)
+
+- [x] Unit test (TestClient + mock `MCPGatewayService` + mockd
+ `get_auth_scope()`): `POST /builtin/agenta/tools/search` reaches
+ `relay_builtin` with `provider="agenta"`, `name="tools/search"` —
+ proves the catch-all nests.
+- [x] Unit test: `POST /builtin/composio/notion/my-notion` reaches the same
+ handler with `provider="composio"`, `integration="notion"`,
+ `name="my-notion"`.
+- [x] Unit test: `POST /custom/acme-notion` reaches `relay_custom` with
+ `name="acme-notion"`.
+- [x] Unit test: `GET` and `DELETE` on both paths return 405.
+- [x] Unit test, revised for the correction above: a parametrized table of all eleven
+ mapped causes (`endpoint_not_found`, `policy_denied`, `entitlement_denied`,
+ `tool_not_allowed`, `ceiling_exceeded`, `auth_required`, `scope_insufficient`,
+ `secret_missing`, `secret_invalid`, `upstream_error` below/above 500 and
+ with no upstream status at all) each asserts BOTH the HTTP status AND the
+ `error.data.cause` string in the JSON-RPC error body — asserting the status
+ alone would also have passed under the old `HTTPException` behaviour and proved
+ nothing about the cause surviving. A second test confirms
+ `MCPAuthRequiredError`'s `GatewayConnectionRequirement` rides in `error.data`.
+ The missing-header 400 test now also asserts `cause == "invalid_request"` and
+ the `{jsonrpc, id: null, error}` shape, not just the status.
+- [x] `ruff format` && `ruff check --fix`; run tests; fix failures.
+- [x] Commit: "gateways(mcp): MCPGatewayProxy routing tests".
+
+## entrypoint wiring (coordinate at IM2)
+
+- [x] Add the `"http": HttpMCPAdapter()` entry to the `MCPUpstreamRegistry`
+ adapters dict in `api/entrypoints/routers.py`, as a diff fragment —
+ do not edit the file directly if WP9's surrounding construction
+ block has not landed; raise it at the merge instead (per
+ `workstreams/README.md` rule 1: own your paths).
+- [x] Add `mcp_gateway_proxy = MCPGatewayProxy(mcp_gateway_service=mcp_gateway_service)`
+ and `app.include_router(router=mcp_gateway_proxy.router,
+ prefix="/gateways/mcps", include_in_schema=False)` as a second diff
+ fragment.
+- [ ] At the IM2 merge: apply this package's two fragments together with
+ WP6's, WP7's, WP9's and WP10's. Verify with `git diff` that the
+ combined edit to `routers.py` contains exactly these lines plus the
+ siblings' — no accidental double-registration of the `"http"` key.
+- [ ] `ruff format` && `ruff check --fix` on the merged `routers.py`.
+- [ ] Commit (at the merge, not before): "gateways: wire WP6/7/8/9/10 into
+ entrypoints/routers.py".
+
+**WP8's two fragments, as-written (not applied — `routers.py` stays untouched by this
+package; current placeholders read at commit time are quoted for context).**
+
+Fragment 1 — the import (currently commented at `api/entrypoints/routers.py:181`):
+
+```diff
+-# from oss.src.apis.fastapi.gateways.mcps.proxy import MCPGatewayProxy # WP8
++from oss.src.apis.fastapi.gateways.mcps.proxy import MCPGatewayProxy
++from oss.src.core.gateways.mcps.providers.http.adapter import HttpMCPAdapter
+```
+
+Fragment 2 — the adapter registry entry, into WP9's `MCPUpstreamRegistry` construction
+(the block does not exist on this branch yet; shown relative to its shape in
+`specs-wp9.md` / `entities.md` §9's wiring block):
+
+```diff
+ upstream_registry=MCPUpstreamRegistry(adapters={
+- # WP9 constructs this dict; WP8, WP5 and (later) the Composio
+- # adapter each contribute one entry, combined at the IM2 merge.
++ "http": HttpMCPAdapter(), # custom: MCPDirectAuth
+ }),
+```
+
+Fragment 3 — the proxy construction, next to WP9's `mcp_gateway_service` construction:
+
+```diff
++mcp_gateway_proxy = MCPGatewayProxy(mcp_gateway_service=mcp_gateway_service)
+```
+
+Fragment 4 — the mount (currently commented at `api/entrypoints/routers.py:1519`,
+alongside WP10's `mcp_gateway.router` line on 1518, which this package does not touch):
+
+```diff
+-# app.include_router(router=mcp_gateway.proxy, prefix="/gateways/mcps", include_in_schema=False)
++app.include_router(
++ router=mcp_gateway_proxy.router,
++ prefix="/gateways/mcps",
++ include_in_schema=False,
++)
+```
+
+## C1 verification (acceptance, after IM2 deploy)
+
+- [ ] Deploy the merged stack (WP1 migration applied, WP5 mock MCP server
+ running as a compose service).
+- [ ] `POST /gateways/mcps/builtin/agenta/` with `tools/list` returns the
+ mock server's own tool list unchanged.
+- [ ] The same call, `tools/call` on an in-policy tool, returns the mock
+ server's own result unchanged.
+- [ ] The same call, `tools/call` on a tool outside the mock endpoint's
+ `tools` filter, returns 403 (`MCPToolNotAllowedError`).
+- [ ] `GET`/`DELETE` on any of the three relay paths returns 405.
+- [ ] File any acceptance-test failure as a finding, not a silent fix —
+ this suite is shared with WP9; a failure may belong to either
+ package.
+
+## Definition of done
+
+Feeds **C1**. Plan.md's stated done condition, verbatim: *"list
+and call both relay unchanged and a tool outside the allowlist is
+refused."* WP8 is done when: `parse_mcp_call_context` and `HttpMCPAdapter`
+pass their unit tests with no real network or database; the three proxy
+routes dispatch to the right handler with the right parsed segments,
+verified against a mock service; the two `routers.py` diff fragments are
+ready to hand to the IM2 merge; and the C1 acceptance assertions
+above pass against the deployed stack.
diff --git a/docs/design/gateways-research/v1/workstreams/tasks-wp9.md b/docs/design/gateways-research/v1/workstreams/tasks-wp9.md
new file mode 100644
index 0000000000..6a5674a146
--- /dev/null
+++ b/docs/design/gateways-research/v1/workstreams/tasks-wp9.md
@@ -0,0 +1,226 @@
+# WP9 tasks — MCP registry and tool allowlist
+
+Ordered so each item is one reviewable commit. Depends on the seed commit
+and on merge IM1 (WP1's `dbs/postgres/gateways/mcps/` DAO implementation and
+migration, WP2's `SecretsResolverInterface` implementation, WP3's
+`GatewayPolicyService`) having landed.
+
+## registry.py
+
+- [x] `core/gateways/mcps/registry.py`: add `MCPUpstreamRegistry.__init__(self,
+ *, adapters: Dict[str, MCPUpstreamInterface])`, `get(self, key: str) ->
+ MCPUpstreamInterface` (raises on a miss), `keys(self) -> list[str]` —
+ shape copied from `ConnectionsGatewayRegistry`
+ (`api/oss/src/core/gateway/connections/registry.py`).
+- [x] Pick the raise for a missing key: an already-declared
+ `core/gateways/mcps/types.py` exception (e.g. `MCPUpstreamError` with
+ a message naming the key), not a new public exception name and not a
+ cross-domain import — see "Missing from the design" in `specs-wp9.md`.
+ Done: `MCPUpstreamError(target=key, detail=...)`. Note:
+ `core/gateways/mcps/interfaces.py` (frozen) declares a same-named
+ `MCPUpstreamRegistry` stub class raising `NotImplementedError` on every
+ method — left untouched per the "own your paths" rule; the real class
+ built here lives only in `registry.py` and is what the composition
+ root and all callers import. Nothing in the codebase imported the
+ stub, so this is dead code in a frozen file, flagged rather than
+ fixed.
+- [x] Unit test: `get()` on a registered key returns that adapter; `get()`
+ on a missing key raises; `keys()` returns exactly the registered set.
+- [x] `ruff format` && `ruff check --fix`; fix all errors.
+- [x] Commit: "gateways(mcp): MCPUpstreamRegistry".
+
+## service.py — management CRUD
+
+- [x] `core/gateways/mcps/service.py`: `MCPGatewayService.__init__(self, *,
+ mcp_endpoints_dao, policy, resolver, upstream_registry,
+ connections_service)`. `connections_service: ConnectionsService` is
+ required for real (see the three-source-merge section below);
+ entities.md §8's abbreviated constructor pseudocode omits it, which is a
+ gap in the design, not an instruction to mock the integration. Flagged
+ for the IM2 merge review.
+- [x] Implement `create_endpoint`, `fetch_endpoint`, `edit_endpoint`,
+ `delete_endpoint`, `query_endpoints` as thin delegations to
+ `MCPEndpointsDAOInterface`.
+- [x] Unit test each, with a mock DAO (in-memory dict), asserting the right
+ DAO verb is called with the right arguments and the return value is
+ passed through unchanged.
+- [x] `ruff format` && `ruff check --fix`; run tests; fix failures.
+- [x] Commit: "gateways(mcp): MCPGatewayService CRUD delegation".
+
+## service.py — the three-source merge
+
+- [x] Implement `list_endpoints(*, project_id) -> List[MCPEndpoint]`:
+ `custom` branch maps `query_endpoints()` rows 1:1.
+- [x] `agenta` branch: a private, service-internal enumeration (not a
+ public symbol `entities.md` does not name) of the code-defined
+ builtin/agenta entries — in wave 1, the mocks WP5 registers. Implemented as
+ `_agenta_endpoints()`: one entry, slug "tools" (matching D30's own
+ route-grammar example `builtin/agenta/tools`), with `provider_key="agenta"`
+ and `data.route.base_url=env.mock_gateways.mcp_url`.
+- [x] `builtin` branch: call `ConnectionsService.query_connections(
+ project_id=project_id, provider_key="composio")`, map each
+ `Connection` into an `MCPEndpoint` with `namespace=BUILTIN`,
+ `connection_id`, `provider_key`, `integration_key`, `slug` stamped
+ from the connection row. `data.route.base_url` is a non-dialable placeholder
+ (`composio://{provider}/{integration}/{slug}`) — no document fixes a
+ real Composio MCP base URL, and D23 keeps every builtin target
+ unreachable this wave anyway.
+- [x] Implement `GatewayConnectionState` derivation per owner/namespace,
+ exactly as specified in `entities.md` §8: NONE-scheme → `READY`;
+ `custom` whose `secret_id` is set and whose `flags.is_valid` holds →
+ `READY`; `builtin` with an active+valid connection → `READY`; otherwise `NEEDS_AUTH` for an
+ OAuth/builtin target; `NEEDS_INPUT` reserved (unreachable, `api_key`
+ deferred with D14). Implemented as `_connection_state(project_id,
+ user_id, endpoint)`. Note: NOT called from `list_endpoints` — that
+ method has no owner/user_id parameter (fixed by entities.md §8's own
+ signature), so it cannot derive a per-caller state. `_connection_state`
+ is exercised directly by its own unit tests as the seam a future
+ per-owner read (WP10's CRUD router, or the D17 connect-affordance
+ builder) wires in.
+- [x] Unit test: agenta entries carry no `id`, `namespace=BUILTIN` with
+ `provider_key="agenta"` and a dialable `data.route.base_url`; composio
+ entries carry `connection_id`/`provider_key`/`integration_key`,
+ `namespace=BUILTIN`; custom rows carry `namespace=CUSTOM`; no
+ generated entry is ever passed to a DAO write (assert the mock DAO's
+ write methods were never called for agenta/builtin entries).
+- [x] Unit test: connection-state derivation for each of the four cases
+ above (NONE, custom+secret, builtin+valid-connection, custom+no-secret).
+- [x] `ruff format` && `ruff check --fix`; run tests; fix failures.
+- [x] Commit: "gateways(mcp): list_endpoints three-source merge".
+
+## service.py — relay orchestration
+
+- [x] Implement target resolution by namespace (step 1): `agenta` → code
+ lookup by `name`; `builtin` → `ConnectionsService` lookup by
+ `(provider, integration, name)`; `custom` →
+ `fetch_endpoint_by_slug(project_id, slug=name)`. Raise
+ `MCPEndpointNotFoundError` (with `namespace`, `provider`,
+ `integration`, `name`) when nothing resolves.
+- [x] Implement `_check_allowlist` (step 2): refuse a named tool outside an
+ `tools` allowlist with `MCPToolNotAllowedError`, called BEFORE
+ any resolver or adapter call.
+- [x] Implement the authorize step (step 3): `self.policy.authorize(scope=,
+ permission=Permission.USE_MCP_ENDPOINTS, target=)`; on denial, call
+ `self.policy.record(...)` before raising `PolicyDeniedError`.
+- [x] Implement secret resolution (step 4): `agenta`/`custom` via
+ `self.resolver.resolve(scope=, ref=, mode=SecretMode.USER_OPTIONAL)`
+ wrapped in `MCPDirectAuth`, skipped (`secret=None`) for
+ NONE-scheme targets; `builtin` via `ConnectionsService` directly,
+ wrapped in `MCPBrokeredAuth` — never through the resolver.
+- [x] Implement dispatch (step 5): a private namespace→adapter-key mapping
+ (`agenta`→`"mock"`, `builtin`→`"composio"`, `custom`→`"http"` in wave
+ 1), then `self.upstream_registry.get(key).relay(route=, auth=,
+ context=, body=, headers=)`.
+- [x] Implement record + list-filter (step 6): `self.policy.record(...)`
+ with the real outcome; when `context.method` is a list operation,
+ filter the JSON response body by the `tools` filter (an allowlist drops
+ entries whole; `ALL` passes everything). Scoped strictly to
+ `context.method == "tools/list"` — not any `*/list` method, since a
+ tool allowlist says nothing about resources/prompts entries.
+- [x] Unit test the step ORDER, not just the final outcome: a tool outside
+ policy raises without the mock resolver or mock adapter ever being
+ invoked (assert on the mocks' call counts, zero for both).
+- [x] Unit test: a policy denial calls `policy.record` before the exception
+ propagates — assert call order via a call-log mock, not just that
+ both eventually happened.
+- [x] Unit test: a `builtin` target's relay call never touches the mock
+ resolver, only the mock `ConnectionsService`.
+- [x] Unit test: tool-list filtering — a canned three-tool `tools/list`
+ response filtered by `INCLUDE, names=["a","b"]` returns exactly two,
+ unmodified in shape; `ALL` passes all three through untouched.
+- [x] `ruff format` && `ruff check --fix`; run tests; fix failures.
+- [x] Commit: "gateways(mcp): relay six-step orchestration".
+- [x] Added beyond the checklist: `relay()` normalizes `namespace` to the
+ real `GatewayEndpointNamespace` enum on entry, because
+ `_ResolvedTarget` is a plain dataclass (not pydantic) and does not
+ auto-coerce a bare-string namespace the way `GatewayTarget` would —
+ without this, a caller passing a plain string (the FastAPI path-param
+ case) would crash on the first `.value` access downstream
+ (`MCPEndpointNotFoundError`, `_ADAPTER_KEYS[namespace]`). Covered
+ implicitly by every relay test, which passes plain strings.
+
+## entrypoint wiring (coordinate at IM2)
+
+**Not applied to `routers.py` by this package** — recorded here per the six rules'
+"own your paths", for whoever runs the IM2 merge.
+
+- [ ] Add the `MCPGatewayService` + `MCPUpstreamRegistry` construction
+ block to `api/entrypoints/routers.py` as a diff fragment. **Updated**
+ from `specs-wp9.md`'s own diff (which omits `connections_service` and
+ the mock-adapter import/registration — both required, see below):
+
+ ```diff
+ -# from oss.src.core.gateways.mcps.providers.mock.adapter import MockMCPAdapter
+ +from oss.src.core.gateways.mcps.providers.mock.adapter import MockMCPAdapter
+ +from oss.src.core.gateways.mcps.registry import MCPUpstreamRegistry
+ +from oss.src.core.gateways.mcps.service import MCPGatewayService
+ +
+ +mcp_gateway_service = MCPGatewayService(
+ + mcp_endpoints_dao=mcp_endpoints_dao,
+ + policy=gateway_policy_service,
+ + resolver=secret_resolver,
+ + connections_service=connections_service,
+ + upstream_registry=MCPUpstreamRegistry(adapters={
+ + # "http": HttpMCPAdapter(), # custom: MCPDirectAuth (WP8)
+ + # "composio": ComposioMCPAdapter(), # builtin: MCPBrokeredAuth (no owner in wave 1)
+ + "mock": MockMCPAdapter(), # serves the builtin/agenta mocks (D23, WP5)
+ + }),
+ +)
+ ```
+
+ Two deltas from the spec's literal text, both load-bearing:
+ 1. **`connections_service=connections_service`** — without it,
+ `MCPGatewayService.__init__` (as built) raises `TypeError` for a
+ missing required keyword argument; `list_endpoints`'s builtin
+ branch and `relay`'s builtin target resolution both call through
+ it for real (see the three-source-merge section above).
+ 2. **The `MockMCPAdapter` import is uncommented and the adapter is
+ registered under `"mock"`.** Per the coordinator's note on this
+ package's landed foundation: WP5's import was left commented with
+ "their imports land with those [WP7/WP9's registries], not here."
+ Uncommenting it and registering it here is this package's job, not
+ WP5's or a later merge step — without it the mocks are unreachable
+ and C1 has nothing to relay to (D23: the mocks are the
+ entire reachable target set in wave 1, no brokered target exists).
+- [ ] `"http": HttpMCPAdapter()` and `"composio": ComposioMCPAdapter()` are
+ left commented above, not omitted outright, so the shape of the final
+ dict is visible at the merge site. `HttpMCPAdapter` is WP8's; landing
+ it uncomments that line. `ComposioMCPAdapter` has no owning package in
+ wave 1 (flagged already by specs-wp9.md) — decide with the merge
+ reviewers whether it stays commented indefinitely or gets a raising
+ stub. Do not silently invent an implementation here.
+- [ ] At the IM2 merge: apply this fragment together with WP6's, WP7's,
+ WP8's and WP10's. Verify with `git diff` that the combined edit
+ contains exactly the expected lines.
+- [ ] `ruff format` && `ruff check --fix` on the merged `routers.py`.
+- [ ] Commit (at the merge, not before): "gateways: wire WP6/7/8/9/10 into
+ entrypoints/routers.py" (shared commit with WP8's fragment — one
+ commit for the whole merged file, not one per package).
+
+## C1 verification (acceptance, after IM2 deploy)
+
+**Not run by this package** — needs a live deployment per the "know which tests you
+may run" rule, so this section stays a checklist for whoever runs the IM2 deploy.
+
+- [ ] Deploy the merged stack.
+- [ ] `create_endpoint` a custom NONE-scheme MCP endpoint; confirm
+ `fetch_endpoint_by_slug` resolves it and `list_endpoints` includes it
+ under `namespace=CUSTOM`.
+- [ ] With at least one active composio connection seeded, confirm
+ `list_endpoints` includes a `namespace=BUILTIN` entry with no
+ corresponding `mcps_endpoints` row.
+- [ ] Confirm the shared C1 relay/allowlist assertions from
+ `tasks-wp8.md` pass (this package's `relay` implementation is what
+ makes them true).
+- [ ] File any acceptance-test failure as a finding — this suite is shared
+ with WP8; a failure may belong to either package.
+
+## Definition of done
+
+Feeds **C1**. Plan.md's stated done condition, verbatim: *"a
+custom server registers and resolves, and a built-in one needs no row."*
+WP9 is done when: every CRUD/merge/relay unit test above passes with no
+real database or network; `list_endpoints` never writes a generated entry
+to the DAO; the connection-state derivation is correct for all four cases;
+the relay step order is verified, not just its outcome; and the
+C1 acceptance assertions above pass against the deployed stack.
diff --git a/docs/designs/platform/runner-rename/specs.md b/docs/designs/platform/runner-rename/specs.md
index f2c0012469..4242b8c872 100644
--- a/docs/designs/platform/runner-rename/specs.md
+++ b/docs/designs/platform/runner-rename/specs.md
@@ -43,7 +43,7 @@ These share `sandbox-agent` / `sandbox_agent` strings but are separate concepts
- **`AGENTA_API_INTERNAL_URL`** — an optional in-cluster bypass with no active consumer;
unrelated to the runner knob.
- **`AGENTA_MOUNTS_TUNNEL_API`** — store/tunnel var consumed by `mount.ts`; belongs to W6
- (store-generalization), not here.
+ (store-generalization), not here. (Superseded: later removed outright.)
- **Agent-behaviour `AGENTA_AGENT_*`** not touched: tools/skills/content+usage/`SANDBOX_PI_*`.
- **Docs prose** under `docs/design/agent-workflows/` — optional follow-up (W7).
diff --git a/docs/designs/platform/store-generalization/specs.md b/docs/designs/platform/store-generalization/specs.md
index 935299fd51..3329a36ec6 100644
--- a/docs/designs/platform/store-generalization/specs.md
+++ b/docs/designs/platform/store-generalization/specs.md
@@ -51,6 +51,9 @@ not a store-credential var. It reads as "the tunnel API used by the mounts runne
stays `AGENTA_MOUNTS_TUNNEL_API`. Renaming it to `AGENTA_STORE_TUNNEL_API` would imply the
store knows about tunnels, which it does not.
+**Superseded:** the variable was later removed outright. It was never set anywhere, and the
+compose service name it defaulted to already resolves.
+
## Decision 2 — move the adapter and signing helper to `core/store/`
`api/oss/src/core/mounts/storage.py` contains `MountStorage` (S3-compatible adapter,
@@ -149,7 +152,7 @@ PR) before their env blocks are correct.
- **Break clean** — no dual-read, no legacy alias, no deprecation fallback.
- **`AGENTA_MOUNTS_TUNNEL_API` stays** — it is the runner-side tunnel-discovery URL, not a
- store credential var.
+ store credential var. (Superseded: later removed outright, never having been set.)
- **`seaweedfs.enabled` stays** — it is the bundle-or-external service toggle, not a store
var.
- **`MountStorage` → `ObjectStore`** (class rename, moved to `core/store/storage.py`).
diff --git a/docs/designs/platform/store-generalization/tasks.md b/docs/designs/platform/store-generalization/tasks.md
index 122f4e5e76..35725b2f6a 100644
--- a/docs/designs/platform/store-generalization/tasks.md
+++ b/docs/designs/platform/store-generalization/tasks.md
@@ -8,7 +8,7 @@
- [x] **Break clean** — no dual-read, no legacy alias. Delete old names in the same
commit that adds new names.
- [x] `AGENTA_MOUNTS_TUNNEL_API` stays — runner-side tunnel discovery, not a store
- credential var.
+ credential var. (Superseded: later removed outright.)
- [x] `seaweedfs.enabled` toggle name stays — bundle-or-external service toggle, not
a store credential var.
- [x] Prefix change: `//` → `mounts///`.
@@ -64,7 +64,7 @@ In both `hosting/docker-compose/oss/docker-compose.dev.yml` and
Update both the per-service `environment:` blocks (api, worker-*, sandbox-agent
variants) and the `seaweedfs` service startup script that inlines the access/secret
keys into `s3.json` and maps `WEED_JWT_FILER_SIGNING_KEY` from the secret.
-- [ ] Leave `AGENTA_MOUNTS_TUNNEL_API` untouched.
+- [x] Leave `AGENTA_MOUNTS_TUNNEL_API` untouched. (Superseded: later removed outright.)
## 6. Helm — rename env vars and secret keys
diff --git a/docs/docs/self-host/access-control/03-dynamic-access-controls.mdx b/docs/docs/self-host/access-control/03-dynamic-access-controls.mdx
index 8fdecb1ed8..d811ecbe9d 100644
--- a/docs/docs/self-host/access-control/03-dynamic-access-controls.mdx
+++ b/docs/docs/self-host/access-control/03-dynamic-access-controls.mdx
@@ -106,7 +106,7 @@ Used by `counters` and `gauges` map values.
### Counter keys {#counter-keys}
-`evaluations_run`, `traces_ingested`, `traces_retrieved`, `credits_consumed`, `events_ingested`.
+`evaluations_run`, `traces_ingested`, `traces_retrieved`, `events_ingested`.
`traces_ingested` and `events_ingested` are independent retention domains: each has its own
counter, its own retention window, its own admin flush endpoint
@@ -158,7 +158,6 @@ starting point for any further customization via `AGENTA_ACCESS_PLANS`:
"evaluations_run": {"strict": true, "period": "monthly"},
"traces_ingested": {"period": "monthly"},
"traces_retrieved": {"strict": true, "scope": "user", "period": "daily"},
- "credits_consumed": {"strict": true, "period": "monthly"},
"events_ingested": {"period": "monthly"}
},
"gauges": {
diff --git a/docs/docs/self-host/reference/01-configuration.mdx b/docs/docs/self-host/reference/01-configuration.mdx
index 3bffe7d683..5e4f427327 100644
--- a/docs/docs/self-host/reference/01-configuration.mdx
+++ b/docs/docs/self-host/reference/01-configuration.mdx
@@ -717,6 +717,12 @@ If you're running a shared or multi-tenant deployment, set it to `false`. Agenta
blocks any such request that resolves to a private, loopback, or reserved IP address,
regardless of who configured the URL. Public URLs are never affected either way.
+Every deployment configuration in this repo that produces a reachable, sign-ups-open
+instance — the docker-compose `.gh` env files and the Kubernetes `values.*.example.yaml`
+overlays — sets this to `false` already. Only the local dev docker-compose files
+(`.env.oss.dev`, `.env.ee.dev`, single developer, not exposed) keep the permissive
+default.
+
The old per-surface names (`AGENTA_WEBHOOKS_ALLOW_INSECURE`, `AGENTA_WEBHOOK_ALLOW_INSECURE`,
`AGENTA_SERVICES_HOOK_ALLOW_INSECURE`, `AGENTA_CUSTOM_PROVIDER_ALLOW_INSECURE`) are
deprecated aliases and still work, but new configuration should use the canonical name.
diff --git a/hosting/docker-compose/ee/docker-compose.dev.yml b/hosting/docker-compose/ee/docker-compose.dev.yml
index c8c47ede18..47fb309a3e 100644
--- a/hosting/docker-compose/ee/docker-compose.dev.yml
+++ b/hosting/docker-compose/ee/docker-compose.dev.yml
@@ -678,7 +678,7 @@ services:
retries: 30
start_period: 5s
- ngrok:
+ ngrok-mounts:
# === ACTIVATION =========================================== #
# Only for REMOTE sandboxes (Daytona/E2B): geesefs runs inside the cloud sandbox and
# cannot reach `seaweedfs:8333` on the compose network, so this tunnels the store to a
@@ -699,7 +699,7 @@ services:
command:
- |
if [ -z "$${NGROK_AUTHTOKEN}" ]; then
- echo "ngrok: NGROK_AUTHTOKEN unset; no tunnel (remote sandbox mounts disabled)."
+ echo "ngrok-mounts: NGROK_AUTHTOKEN unset; no tunnel (remote sandbox mounts disabled)."
exit 0
fi
printf 'version: 3\nagent:\n web_addr: 0.0.0.0:4040\n' > /tmp/ngrok.yml
@@ -720,6 +720,65 @@ services:
seaweedfs:
condition: service_healthy
+ ngrok-ingress:
+ # === ACTIVATION =========================================== #
+ # A SECOND tunnel, beside the store's, publishing THIS deployment's ingress on a public
+ # HTTPS address. It exists for the cases where something outside has to reach us: a
+ # platform posting a webhook, or an authorization server fetching a document we serve.
+ #
+ # It forwards to traefik, so every inbound route arrives on its normal path -- `/api/...`
+ # is already routed there, in the self-host compose files, and in production. So no
+ # integration needs a tunnel of its own; one endpoint serves channels, the model and MCP
+ # gateways, and anything added later.
+ #
+ # A browser redirect needs none of this: the user is already looking at this deployment,
+ # so the address that got them there is one their browser reaches.
+ #
+ # Separate from the `ngrok-mounts` service on purpose. That one publishes the object store and
+ # the runner finds it at `ngrok-mounts:4040`; keeping them apart means neither can be handed the
+ # other's URL. Costs two agent sessions -- if the plan allows only one, run a single agent
+ # with two named endpoints instead: the runner matches a tunnel by the upstream it
+ # forwards to, so that arrangement is safe too.
+ profiles:
+ - with-tunnel
+ # === IMAGE ================================================ #
+ image: ngrok/ngrok:latest
+ # === EXECUTION ============================================ #
+ # Same shape as the store tunnel: without a token ngrok would exit non-zero and, under
+ # `restart: on-failure`, loop; exit 0 instead so an unconfigured deploy stays quiet.
+ # NGROK_DOMAIN_INGRESS pins a reserved domain, which is what keeps an address we hand to a
+ # provider valid across restarts -- a rotating one invalidates every registration.
+ entrypoint: ["/bin/sh", "-c"]
+ command:
+ - |
+ if [ -z "$${NGROK_AUTHTOKEN}" ]; then
+ echo "ngrok-ingress: NGROK_AUTHTOKEN unset; no tunnel (ingress not published)."
+ exit 0
+ fi
+ DOMAIN_ARG=""
+ if [ -n "$${NGROK_DOMAIN_INGRESS}" ]; then
+ DOMAIN_ARG="--domain=$${NGROK_DOMAIN_INGRESS}"
+ fi
+ printf 'version: 3\nagent:\n web_addr: 0.0.0.0:4040\n' > /tmp/ngrok.yml
+ exec /bin/ngrok http traefik:80 $${DOMAIN_ARG} --log stdout --config /tmp/ngrok.yml
+ # === CONFIGURATION ======================================== #
+ environment:
+ NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN:-}
+ NGROK_DOMAIN_INGRESS: ${NGROK_DOMAIN_INGRESS:-}
+ # === NETWORK ============================================== #
+ networks:
+ - agenta-network
+ # Nothing reads this agent's API today; the public URL is for a human to paste into a
+ # provider's configuration. Uncomment to inspect it from the host.
+ # ports:
+ # - "4041:4040"
+ # === LIFECYCLE ============================================ #
+ # Not `always`: that would restart the intentional exit-0 above into a loop.
+ restart: on-failure
+ depends_on:
+ traefik:
+ condition: service_healthy
+
traefik:
# === IMAGE ================================================ #
image: traefik:2
@@ -797,6 +856,63 @@ services:
# === LIFECYCLE ============================================ #
restart: always
+ # Fake upstreams (D23, WP5): the only reachable targets for Checkpoint A's acceptance
+ # suite. Not profile-gated — unlike the composio and tunnel services these are ours, not a third-party
+ # dependency, so they run every deploy (entities.md §0, "the gateways have no
+ # third-party dependency to gate on"). Not a license-gated feature: the EE dev stack
+ # mirrors OSS dev here.
+ mock-llm-gateway:
+ # === IMAGE ================================================ #
+ image: agenta-ee-dev-api:latest
+ # === EXECUTION ============================================ #
+ command:
+ [
+ "uvicorn",
+ "oss.src.core.gateways.llms.providers.mock.app:app",
+ "--host",
+ "0.0.0.0",
+ "--port",
+ "9091",
+ ]
+ # === NETWORK ============================================== #
+ ports:
+ - "127.0.0.1:9091:9091"
+ networks:
+ - agenta-network
+ # === LIFECYCLE ============================================ #
+ restart: always
+ healthcheck:
+ test: ["CMD", "curl", "-sf", "http://localhost:9091/health"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ mock-mcp-gateway:
+ # === IMAGE ================================================ #
+ image: agenta-ee-dev-api:latest
+ # === EXECUTION ============================================ #
+ command:
+ [
+ "uvicorn",
+ "oss.src.core.gateways.mcps.providers.mock.app:app",
+ "--host",
+ "0.0.0.0",
+ "--port",
+ "9092",
+ ]
+ # === NETWORK ============================================== #
+ ports:
+ - "127.0.0.1:9092:9092"
+ networks:
+ - agenta-network
+ # === LIFECYCLE ============================================ #
+ restart: always
+ healthcheck:
+ test: ["CMD", "curl", "-sf", "http://localhost:9092/health"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
# Dev tunnel for Composio trigger events (disable: run.sh --no-tunnel).
composio:
# === ACTIVATION =========================================== #
diff --git a/hosting/docker-compose/ee/env.ee.dev.example b/hosting/docker-compose/ee/env.ee.dev.example
index 2fa83bfbaf..cbe86a4c39 100644
--- a/hosting/docker-compose/ee/env.ee.dev.example
+++ b/hosting/docker-compose/ee/env.ee.dev.example
@@ -221,6 +221,8 @@ AGENTA_INSECURE_EGRESS_ALLOWED=true
# ================================================================== #
# COMPOSIO_API_KEY=
# COMPOSIO_API_URL=https://backend.composio.dev/api/v3.1
+# AGENTA_MOCK_LLM_GATEWAY_URL=http://mock-llm-gateway:9091
+# AGENTA_MOCK_MCP_GATEWAY_URL=http://mock-mcp-gateway:9092
# ================================================================== #
# crisp
@@ -241,9 +243,16 @@ AGENTA_INSECURE_EGRESS_ALLOWED=true
# Generic fallback snapshot, used when DAYTONA_SNAPSHOT_CODE is unset.
# DAYTONA_SNAPSHOT=
-# Publishes the durable store on a public URL so Daytona agent sandboxes can mount it
-# (dev compose, `with-tunnel` profile).
+# ================================================================== #
+# ngrok (development tunnels)
+# ================================================================== #
+# Drives both dev tunnels (`with-tunnel` profile): `ngrok-mounts` publishes the durable store
+# so Daytona sandboxes can mount it, `ngrok-ingress` publishes this deployment so a provider
+# can post a webhook to `/api/...`. Two tunnels means two agent sessions.
# NGROK_AUTHTOKEN=
+# Pins the ingress tunnel to a reserved domain, so an address registered with a provider
+# survives a restart.
+# NGROK_DOMAIN_INGRESS=
# ================================================================== #
# docker
diff --git a/hosting/docker-compose/ee/env.ee.gh.example b/hosting/docker-compose/ee/env.ee.gh.example
index f88d53885e..445650f42b 100644
--- a/hosting/docker-compose/ee/env.ee.gh.example
+++ b/hosting/docker-compose/ee/env.ee.gh.example
@@ -204,7 +204,7 @@ AGENTA_RUNNER_TOKEN=replace-me
# ================================================================== #
# Agenta - Egress (SSRF protection)
# ================================================================== #
-# AGENTA_INSECURE_EGRESS_ALLOWED=true
+AGENTA_INSECURE_EGRESS_ALLOWED=false
# ================================================================== #
# alembic
diff --git a/hosting/docker-compose/oss/docker-compose.dev.yml b/hosting/docker-compose/oss/docker-compose.dev.yml
index 5b4d33f9aa..5662fac631 100644
--- a/hosting/docker-compose/oss/docker-compose.dev.yml
+++ b/hosting/docker-compose/oss/docker-compose.dev.yml
@@ -636,7 +636,7 @@ services:
retries: 30
start_period: 5s
- ngrok:
+ ngrok-mounts:
# === ACTIVATION =========================================== #
# Only for REMOTE sandboxes (Daytona/E2B): geesefs runs inside the cloud sandbox and
# cannot reach `seaweedfs:8333` on the compose network, so this tunnels the store to a
@@ -657,7 +657,7 @@ services:
command:
- |
if [ -z "$${NGROK_AUTHTOKEN}" ]; then
- echo "ngrok: NGROK_AUTHTOKEN unset; no tunnel (remote sandbox mounts disabled)."
+ echo "ngrok-mounts: NGROK_AUTHTOKEN unset; no tunnel (remote sandbox mounts disabled)."
exit 0
fi
printf 'version: 3\nagent:\n web_addr: 0.0.0.0:4040\n' > /tmp/ngrok.yml
@@ -678,6 +678,65 @@ services:
seaweedfs:
condition: service_healthy
+ ngrok-ingress:
+ # === ACTIVATION =========================================== #
+ # A SECOND tunnel, beside the store's, publishing THIS deployment's ingress on a public
+ # HTTPS address. It exists for the cases where something outside has to reach us: a
+ # platform posting a webhook, or an authorization server fetching a document we serve.
+ #
+ # It forwards to traefik, so every inbound route arrives on its normal path -- `/api/...`
+ # is already routed there, in the self-host compose files, and in production. So no
+ # integration needs a tunnel of its own; one endpoint serves channels, the model and MCP
+ # gateways, and anything added later.
+ #
+ # A browser redirect needs none of this: the user is already looking at this deployment,
+ # so the address that got them there is one their browser reaches.
+ #
+ # Separate from the `ngrok-mounts` service on purpose. That one publishes the object store and
+ # the runner finds it at `ngrok-mounts:4040`; keeping them apart means neither can be handed the
+ # other's URL. Costs two agent sessions -- if the plan allows only one, run a single agent
+ # with two named endpoints instead: the runner matches a tunnel by the upstream it
+ # forwards to, so that arrangement is safe too.
+ profiles:
+ - with-tunnel
+ # === IMAGE ================================================ #
+ image: ngrok/ngrok:latest
+ # === EXECUTION ============================================ #
+ # Same shape as the store tunnel: without a token ngrok would exit non-zero and, under
+ # `restart: on-failure`, loop; exit 0 instead so an unconfigured deploy stays quiet.
+ # NGROK_DOMAIN_INGRESS pins a reserved domain, which is what keeps an address we hand to a
+ # provider valid across restarts -- a rotating one invalidates every registration.
+ entrypoint: ["/bin/sh", "-c"]
+ command:
+ - |
+ if [ -z "$${NGROK_AUTHTOKEN}" ]; then
+ echo "ngrok-ingress: NGROK_AUTHTOKEN unset; no tunnel (ingress not published)."
+ exit 0
+ fi
+ DOMAIN_ARG=""
+ if [ -n "$${NGROK_DOMAIN_INGRESS}" ]; then
+ DOMAIN_ARG="--domain=$${NGROK_DOMAIN_INGRESS}"
+ fi
+ printf 'version: 3\nagent:\n web_addr: 0.0.0.0:4040\n' > /tmp/ngrok.yml
+ exec /bin/ngrok http traefik:80 $${DOMAIN_ARG} --log stdout --config /tmp/ngrok.yml
+ # === CONFIGURATION ======================================== #
+ environment:
+ NGROK_AUTHTOKEN: ${NGROK_AUTHTOKEN:-}
+ NGROK_DOMAIN_INGRESS: ${NGROK_DOMAIN_INGRESS:-}
+ # === NETWORK ============================================== #
+ networks:
+ - agenta-network
+ # Nothing reads this agent's API today; the public URL is for a human to paste into a
+ # provider's configuration. Uncomment to inspect it from the host.
+ # ports:
+ # - "4041:4040"
+ # === LIFECYCLE ============================================ #
+ # Not `always`: that would restart the intentional exit-0 above into a loop.
+ restart: on-failure
+ depends_on:
+ traefik:
+ condition: service_healthy
+
traefik:
# === IMAGE ================================================ #
image: traefik:2
@@ -745,6 +804,62 @@ services:
#
#
#
+ # Fake upstreams (D23, WP5): the only reachable targets for Checkpoint A's acceptance
+ # suite. Not profile-gated — unlike the composio and tunnel services these are ours, not a third-party
+ # dependency, so they run every deploy (entities.md §0, "the gateways have no
+ # third-party dependency to gate on").
+ mock-llm-gateway:
+ # === IMAGE ================================================ #
+ image: agenta-oss-dev-api:latest
+ # === EXECUTION ============================================ #
+ command:
+ [
+ "uvicorn",
+ "oss.src.core.gateways.llms.providers.mock.app:app",
+ "--host",
+ "0.0.0.0",
+ "--port",
+ "9091",
+ ]
+ # === NETWORK ============================================== #
+ ports:
+ - "127.0.0.1:9091:9091"
+ networks:
+ - agenta-network
+ # === LIFECYCLE ============================================ #
+ restart: always
+ healthcheck:
+ test: ["CMD", "curl", "-sf", "http://localhost:9091/health"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
+ mock-mcp-gateway:
+ # === IMAGE ================================================ #
+ image: agenta-oss-dev-api:latest
+ # === EXECUTION ============================================ #
+ command:
+ [
+ "uvicorn",
+ "oss.src.core.gateways.mcps.providers.mock.app:app",
+ "--host",
+ "0.0.0.0",
+ "--port",
+ "9092",
+ ]
+ # === NETWORK ============================================== #
+ ports:
+ - "127.0.0.1:9092:9092"
+ networks:
+ - agenta-network
+ # === LIFECYCLE ============================================ #
+ restart: always
+ healthcheck:
+ test: ["CMD", "curl", "-sf", "http://localhost:9092/health"]
+ interval: 5s
+ timeout: 5s
+ retries: 10
+
#
#
#
diff --git a/hosting/docker-compose/oss/env.oss.dev.example b/hosting/docker-compose/oss/env.oss.dev.example
index f7bf34ad0c..242900e8ca 100644
--- a/hosting/docker-compose/oss/env.oss.dev.example
+++ b/hosting/docker-compose/oss/env.oss.dev.example
@@ -223,6 +223,8 @@ AGENTA_INSECURE_EGRESS_ALLOWED=true
# ================================================================== #
# COMPOSIO_API_KEY=
# COMPOSIO_API_URL=https://backend.composio.dev/api/v3.1
+# AGENTA_MOCK_LLM_GATEWAY_URL=http://mock-llm-gateway:9091
+# AGENTA_MOCK_MCP_GATEWAY_URL=http://mock-mcp-gateway:9092
# ================================================================== #
# crisp
@@ -243,9 +245,16 @@ AGENTA_INSECURE_EGRESS_ALLOWED=true
# Generic fallback snapshot, used when DAYTONA_SNAPSHOT_CODE is unset.
# DAYTONA_SNAPSHOT=
-# Publishes the durable store on a public URL so Daytona agent sandboxes can mount it
-# (dev compose, `with-tunnel` profile).
+# ================================================================== #
+# ngrok (development tunnels)
+# ================================================================== #
+# Drives both dev tunnels (`with-tunnel` profile): `ngrok-mounts` publishes the durable store
+# so Daytona sandboxes can mount it, `ngrok-ingress` publishes this deployment so a provider
+# can post a webhook to `/api/...`. Two tunnels means two agent sessions.
# NGROK_AUTHTOKEN=
+# Pins the ingress tunnel to a reserved domain, so an address registered with a provider
+# survives a restart.
+# NGROK_DOMAIN_INGRESS=
# ================================================================== #
# docker
diff --git a/hosting/docker-compose/oss/env.oss.gh.example b/hosting/docker-compose/oss/env.oss.gh.example
index 25c279b624..b3602de563 100644
--- a/hosting/docker-compose/oss/env.oss.gh.example
+++ b/hosting/docker-compose/oss/env.oss.gh.example
@@ -204,7 +204,7 @@ AGENTA_RUNNER_TOKEN=replace-me
# ================================================================== #
# Agenta - Egress (SSRF protection)
# ================================================================== #
-# AGENTA_INSECURE_EGRESS_ALLOWED=true
+AGENTA_INSECURE_EGRESS_ALLOWED=false
# ================================================================== #
# alembic
diff --git a/hosting/kubernetes/ee/values.ee.example.yaml b/hosting/kubernetes/ee/values.ee.example.yaml
index 701297fc50..92c1c17aff 100644
--- a/hosting/kubernetes/ee/values.ee.example.yaml
+++ b/hosting/kubernetes/ee/values.ee.example.yaml
@@ -16,6 +16,7 @@ agenta:
authKey: "replace-me"
cryptKey: "replace-me"
runnerToken: "replace-me"
+ insecureEgressAllowed: false
# ================================================================== #
# Global (Bitnami subchart conventions, image pull secrets)
@@ -125,14 +126,6 @@ global:
# authEnabled: true
# cachingEnabled: true
-# ================================================================== #
-# === agenta.insecureEgressAllowed ===
-# default true (permissive, zero-config self-host); set false to harden a
-# shared/multi-tenant deployment.
-# ================================================================== #
-# agenta:
-# insecureEgressAllowed: true
-
# ================================================================== #
# === agenta.sandboxLocalAllowed ===
# default true (permissive, zero-config self-host); `local` sandbox runs unconfined
diff --git a/hosting/kubernetes/oss/values.oss.example.yaml b/hosting/kubernetes/oss/values.oss.example.yaml
index 9c5c7ecde3..b32bdb37c0 100644
--- a/hosting/kubernetes/oss/values.oss.example.yaml
+++ b/hosting/kubernetes/oss/values.oss.example.yaml
@@ -16,6 +16,7 @@ agenta:
authKey: "replace-me"
cryptKey: "replace-me"
runnerToken: "replace-me"
+ insecureEgressAllowed: false
# ================================================================== #
# === Images ===
@@ -112,14 +113,6 @@ agenta:
# authEnabled: true
# cachingEnabled: true
-# ================================================================== #
-# === agenta.insecureEgressAllowed ===
-# default true (permissive, zero-config self-host); set false to harden a
-# shared/multi-tenant deployment.
-# ================================================================== #
-# agenta:
-# insecureEgressAllowed: true
-
# ================================================================== #
# === agenta.sandboxLocalAllowed ===
# default true (permissive, zero-config self-host); `local` sandbox runs unconfined
diff --git a/hosting/railway/oss/template/template.json b/hosting/railway/oss/template/template.json
index ff7adc4e0e..c95dd87f2b 100644
--- a/hosting/railway/oss/template/template.json
+++ b/hosting/railway/oss/template/template.json
@@ -165,7 +165,8 @@
"REDIS_URI": "redis://redis.railway.internal:6379/0",
"REDIS_URI_VOLATILE": "redis://redis.railway.internal:6379/0",
"REDIS_URI_DURABLE": "redis://redis.railway.internal:6379/0",
- "SUPERTOKENS_CONNECTION_URI": "http://supertokens.railway.internal:3567"
+ "SUPERTOKENS_CONNECTION_URI": "http://supertokens.railway.internal:3567",
+ "AGENTA_INSECURE_EGRESS_ALLOWED": "false"
},
"optionalVariables": [
"COMPOSIO_API_KEY",
@@ -213,7 +214,8 @@
"AGENTA_API_INTERNAL_URL": "http://api.railway.internal:8000/api",
"REDIS_URI": "redis://redis.railway.internal:6379/0",
"REDIS_URI_VOLATILE": "redis://redis.railway.internal:6379/0",
- "REDIS_URI_DURABLE": "redis://redis.railway.internal:6379/0"
+ "REDIS_URI_DURABLE": "redis://redis.railway.internal:6379/0",
+ "AGENTA_INSECURE_EGRESS_ALLOWED": "false"
},
"optionalVariables": [
"DAYTONA_API_KEY"
@@ -243,7 +245,8 @@
"AGENTA_STORE_BUCKET": "agenta-store",
"AGENTA_STORE_SIGNING_KEY": {
"secret": "AGENTA_STORE_SIGNING_KEY"
- }
+ },
+ "AGENTA_INSECURE_EGRESS_ALLOWED": "false"
},
"optionalVariables": [
"AGENTA_API_URL",
diff --git a/sdks/python/agenta/sdk/agents/adapters/codex_settings.py b/sdks/python/agenta/sdk/agents/adapters/codex_settings.py
index b0878ba149..5c9aa3bfa6 100644
--- a/sdks/python/agenta/sdk/agents/adapters/codex_settings.py
+++ b/sdks/python/agenta/sdk/agents/adapters/codex_settings.py
@@ -71,6 +71,13 @@
# absent on subscription runs.
MANAGED_PROVIDER_ENV_KEY = "OPENAI_API_KEY"
+# OUR gateway credential (D31/D36), not a provider secret: codex's `env_http_headers` maps a header
+# NAME to an env var name and reads the value from its process environment at request time (the
+# same indirection `env_key` already uses for the bearer token), so this file never carries the raw
+# value. Must match the runner's `GATEWAY_CREDENTIALS_VALUE_ENV` (services/runner/src/engines/
+# sandbox_agent/run-plan.ts) — both sides read/write the same env var name.
+GATEWAY_CREDENTIALS_VALUE_ENV = "AGENTA_GATEWAY_CREDENTIALS_VALUE"
+
def _toml_escape(value: str) -> str:
"""Escape backslashes and double quotes for a TOML basic string."""
@@ -91,17 +98,33 @@ def _render_config_toml(scalars: Dict[str, str]) -> str:
)
-def _render_managed_provider_table() -> str:
+def _render_managed_provider_table(
+ base_url: Optional[str] = None, gateway_header: Optional[str] = None
+) -> str:
"""Render the file-free managed auth provider table (see the ``MANAGED_PROVIDER_*`` docstring).
A TOML table must follow every top-level scalar, so this is appended AFTER the scalars (which
include the ``model_provider`` pointer). The secret never appears here; only the env var name.
+
+ ``base_url`` and ``gateway_header`` carry a gateway route (D31/D36): ``base_url`` points codex
+ at the gateway instead of OpenAI's default endpoint, and ``env_http_headers`` (a codex 0.145+
+ field, verified OD14) maps the header NAME to ``GATEWAY_CREDENTIALS_VALUE_ENV`` so codex reads
+ the credential from its process env at request time, exactly like ``env_key`` above. Both
+ absent on a non-gateway connection (byte-identical to before).
"""
- return (
- f"\n[model_providers.{MANAGED_PROVIDER_ID}]\n"
- f'name = "{_toml_escape(MANAGED_PROVIDER_NAME)}"\n'
- f'env_key = "{_toml_escape(MANAGED_PROVIDER_ENV_KEY)}"\n'
- )
+ lines = [
+ f"\n[model_providers.{MANAGED_PROVIDER_ID}]\n",
+ f'name = "{_toml_escape(MANAGED_PROVIDER_NAME)}"\n',
+ f'env_key = "{_toml_escape(MANAGED_PROVIDER_ENV_KEY)}"\n',
+ ]
+ if base_url:
+ lines.append(f'base_url = "{_toml_escape(base_url)}"\n')
+ if gateway_header:
+ lines.append(
+ f'env_http_headers = {{ "{_toml_escape(gateway_header)}" = '
+ f'"{_toml_escape(GATEWAY_CREDENTIALS_VALUE_ENV)}" }}\n'
+ )
+ return "".join(lines)
def _get(source: Any, key: str) -> Any:
@@ -140,6 +163,8 @@ def build_codex_settings_files(
tool_specs: Any = None,
permission_default: PermissionMode = "allow_reads",
credential_mode: Optional[str] = None,
+ gateway_base_url: Optional[str] = None,
+ gateway_header: Optional[str] = None,
) -> List[Dict[str, str]]:
"""Build the Codex ``config.toml`` as one generic ``harnessFiles`` entry, or ``[]`` if none.
@@ -161,6 +186,10 @@ def build_codex_settings_files(
When a subscription run has nothing authored or derived either, returns ``[]`` so the runner
writes no file and that run stays byte-identical to a fileless run.
+ ``gateway_base_url``/``gateway_header`` (D31/D36) carry a gateway route onto the managed
+ provider table (see ``_render_managed_provider_table``); both are ignored on a subscription
+ run, which never renders the table at all.
+
Returns ``[{"path": ".codex/config.toml", "content": }]`` or ``[]``.
"""
managed = credential_mode != "runtime_provided"
@@ -193,5 +222,5 @@ def build_codex_settings_files(
content = _render_config_toml(scalars)
if managed:
- content += _render_managed_provider_table()
+ content += _render_managed_provider_table(gateway_base_url, gateway_header)
return [{"path": SETTINGS_PATH, "content": content}]
diff --git a/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py b/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py
index df314c9bee..ea4b13c55c 100644
--- a/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py
+++ b/sdks/python/agenta/sdk/agents/adapters/vercel/stream.py
@@ -943,10 +943,15 @@ def _error_parts(
if not isinstance(resolved_code, str) or not resolved_code:
resolved_code = AgentRunFailed.failure_code
resolved_text = _as_text(error_text)
- yield {
- "type": "data-agent-error",
- "data": {"code": resolved_code, "errorText": resolved_text},
- }
+ data: Dict[str, Any] = {"code": resolved_code, "errorText": resolved_text}
+ # The gateway's agent-actionable envelope (WP13's AgentErrorDetail), when the runner
+ # recovered one (`AgentRunFailed.error_detail`, gateway-error.ts). Additive: a caller
+ # reading only `code`/`errorText` above sees no change, and the key is omitted entirely
+ # (never `null`) when there is nothing to carry.
+ error_detail = getattr(error, "error_detail", None)
+ if error_detail:
+ data["errorDetail"] = error_detail
+ yield {"type": "data-agent-error", "data": data}
yield {"type": "error", "errorText": resolved_text}
diff --git a/sdks/python/agenta/sdk/agents/connections/__init__.py b/sdks/python/agenta/sdk/agents/connections/__init__.py
index fe89d186aa..0ef384d52e 100644
--- a/sdks/python/agenta/sdk/agents/connections/__init__.py
+++ b/sdks/python/agenta/sdk/agents/connections/__init__.py
@@ -28,6 +28,7 @@
Deployment,
Endpoint,
EnvironmentCredentialBinding,
+ GatewayCredentials,
ModelRef,
ResolvedConnection,
ResolvedCredential,
@@ -40,6 +41,7 @@
"Connection",
"Endpoint",
"EnvironmentCredentialBinding",
+ "GatewayCredentials",
"ModelRef",
"ResolvedConnection",
"ResolvedCredential",
diff --git a/sdks/python/agenta/sdk/agents/connections/endpoints.py b/sdks/python/agenta/sdk/agents/connections/endpoints.py
index aa3cfd7c76..33684413e4 100644
--- a/sdks/python/agenta/sdk/agents/connections/endpoints.py
+++ b/sdks/python/agenta/sdk/agents/connections/endpoints.py
@@ -6,7 +6,7 @@
from urllib.parse import urlparse
from .errors import InvalidConnectionConfigurationError
-from .models import Endpoint, ResolvedConnection, ResolvedCredential
+from .models import Endpoint, GatewayCredentials, ResolvedConnection, ResolvedCredential
_DIRECT_ENDPOINTS: Dict[str, str] = {
"openai": "https://api.openai.com/v1",
@@ -25,6 +25,9 @@
"GOOGLE_CLOUD_PROJECT",
"GOOGLE_CLOUD_LOCATION",
}
+# The subset of _NON_SECRET_ENV that `Endpoint.region` already carries (gateways-research/v1
+# WP24) — GOOGLE_CLOUD_PROJECT has no endpoint-row field yet, so it still rides `environment`.
+_REGION_ENV = {"AWS_REGION", "AWS_DEFAULT_REGION", "GOOGLE_CLOUD_LOCATION"}
_LOCAL_USE_ENV = {
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
@@ -34,6 +37,11 @@
}
+def direct_endpoint(provider: str) -> Optional[str]:
+ """The registered direct base URL for a provider, or None when it has none."""
+ return _DIRECT_ENDPOINTS.get(provider.lower())
+
+
def effective_endpoint(
*,
provider: str,
@@ -52,14 +60,23 @@ def effective_endpoint(
)
resolved = Endpoint(base_url=base_url)
elif deployment == "bedrock":
- region = environment.get("AWS_REGION") or environment.get("AWS_DEFAULT_REGION")
+ # The endpoint row is the authoritative source (gateways-research/v1 WP24): a
+ # region belongs behind the gateway, not recomputed from caller-side env.
+ # `environment` stays a fallback for a connection resolved with no endpoint at all.
+ region = (
+ (endpoint.region if endpoint else None)
+ or 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")
+ location = (endpoint.region if endpoint else None) or environment.get(
+ "GOOGLE_CLOUD_LOCATION"
+ )
if not location:
raise ValueError("vertex model connection requires GOOGLE_CLOUD_LOCATION")
resolved = Endpoint(
@@ -147,3 +164,59 @@ def build_resolved_connection(
endpoint=route,
input_modalities=input_modalities,
)
+
+
+def gateway_target(*, kind: str, provider: str, slug: str) -> Tuple[str, str]:
+ """The D30 ``(namespace, name)`` pair for a chosen vault candidate.
+
+ ``provider_key`` records carry no endpoint row of their own — the gateway already knows
+ the shape (D30's "generated provider set") — so they route through ``standard/{provider}``.
+ ``custom_provider`` records are a stored row (their own base URL), so they route through
+ ``custom/{slug}``.
+ """
+ if kind == "provider_key":
+ return "standard", provider.lower()
+ return "custom", slug
+
+
+def gateway_route(*, namespace: str, name: str, gateway_base_url: str) -> str:
+ """The gateway route base URL (D30): ``{gateway_base}/gateways/llms/{namespace}/{name}``.
+
+ No protocol suffix (``/v1/chat/completions``) — that is the harness's own append, the
+ same split the endpoint document already makes (entities.md §2.4).
+ """
+ return f"{gateway_base_url.rstrip('/')}/gateways/llms/{namespace}/{name}"
+
+
+def build_gateway_resolved_connection(
+ *,
+ provider: str,
+ model: str,
+ deployment: str,
+ namespace: str,
+ name: str,
+ gateway_base_url: str,
+ gateway_credentials_value: str,
+ input_modalities: Optional[List[str]] = None,
+) -> ResolvedConnection:
+ """Build a resolved connection that routes through the gateway (D36/D30/D31).
+
+ No provider secret ever lands here: ``credentials`` stays empty and ``credential_mode``
+ is ``none`` — the gateway holds the provider's secret, not the harness. Our own
+ credentials into the gateway ride ``gateway_credentials`` (``X-AG-Credentials``), never
+ ``credentials``, which stays reserved for a provider's own secret (D36).
+ """
+ return ResolvedConnection(
+ provider=provider,
+ model=model,
+ deployment=deployment,
+ credential_mode="none",
+ credentials=[],
+ endpoint=Endpoint(
+ base_url=gateway_route(
+ namespace=namespace, name=name, gateway_base_url=gateway_base_url
+ )
+ ),
+ gateway_credentials=GatewayCredentials(value=gateway_credentials_value),
+ input_modalities=input_modalities,
+ )
diff --git a/sdks/python/agenta/sdk/agents/connections/models.py b/sdks/python/agenta/sdk/agents/connections/models.py
index 338c0c9a50..ebf6cdf498 100644
--- a/sdks/python/agenta/sdk/agents/connections/models.py
+++ b/sdks/python/agenta/sdk/agents/connections/models.py
@@ -34,6 +34,13 @@
CredentialMode = Literal["env", "runtime_provided", "none"]
CredentialUsage = Literal["opaque_http", "local_use"]
+_LOOPBACK_HOSTNAMES = frozenset({"localhost", "127.0.0.1", "::1"})
+
+
+def _is_loopback(hostname: Optional[str]) -> bool:
+ return (hostname or "").strip("[]").lower() in _LOOPBACK_HOSTNAMES
+
+
# 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
# ``custom``, ``azure``, ``bedrock``, or ``vertex_ai``).
@@ -152,6 +159,36 @@ def to_wire(self) -> Dict[str, Any]:
}
+class GatewayCredentials(BaseModel):
+ """OUR credentials for the gateway, bound to the header that carries them.
+
+ Deliberately not a member of the credential union above. A :class:`ResolvedCredential`
+ carries a *provider's* secret and authenticates the gateway to that provider; this
+ authenticates the caller as us, into the gateway. The two are never interchangeable, and
+ a header-bound value has no environment variable to materialize into — widening the union
+ would give a value that validates, crosses the wire and vanishes at the materialization
+ boundary.
+ """
+
+ header: str = "X-AG-Credentials"
+ value: str = Field(repr=False)
+
+ @model_validator(mode="after")
+ def _require_header_and_value(self) -> "GatewayCredentials":
+ if not self.header.strip():
+ raise ValueError("gateway credentials require a non-empty header name")
+ if not self.value:
+ raise ValueError("gateway credentials require a non-empty value")
+ return self
+
+ @field_serializer("value", when_used="always")
+ def _mask_value(self, value: str) -> str:
+ return "**********"
+
+ def to_wire(self) -> Dict[str, str]:
+ return {"header": self.header, "value": self.value}
+
+
class ModelRef(BaseModel):
"""Model intent plus the credential connection, carried in the agent config.
@@ -227,6 +264,18 @@ class ResolvedConnection(BaseModel):
environment: Dict[str, str] = Field(default_factory=dict)
endpoint: Optional[Endpoint] = None # NON-secret connection config only
input_modalities: Optional[List[str]] = None
+ gateway_credentials: Optional[GatewayCredentials] = Field(default=None, repr=False)
+
+ def _require_effective_https(self, subject: str) -> None:
+ base_url = self.endpoint.base_url if self.endpoint else None
+ parsed = urlparse(base_url or "")
+ scheme = parsed.scheme.lower()
+ if scheme == "https" and parsed.hostname:
+ return
+ # A plaintext hop to a loopback host has no remote to leak the value to.
+ if scheme == "http" and _is_loopback(parsed.hostname):
+ return
+ raise ValueError(f"{subject} require an effective HTTPS endpoint")
@model_validator(mode="after")
def _validate_credential_route(self) -> "ResolvedConnection":
@@ -240,12 +289,9 @@ def _validate_credential_route(self) -> "ResolvedConnection":
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"
- )
+ self._require_effective_https("opaque_http model credentials")
+ if self.gateway_credentials is not None:
+ self._require_effective_https("gateway credentials")
return self
def plaintext_environment(self) -> Dict[str, str]:
@@ -255,6 +301,16 @@ def plaintext_environment(self) -> Dict[str, str]:
values[credential.binding.name] = credential.value
return values
+ def plaintext_headers(self) -> Dict[str, str]:
+ """Materialize the gateway credentials at a local execution boundary.
+
+ The header counterpart of :meth:`plaintext_environment`, and the reason the gateway
+ credentials are their own field: they have no environment variable to land in.
+ """
+ if self.gateway_credentials is None:
+ return {}
+ return {self.gateway_credentials.header: self.gateway_credentials.value}
+
def to_wire(self) -> Dict[str, Any]:
"""Serialize the consumer-owned model connection onto the trusted internal wire."""
wire: Dict[str, Any] = {
@@ -271,6 +327,8 @@ def to_wire(self) -> Dict[str, Any]:
wire["endpoint"] = endpoint_wire
if self.input_modalities is not None:
wire["modelCapabilities"] = {"inputModalities": list(self.input_modalities)}
+ if self.gateway_credentials is not None:
+ wire["gatewayCredentials"] = self.gateway_credentials.to_wire()
return wire
diff --git a/sdks/python/agenta/sdk/agents/dtos.py b/sdks/python/agenta/sdk/agents/dtos.py
index 656fbbfd17..78bbe036c7 100644
--- a/sdks/python/agenta/sdk/agents/dtos.py
+++ b/sdks/python/agenta/sdk/agents/dtos.py
@@ -1052,8 +1052,14 @@ def wire_harness_files(self) -> Dict[str, Any]:
# connection intent so an explicit ``self_managed`` (subscription) is still excluded;
# everything else defaults to managed, matching the runner's ``isManagedCodexRun``.
credential_mode: Optional[str] = None
+ gateway_base_url: Optional[str] = None
+ gateway_header: Optional[str] = None
if self.resolved_connection is not None:
credential_mode = self.resolved_connection.credential_mode
+ if self.resolved_connection.gateway_credentials is not None:
+ gateway_header = self.resolved_connection.gateway_credentials.header
+ if self.resolved_connection.endpoint is not None:
+ gateway_base_url = self.resolved_connection.endpoint.base_url
elif (
self.model_ref is not None
and self.model_ref.connection is not None
@@ -1068,6 +1074,8 @@ def wire_harness_files(self) -> Dict[str, Any]:
self.tool_specs,
self.permission_default,
credential_mode=credential_mode,
+ gateway_base_url=gateway_base_url,
+ gateway_header=gateway_header,
)
if not files:
return {}
diff --git a/sdks/python/agenta/sdk/agents/errors.py b/sdks/python/agenta/sdk/agents/errors.py
index 6de1a8d895..7d3c4d6685 100644
--- a/sdks/python/agenta/sdk/agents/errors.py
+++ b/sdks/python/agenta/sdk/agents/errors.py
@@ -2,7 +2,7 @@
from __future__ import annotations
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING, Any, Dict, Optional
from agenta.sdk.engines.running.errors import ERRORS_BASE_URL, ErrorStatus
@@ -40,12 +40,25 @@ class AgentRunnerConfigurationError(RuntimeError):
class AgentRunFailed(RuntimeError):
- """A runner-reported terminal failure with a stable machine-readable code."""
+ """A runner-reported terminal failure with a stable machine-readable code.
+
+ ``error_detail`` carries the platform's agent-actionable envelope
+ (``{code, message, retryable, next_step?, details?}``, api/AGENTS.md) when the runner
+ recovered one from a gateway data-plane refusal (`gateway-error.ts`
+ `parseGatewayErrorDetail`); absent for every other failure, unchanged from before. When
+ present, ``failure_code`` becomes ITS ``code`` (e.g. ``model_not_allowed``) instead of the
+ generic default, so a caller matching on `failure_code` gets the real cause.
+ """
failure_code: str = "agent_run_failed"
- def __init__(self, message: str) -> None:
+ def __init__(
+ self, message: str, error_detail: Optional[Dict[str, Any]] = None
+ ) -> None:
self.message = message
+ self.error_detail = error_detail
+ if error_detail and isinstance(error_detail.get("code"), str):
+ self.failure_code = error_detail["code"]
super().__init__(f"Agent run failed: {message}")
diff --git a/sdks/python/agenta/sdk/agents/mcp/resolver.py b/sdks/python/agenta/sdk/agents/mcp/resolver.py
index 5058a12d25..1c69f3109c 100644
--- a/sdks/python/agenta/sdk/agents/mcp/resolver.py
+++ b/sdks/python/agenta/sdk/agents/mcp/resolver.py
@@ -2,29 +2,96 @@
from __future__ import annotations
-from typing import Mapping, Sequence
+from typing import Mapping, Optional, Sequence
from agenta.sdk.agents.tools.models import MissingSecretPolicy
from agenta.sdk.utils.net import assert_endpoint_url_allowed
from .errors import MCPServerURLBlockedError, MissingMCPSecretError
from .interfaces import MCPSecretProvider
-from .models import MCPHeaderSecretRefs, MCPServerConfig, ResolvedMCPServer
+from .models import (
+ HeaderCredentialBinding,
+ MCPHeaderSecretRefs,
+ MCPServerConfig,
+ ResolvedMCPCredential,
+ ResolvedMCPServer,
+)
+
+# The header our own credentials ride, per D31 — same default as the LLM plane's
+# ``GatewayCredentials.header`` (connections/models.py), duplicated rather than imported
+# because that module belongs to a different package (WP12).
+_GATEWAY_CREDENTIALS_HEADER = "X-AG-Credentials"
+
+
+def _gateway_mcp_route(*, namespace: str, name: str, gateway_base_url: str) -> str:
+ """The gateway route base URL (D30): ``{gateway_base}/gateways/mcps/{namespace}/{name}``.
+
+ The MCP protocol POSTs to this URL directly; nothing is appended.
+ """
+ return f"{gateway_base_url.rstrip('/')}/gateways/mcps/{namespace}/{name}"
class MCPResolver:
+ """Resolve author-declared MCP servers.
+
+ Every server this resolver is handed is a D30 ``custom`` target (D27/D30: "a server
+ they brought"), so when a gateway is configured it routes through
+ ``custom/{server.name}`` with OUR credentials (D31) rather than dialling the author's
+ URL directly with named secrets. With no gateway configured (the offline/standalone
+ case, mirroring ``EnvConnectionResolver``/``StaticConnectionResolver`` on the LLM
+ side) it falls back to the direct dial, unchanged.
+ """
+
def __init__(
self,
*,
secret_provider: MCPSecretProvider,
missing_secret_policy: MissingSecretPolicy = MissingSecretPolicy.ERROR,
+ gateway_base_url: Optional[str] = None,
+ gateway_credentials_value: Optional[str] = None,
) -> None:
self._secret_provider = secret_provider
self._missing_secret_policy = missing_secret_policy
+ self._gateway_base_url = gateway_base_url
+ self._gateway_credentials_value = gateway_credentials_value
async def resolve(
self,
server_configs: Sequence[MCPServerConfig],
+ ) -> list[ResolvedMCPServer]:
+ if self._gateway_base_url and self._gateway_credentials_value:
+ return [
+ self._resolve_gateway(server_config) for server_config in server_configs
+ ]
+ return await self._resolve_direct(server_configs)
+
+ def _resolve_gateway(self, server_config: MCPServerConfig) -> ResolvedMCPServer:
+ """Route through the gateway: no upstream secret ever reaches the sandbox (WP13's
+ contract, on the tool side). ``policy.tools`` and the public headers pass through
+ unchanged; the author's URL and named secret refs are not used — the gateway's own
+ stored endpoint (registered under this server's ``name``) knows the real upstream."""
+ assert self._gateway_base_url is not None # narrowed by the caller above
+ assert self._gateway_credentials_value is not None
+ return ResolvedMCPServer(
+ name=server_config.name,
+ url=_gateway_mcp_route(
+ namespace="custom",
+ name=server_config.name,
+ gateway_base_url=self._gateway_base_url,
+ ),
+ headers=dict(server_config.connection.headers),
+ credentials=[
+ ResolvedMCPCredential(
+ binding=HeaderCredentialBinding(name=_GATEWAY_CREDENTIALS_HEADER),
+ value=self._gateway_credentials_value,
+ )
+ ],
+ policy=server_config.policy,
+ )
+
+ async def _resolve_direct(
+ self,
+ server_configs: Sequence[MCPServerConfig],
) -> list[ResolvedMCPServer]:
secret_names = sorted(
{
diff --git a/sdks/python/agenta/sdk/agents/platform/__init__.py b/sdks/python/agenta/sdk/agents/platform/__init__.py
index 29291de5b4..16e51b733a 100644
--- a/sdks/python/agenta/sdk/agents/platform/__init__.py
+++ b/sdks/python/agenta/sdk/agents/platform/__init__.py
@@ -2,8 +2,8 @@
This package holds the implementations that reach the Agenta backend over HTTP: the
:class:`PlatformConnection` (base URL + per-call auth), the gateway tool resolver, the
-named-secret provider, and the provider-key fetch, plus the three resolution entrypoints
-(:func:`resolve_tools`, :func:`resolve_mcp`, :func:`resolve_secrets`). The pure resolution
+named-secret provider, and the connection resolver, plus the resolution entrypoints
+(:func:`resolve_tools`, :func:`resolve_mcp`, :func:`resolve_connection`). The pure resolution
framework and the neutral models stay in ``agenta.sdk.agents.tools``; only the platform-bound
code lives here.
@@ -17,12 +17,8 @@
from .gateway import AgentaGatewayToolResolver
from .op_catalog import PLATFORM_OPS, PlatformOp, get_platform_op
from .platform_tools import AgentaPlatformToolResolver
-from .resolve import resolve_connection, resolve_mcp, resolve_secrets, resolve_tools
-from .secrets import (
- AgentaNamedSecretProvider,
- resolve_named_secrets,
- resolve_provider_keys,
-)
+from .resolve import resolve_connection, resolve_mcp, resolve_tools
+from .secrets import AgentaNamedSecretProvider, resolve_named_secrets
from .workflow import AgentaWorkflowToolResolver
__all__ = [
@@ -37,9 +33,7 @@
"PLATFORM_OPS",
"get_platform_op",
"resolve_named_secrets",
- "resolve_provider_keys",
"resolve_tools",
"resolve_mcp",
- "resolve_secrets",
"resolve_connection",
]
diff --git a/sdks/python/agenta/sdk/agents/platform/connection.py b/sdks/python/agenta/sdk/agents/platform/connection.py
index 0f943346ca..c3e7dc5bd9 100644
--- a/sdks/python/agenta/sdk/agents/platform/connection.py
+++ b/sdks/python/agenta/sdk/agents/platform/connection.py
@@ -127,6 +127,15 @@ def base_url(self) -> Optional[str]:
"""The backend base URL: explicit, else derived from SDK config/env. ``None`` if unset."""
return self._base_url or _derive_base_url()
+ def gateway_base_url(self) -> Optional[str]:
+ """The base URL the gateway route is composed against (D30's ``{gateway_base}``).
+
+ The gateways mount inside the API app under ``/gateways/...``, so this IS the API
+ base. It stays a named accessor rather than a concatenation at each call site: a
+ gateway hosted separately would change this body and nothing else.
+ """
+ return self.base_url()
+
def authorization(self) -> Optional[str]:
"""The caller's Authorization: explicit, else the per-request context, else env key."""
return self._authorization or _derive_authorization()
diff --git a/sdks/python/agenta/sdk/agents/platform/connections.py b/sdks/python/agenta/sdk/agents/platform/connections.py
index c3f01a45c2..42f7e76451 100644
--- a/sdks/python/agenta/sdk/agents/platform/connections.py
+++ b/sdks/python/agenta/sdk/agents/platform/connections.py
@@ -24,13 +24,19 @@
HARNESS_CONNECTION_CAPABILITIES,
PROVIDER_ENV_VARS,
)
-from ..connections.endpoints import build_resolved_connection
+from ..connections.endpoints import (
+ _REGION_ENV,
+ build_gateway_resolved_connection,
+ build_resolved_connection,
+ gateway_target,
+)
from ..connections import (
AmbiguousConnectionError,
ConnectionNotFoundError,
ConnectionResolutionError,
EndpointResolutionError,
Endpoint,
+ InvalidConnectionConfigurationError,
MissingCredentialError,
MissingProviderError,
ModelRef,
@@ -339,7 +345,9 @@ def endpoint_resolution_error(self) -> EndpointResolutionError:
)
def resolved_env(self, provider: str) -> Dict[str, str]:
- env = dict(self.env)
+ # Region is addressing, not a credential: it reaches the gateway on the endpoint row,
+ # not by riding this dict to be discarded downstream (WP24).
+ env = {k: v for k, v in self.env.items() if k not in _REGION_ENV}
env_var = _provider_env_var(provider) or _provider_env_var(self.provider)
# Bedrock's key is a bearer token with its own channel below — never the family's
# API-key env var (a bedrock key in ANTHROPIC_API_KEY would mis-auth the direct API).
@@ -399,7 +407,11 @@ def _custom_provider_candidate(
return None
env = _normalized_extra_env(extras)
- region = env.get("AWS_REGION") or env.get("AWS_DEFAULT_REGION")
+ region = (
+ env.get("AWS_REGION")
+ or env.get("AWS_DEFAULT_REGION")
+ or env.get("GOOGLE_CLOUD_LOCATION")
+ )
raw_url = _stripped(settings.get("url"))
endpoint_blocked = False
if raw_url:
@@ -555,7 +567,12 @@ def _choose_named(
def _resolve_from_secrets(
- *, secrets: Sequence[Any], model: ModelRef, harness: Optional[str] = None
+ *,
+ secrets: Sequence[Any],
+ model: ModelRef,
+ harness: Optional[str] = None,
+ gateway_base_url: Optional[str] = None,
+ gateway_credentials_value: Optional[str] = None,
) -> ResolvedConnection:
connection = model.connection
# A bare Claude alias (haiku/sonnet/opus + [1m]) or a dated claude-* id is unambiguously
@@ -587,6 +604,14 @@ def _resolve_from_secrets(
else _choose_default(candidates, model, harness)
)
provider = chosen.resolved_provider(model)
+ if chosen.deployment in {"vertex", "vertex_ai"} and chosen.env.get(
+ "GOOGLE_CLOUD_API_KEY"
+ ):
+ # Same rejection `build_resolved_connection` made for the offline path: out of scope
+ # regardless of routing.
+ raise InvalidConnectionConfigurationError(
+ "Vertex API-key authentication is not supported by the agent connection contract"
+ )
# A chosen custom connection must carry a usable base URL. Failing here (rather than
# returning endpoint=None) keeps the harness from falling back to a provider default and
# silently ignoring the user's routing choice (design Decision 4). The error names the slug
@@ -599,13 +624,25 @@ def _resolve_from_secrets(
resolved_model = chosen.selected_model_id(model)
if not env:
raise MissingCredentialError(provider=provider, slug=chosen.slug)
- return build_resolved_connection(
+
+ # The gateway holds the provider's secret now (D4/D36): the connected path routes through
+ # it rather than injecting `env` into the harness. `env`'s only remaining job above is the
+ # fail-loud emptiness check; the value itself never leaves this function.
+ if not gateway_base_url or not gateway_credentials_value:
+ raise ConnectionResolutionError(
+ "no Agenta backend configured for gateway connection resolution"
+ )
+ namespace, name = gateway_target(
+ kind=chosen.kind, provider=provider, slug=chosen.slug
+ )
+ return build_gateway_resolved_connection(
provider=provider,
model=resolved_model,
deployment=chosen.deployment,
- credential_mode="env",
- values=env,
- endpoint=chosen.endpoint,
+ namespace=namespace,
+ name=name,
+ gateway_base_url=gateway_base_url,
+ gateway_credentials_value=gateway_credentials_value,
# A miss means workspace-only downstream; do not guess.
input_modalities=model_input_modalities(
harness, resolved_model, provider=provider
@@ -642,11 +679,16 @@ async def resolve(
"no Agenta backend configured for connection resolution"
)
+ # Resolved once and reused for both the request header and the gateway-credentials
+ # field, so they cannot diverge across the two reads (the same precedent as the
+ # gateway tool resolver's ToolCallback).
+ authorization = self._connection.authorization()
+
try:
async with httpx.AsyncClient(timeout=self._connection.timeout) as client:
response = await client.get(
f"{api_base}/secrets/",
- headers=self._connection.headers(),
+ headers=self._connection.headers(authorization=authorization),
)
except Exception as exc: # pylint: disable=broad-except
log.warning(
@@ -665,12 +707,33 @@ async def resolve(
data = response.json() or []
if not isinstance(data, list):
raise ConnectionResolutionError("connection resolution returned a non-list")
- return _resolve_from_secrets(secrets=data, model=model, harness=context.harness)
+ return _resolve_from_secrets(
+ secrets=data,
+ model=model,
+ harness=context.harness,
+ gateway_base_url=self._connection.gateway_base_url(),
+ gateway_credentials_value=authorization,
+ )
class _StaticSecretsResolver:
- def __init__(self, secrets: Sequence[Any]) -> None:
+ """The offline stand-in for the live ``GET /secrets/`` fetch (self_managed short-circuit,
+ and a recorded-replay test's substitute for the vault). ``gateway_base_url`` /
+ ``gateway_credentials_value`` default to ``None``, which is correct for the self_managed
+ caller (it never reaches the gateway-building branch); a caller resolving an ``agenta``
+ connection offline must supply both, the same as :class:`VaultConnectionResolver` does.
+ """
+
+ def __init__(
+ self,
+ secrets: Sequence[Any],
+ *,
+ gateway_base_url: Optional[str] = None,
+ gateway_credentials_value: Optional[str] = None,
+ ) -> None:
self._secrets = secrets
+ self._gateway_base_url = gateway_base_url
+ self._gateway_credentials_value = gateway_credentials_value
async def resolve(
self,
@@ -679,5 +742,9 @@ async def resolve(
context: RuntimeAuthContext,
) -> ResolvedConnection:
return _resolve_from_secrets(
- secrets=self._secrets, model=model, harness=context.harness
+ secrets=self._secrets,
+ model=model,
+ harness=context.harness,
+ gateway_base_url=self._gateway_base_url,
+ gateway_credentials_value=self._gateway_credentials_value,
)
diff --git a/sdks/python/agenta/sdk/agents/platform/resolve.py b/sdks/python/agenta/sdk/agents/platform/resolve.py
index 724c16d98d..d4fa79be03 100644
--- a/sdks/python/agenta/sdk/agents/platform/resolve.py
+++ b/sdks/python/agenta/sdk/agents/platform/resolve.py
@@ -1,7 +1,7 @@
-"""The three resolution entrypoints, composed over the SDK framework + platform adapters.
+"""The resolution entrypoints, composed over the SDK framework + platform adapters.
-Deliberately three separate functions, not one aggregate: a caller resolves only what it
-needs. Each defaults to the Agenta-platform-backed adapters (the connected path) but accepts
+Deliberately separate functions, not one aggregate: a caller resolves only what it needs.
+Each defaults to the Agenta-platform-backed adapters (the connected path) but accepts
injected adapters, so an offline standalone user can pass an env-backed secret provider and
no gateway resolver, and a test can pass fakes.
@@ -9,12 +9,9 @@
specs). Code-tool named secrets are resolved through the secret provider here.
- ``resolve_mcp`` -> resolved MCP servers (named secrets injected). No deployment flag gate
here; gating MCP on/off is the caller's concern.
-- ``resolve_secrets`` -> the harness/model provider keys (``agenta.sdk.agents.platform``'s
- ``resolve_provider_keys``), optional by design. Deprecated: the model-blind whole-vault dump,
- superseded by ``resolve_connection`` (one connection, fail-loud); kept until the service
- migrates onto the new resolver.
- ``resolve_connection`` -> one least-privilege ``ResolvedConnection`` for a single ``ModelRef``,
- via the secrets-backed ``VaultConnectionResolver`` (fail-loud).
+ via the secrets-backed ``VaultConnectionResolver`` (fail-loud), routed through the gateway
+ and carrying no provider secret (D36/D30).
"""
from __future__ import annotations
@@ -45,14 +42,14 @@
WorkflowToolResolver,
)
+from .connection import PlatformConnection
from .connections import VaultConnectionResolver
from .gateway import AgentaGatewayToolResolver
from .platform_tools import AgentaPlatformToolResolver
from .secrets import AgentaNamedSecretProvider
-from .secrets import resolve_provider_keys as resolve_secrets
from .workflow import AgentaWorkflowToolResolver
-__all__ = ["resolve_tools", "resolve_mcp", "resolve_secrets", "resolve_connection"]
+__all__ = ["resolve_tools", "resolve_mcp", "resolve_connection"]
async def resolve_tools(
@@ -84,11 +81,21 @@ async def resolve_mcp(
*,
secret_provider: Optional[ToolSecretProvider] = None,
missing_secret_policy: MissingSecretPolicy = MissingSecretPolicy.ERROR,
+ connection: Optional[PlatformConnection] = None,
) -> List[ResolvedMCPServer]:
- """Resolve MCP server declarations (named secrets injected). Caller decides whether to call."""
+ """Resolve MCP server declarations. Caller decides whether to call.
+
+ Routes through the gateway (D36/D30/D31) when a backend is configured: every declared
+ server becomes a `custom/{name}` gateway route carrying OUR credentials, and no named
+ secret is fetched. With no backend configured (the offline/standalone case) it falls
+ back to the direct dial with named secrets injected, unchanged.
+ """
+ platform_connection = connection or PlatformConnection()
return await MCPResolver(
secret_provider=secret_provider or AgentaNamedSecretProvider(),
missing_secret_policy=missing_secret_policy,
+ gateway_base_url=platform_connection.gateway_base_url(),
+ gateway_credentials_value=platform_connection.authorization(),
).resolve(parse_mcp_server_configs(mcp_servers))
diff --git a/sdks/python/agenta/sdk/agents/platform/secrets.py b/sdks/python/agenta/sdk/agents/platform/secrets.py
index 2ade52aca4..8a6559a8ab 100644
--- a/sdks/python/agenta/sdk/agents/platform/secrets.py
+++ b/sdks/python/agenta/sdk/agents/platform/secrets.py
@@ -1,14 +1,15 @@
"""Agenta-platform-backed secret resolution.
-Two distinct vault reads, both best-effort (an outage returns empty rather than failing the
-run, since a project with no secret-bearing tools still runs):
+`resolve_named_secrets` (`GET /secrets/{slug}`): named secret values for code-tool and MCP
+environments, resolved by explicit slug. Best-effort (an outage returns empty rather than
+failing the run, since a project with no secret-bearing tools still runs). Pairs with the
+resolver's `MissingSecretPolicy.ERROR`, so a tool whose declared secret is absent then
+hard-fails.
-- `resolve_named_secrets` (`GET /secrets/{slug}`): named secret values for code-tool and
- MCP environments, resolved by explicit slug. Pairs with the resolver's
- `MissingSecretPolicy.ERROR`, so a tool whose declared secret is absent then hard-fails.
-- `resolve_provider_keys` (`GET /secrets/`): the project's LLM provider keys, mapped to the
- env vars a harness reads. Optional by design: when the vault has none, the harness falls
- back to its own login/OAuth, so self-managed Pi/Claude sidecars keep working.
+The project's LLM provider keys (the model-blind whole-vault dump this module used to also
+expose as `resolve_provider_keys`) are gone: the agent resolves exactly one least-privilege
+connection per run via `resolve_connection` / `VaultConnectionResolver`, which routes through
+the gateway and injects no provider secret into the harness (D36/D30).
Logs never include secret names or values, only counts.
"""
@@ -22,7 +23,6 @@
from agenta.sdk.utils.logging import get_module_logger
-from ..capabilities import PROVIDER_ENV_VARS
from .connection import PlatformConnection
log = get_module_logger(__name__)
@@ -92,59 +92,3 @@ def __init__(self, connection: Optional[PlatformConnection] = None) -> None:
async def get_many(self, names: Sequence[str]) -> Mapping[str, str]:
return await resolve_named_secrets(names, connection=self._connection)
-
-
-# Canonical map lives in capabilities.py; this alias keeps the local name callers already use.
-_PROVIDER_ENV_VARS = PROVIDER_ENV_VARS
-
-
-async def resolve_provider_keys(
- *,
- connection: Optional[PlatformConnection] = None,
-) -> Dict[str, str]:
- """Fetch the project vault's provider keys as ``{ENV_VAR: key}``. Best-effort, optional.
-
- Empty when the vault has none, in which case the harness falls back to its own
- login/OAuth (self-managed Pi/Claude sidecars), so absence is valid, never an error.
-
- DEPRECATED: this is the model-blind whole-vault dump (it injects *every* provider key the
- project holds, ignoring which model/connection the run actually uses, and never reads
- ``custom_provider`` secrets). It is superseded by
- :func:`agenta.sdk.agents.platform.resolve_connection` /
- :class:`agenta.sdk.agents.platform.VaultConnectionResolver`, which resolve exactly one
- least-privilege connection and fail loud. The agent ``/invoke`` path no longer calls it —
- ``services/oss/src/agent/app.py`` resolves one connection via ``resolve_connection``. It is
- kept callable only for the deprecated re-export in ``services/oss/src/agent/secrets.py`` and
- its integration test; removing both (and this function) is Slice 3.
- """
- connection = connection or PlatformConnection()
- api_base = connection.base_url()
- if not api_base:
- return {}
-
- try:
- async with httpx.AsyncClient(timeout=connection.timeout) as client:
- response = await client.get(
- f"{api_base}/secrets/", headers=connection.headers()
- )
- if response.status_code >= 400:
- log.warning("agent: vault secrets fetch HTTP %s", response.status_code)
- return {}
- secrets = response.json() or []
- except Exception: # pylint: disable=broad-except
- log.warning("agent: vault secrets fetch failed", exc_info=True)
- return {}
-
- env: Dict[str, str] = {}
- for secret in secrets:
- if not isinstance(secret, dict) or secret.get("kind") != "provider_key":
- continue
- data = secret.get("data") or {}
- kind = str(data.get("kind", "")).lower()
- env_var = _PROVIDER_ENV_VARS.get(kind)
- key = (data.get("provider") or {}).get("key")
- if env_var and key:
- env.setdefault(env_var, key)
- elif kind and not env_var:
- log.warning("agent: vault provider kind %r has no known env var", kind)
- return env
diff --git a/sdks/python/agenta/sdk/agents/utils/wire.py b/sdks/python/agenta/sdk/agents/utils/wire.py
index e485fcedd3..c13b47a613 100644
--- a/sdks/python/agenta/sdk/agents/utils/wire.py
+++ b/sdks/python/agenta/sdk/agents/utils/wire.py
@@ -182,7 +182,10 @@ def result_from_wire(data: Dict[str, Any]) -> AgentResult:
"""
data = get_active_redactor().redact_json(data, sink="runner_result")
if not data.get("ok"):
- raise AgentRunFailed(sanitize_runner_error(data.get("error")))
+ raise AgentRunFailed(
+ sanitize_runner_error(data.get("error")),
+ error_detail=data.get("errorDetail"),
+ )
messages: List[Message] = []
for raw in data.get("messages") or []:
diff --git a/sdks/python/agenta/sdk/engines/running/handlers.py b/sdks/python/agenta/sdk/engines/running/handlers.py
index 40b6ed82d9..dbb5d93d56 100644
--- a/sdks/python/agenta/sdk/engines/running/handlers.py
+++ b/sdks/python/agenta/sdk/engines/running/handlers.py
@@ -3,19 +3,17 @@
import math
import os
import re
-import socket
-import ipaddress
import traceback
from inspect import isawaitable
from difflib import SequenceMatcher
from json import dumps, loads
from typing import Any, Dict, List, Optional, Union, Tuple
-from urllib.parse import urlparse, urlunparse
import httpx
from pydantic import BaseModel, Field
+from agenta.sdk.utils import net
from agenta.sdk.utils.constants import TRUTHY
from agenta.sdk.utils.logging import get_module_logger
from agenta.sdk.utils.lazy import (
@@ -79,7 +77,7 @@
or os.getenv("AGENTA_SERVICES_HOOK_ALLOW_INSECURE")
or os.getenv("AGENTA_WEBHOOKS_ALLOW_INSECURE")
or os.getenv("AGENTA_WEBHOOK_ALLOW_INSECURE")
- or "false"
+ or "true"
).lower() in TRUTHY
if not _HOOK_ALLOW_INSECURE:
@@ -90,76 +88,15 @@
)
-def _is_blocked_ip(ip: ipaddress._BaseAddress) -> bool:
- if _HOOK_ALLOW_INSECURE:
- return False
- return (
- ip.is_private
- or ip.is_loopback
- or ip.is_link_local
- or ip.is_reserved
- or ip.is_multicast
- or ip.is_unspecified
- )
-
-
def _validate_webhook_url(url: str) -> str:
- """Validate `url` and resolve it to a single blocked-range-checked literal IP.
-
- Resolves once here; callers must connect to the returned literal IP (not
- re-resolve the hostname) so a DNS-rebind between validation and send cannot
- reach an internal host.
+ """Resolve-once, block-range-checked literal IP, under this handler's own insecure-flag
+ precedent (see `_HOOK_ALLOW_INSECURE` above). Guard logic lives once in `agenta.sdk.utils.net`.
"""
- if not url:
- raise ValueError("Webhook URL is required.")
-
- parsed = urlparse(url)
- scheme = parsed.scheme.lower()
- if scheme not in {"http", "https"}:
- raise ValueError("Webhook URL must use http or https.")
- if scheme == "http" and not _HOOK_ALLOW_INSECURE:
- raise ValueError("Webhook URL must use https.")
- if not parsed.netloc:
- raise ValueError("Webhook URL must include a host.")
- if parsed.username or parsed.password:
- raise ValueError("Webhook URL must not include credentials.")
-
- hostname = (parsed.hostname or "").lower()
- if not hostname:
- raise ValueError("Webhook URL must include a valid hostname.")
- if hostname in {"localhost", "localhost.localdomain"} and not _HOOK_ALLOW_INSECURE:
- raise ValueError("Webhook URL hostname is not allowed.")
-
- try:
- ip = ipaddress.ip_address(hostname)
- except ValueError:
- ip = None
-
- if ip is not None:
- if _is_blocked_ip(ip):
- raise ValueError("Webhook URL resolves to a blocked IP range.")
- return str(ip)
-
- try:
- addresses = [
- ipaddress.ip_address(info[4][0])
- for info in socket.getaddrinfo(hostname, None)
- ]
- except socket.gaierror as exc:
- raise ValueError("Webhook URL hostname could not be resolved.") from exc
-
- if not addresses or any(_is_blocked_ip(addr) for addr in addresses):
- raise ValueError("Webhook URL resolves to a blocked IP range.")
-
- return str(addresses[0])
+ return net.validate_endpoint_url(url, allow_insecure=_HOOK_ALLOW_INSECURE)
def _pin_webhook_url(url: str, resolved_ip: str) -> Tuple[str, str]:
- """Swap the URL's host for the literal validated IP; keep hostname for Host/SNI."""
- parsed = urlparse(url)
- host_literal = f"[{resolved_ip}]" if ":" in resolved_ip else resolved_ip
- pinned_netloc = f"{host_literal}:{parsed.port}" if parsed.port else host_literal
- return urlunparse(parsed._replace(netloc=pinned_netloc)), parsed.hostname or ""
+ return net.pin_url_to_ip(url, resolved_ip)
async def _compute_embedding(openai: Any, model: str, input: str) -> List[float]:
diff --git a/sdks/python/agenta/sdk/utils/net.py b/sdks/python/agenta/sdk/utils/net.py
index 7a86afaa68..6c6e8c775c 100644
--- a/sdks/python/agenta/sdk/utils/net.py
+++ b/sdks/python/agenta/sdk/utils/net.py
@@ -1,13 +1,15 @@
"""Shared SSRF guard for outbound URLs configured by tenants (custom-provider endpoints, etc).
-Mirrors api/oss/src/core/webhooks/utils.py and engines/running/handlers.py's
-_validate_webhook_url; unify these three if a clean shared package ever spans API + SDK.
+Mirrors api/oss/src/core/webhooks/utils.py's range logic; the SDK ships to users so it
+cannot import the API package, hence this separate copy. engines/running/handlers.py
+delegates its webhook guard here rather than duplicating the checks.
"""
import ipaddress
import os
import socket
-from urllib.parse import urlparse
+from typing import Optional, Tuple
+from urllib.parse import urlparse, urlunparse
from agenta.sdk.utils.constants import TRUTHY
from agenta.sdk.utils.logging import get_module_logger
@@ -20,7 +22,7 @@
or os.getenv("AGENTA_CUSTOM_PROVIDER_ALLOW_INSECURE")
or os.getenv("AGENTA_WEBHOOKS_ALLOW_INSECURE")
or os.getenv("AGENTA_WEBHOOK_ALLOW_INSECURE")
- or "false"
+ or "true"
).lower() in TRUTHY
if not _ALLOW_INSECURE:
@@ -31,8 +33,10 @@
)
-def _is_blocked_ip(ip: ipaddress._BaseAddress) -> bool:
- if _ALLOW_INSECURE:
+def _is_blocked_ip(
+ ip: ipaddress._BaseAddress, *, allow_insecure: Optional[bool] = None
+) -> bool:
+ if _ALLOW_INSECURE if allow_insecure is None else allow_insecure:
return False
return (
ip.is_private
@@ -57,7 +61,7 @@ def assert_endpoint_url_allowed(url: str) -> None:
validate_endpoint_url(url)
-def validate_endpoint_url(url: str) -> str:
+def validate_endpoint_url(url: str, *, allow_insecure: Optional[bool] = None) -> str:
"""Validate `url` and resolve it to a blocked-range-checked literal IP.
For tenant-configured endpoints this process connects to directly: the caller MUST connect
@@ -65,7 +69,12 @@ def validate_endpoint_url(url: str) -> str:
and send cannot reach an internal host. Raises ValueError on anything private/loopback/
reserved by default. For a validate-only config gate (no in-process connect), use
`assert_endpoint_url_allowed` instead.
+
+ `allow_insecure` defaults to this module's own env-resolved flag; pass it explicitly to
+ reuse this guard under a caller's own policy (e.g. a different env-var precedence).
"""
+ insecure = _ALLOW_INSECURE if allow_insecure is None else allow_insecure
+
if not url:
raise ValueError("URL is required.")
@@ -73,7 +82,7 @@ def validate_endpoint_url(url: str) -> str:
scheme = parsed.scheme.lower()
if scheme not in {"http", "https"}:
raise ValueError("URL must use http or https.")
- if scheme == "http" and not _ALLOW_INSECURE:
+ if scheme == "http" and not insecure:
raise ValueError("URL must use https.")
if not parsed.netloc:
raise ValueError("URL must include a host.")
@@ -83,7 +92,7 @@ def validate_endpoint_url(url: str) -> str:
hostname = (parsed.hostname or "").lower()
if not hostname:
raise ValueError("URL must include a valid hostname.")
- if hostname in {"localhost", "localhost.localdomain"} and not _ALLOW_INSECURE:
+ if hostname in {"localhost", "localhost.localdomain"} and not insecure:
raise ValueError("URL hostname is not allowed.")
try:
@@ -92,7 +101,7 @@ def validate_endpoint_url(url: str) -> str:
ip = None
if ip is not None:
- if _is_blocked_ip(ip):
+ if _is_blocked_ip(ip, allow_insecure=insecure):
raise ValueError("URL resolves to a blocked IP range.")
return str(ip)
@@ -104,7 +113,17 @@ def validate_endpoint_url(url: str) -> str:
except socket.gaierror as exc:
raise ValueError("URL hostname could not be resolved.") from exc
- if not addresses or any(_is_blocked_ip(addr) for addr in addresses):
+ if not addresses or any(
+ _is_blocked_ip(addr, allow_insecure=insecure) for addr in addresses
+ ):
raise ValueError("URL resolves to a blocked IP range.")
return str(addresses[0])
+
+
+def pin_url_to_ip(url: str, resolved_ip: str) -> Tuple[str, str]:
+ """Swap the URL's host for the literal validated IP; keep hostname for Host/SNI."""
+ parsed = urlparse(url)
+ host_literal = f"[{resolved_ip}]" if ":" in resolved_ip else resolved_ip
+ pinned_netloc = f"{host_literal}:{parsed.port}" if parsed.port else host_literal
+ return urlunparse(parsed._replace(netloc=pinned_netloc)), parsed.hostname or ""
diff --git a/sdks/python/oss/tests/pytest/acceptance/agents/__init__.py b/sdks/python/oss/tests/pytest/acceptance/agents/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/sdks/python/oss/tests/pytest/acceptance/agents/test_mcp_gateway_routing_acceptance.py b/sdks/python/oss/tests/pytest/acceptance/agents/test_mcp_gateway_routing_acceptance.py
new file mode 100644
index 0000000000..3594b20617
--- /dev/null
+++ b/sdks/python/oss/tests/pytest/acceptance/agents/test_mcp_gateway_routing_acceptance.py
@@ -0,0 +1,133 @@
+"""Acceptance: an author-declared MCP server resolves to a gateway route (specs-wp15.md).
+
+Mirrors `api/oss/tests/pytest/acceptance/gateways/test_mcp_gateway_proxy_acceptance.py`,
+one layer up: this proves the SDK's `resolve_mcp` (the runner's caller) produces a
+`connection.url`/`connection.credentials` pair that actually reaches the mock upstream
+through the gateway, with no upstream server token anywhere in the resolved output.
+
+Needs a real deployment_kind (api/AGENTS.md's test-layer rule) with WP5's mock MCP
+upstream reachable at `mock-mcp-gateway:9092`. Run it with the stack up:
+
+ load-env hosting/docker-compose/ee/.env.ee.dev
+ bash hosting/docker-compose/run.sh --ee --dev --build
+ cd sdks/python && pytest oss/tests/pytest/acceptance/agents -m acceptance
+
+Audit-event assertion (WP4) is intentionally not made here: this branch is cut from
+WP13's wire commit, before WP4's emission lands, so there is nothing to assert yet.
+"""
+
+from __future__ import annotations
+
+from uuid import uuid4
+
+import pytest
+import requests
+
+from agenta.sdk.agents.platform import PlatformConnection, resolve_mcp
+
+pytestmark = [pytest.mark.acceptance]
+
+# Compose service name and port WP5 owns; the mock speaks Streamable HTTP in JSON mode
+# at the root path (see the sibling api-layer acceptance test).
+_MOCK_BASE_URL = "http://mock-mcp-gateway:9092/"
+
+
+class _EmptySecrets:
+ async def get_many(self, names):
+ raise AssertionError(
+ "gateway-routed resolution must never fetch a named secret"
+ )
+
+
+@pytest.fixture
+def custom_mcp_endpoint(e2e_account):
+ """Register a `custom` MCP endpoint pointing at the mock, matching this server's name."""
+ slug = f"wp15-acceptance-{uuid4().hex[:8]}"
+ response = requests.post(
+ f"{e2e_account['api_url']}/gateways/mcps/endpoints/",
+ headers={"Authorization": e2e_account["credentials"]},
+ json={
+ "endpoint": {
+ "slug": slug,
+ "auth_mode": "none", # the mock needs no secret (D23)
+ "secret_id": None,
+ "data": {"route": {"base_url": _MOCK_BASE_URL}},
+ }
+ },
+ timeout=30,
+ )
+ response.raise_for_status()
+ endpoint = response.json()["endpoint"]
+ yield endpoint
+ requests.delete(
+ f"{e2e_account['api_url']}/gateways/mcps/endpoints/{endpoint['id']}",
+ headers={"Authorization": e2e_account["credentials"]},
+ timeout=30,
+ )
+
+
+async def test_resolved_mcp_server_carries_the_gateway_route_and_our_credentials(
+ e2e_account, custom_mcp_endpoint
+):
+ connection = PlatformConnection(
+ base_url=e2e_account["api_url"], authorization=e2e_account["credentials"]
+ )
+ slug = custom_mcp_endpoint["slug"]
+
+ resolved = await resolve_mcp(
+ [
+ {
+ "name": slug,
+ # The author's own URL and secret ref are deliberately wrong/unreachable:
+ # a gateway-routed resolution must never use them (`_EmptySecrets` proves
+ # no named-secret lookup happens either).
+ "connection": {
+ "type": "http",
+ "url": "https://placeholder.invalid/mcp",
+ "credentials": {
+ "type": "header_secret_refs",
+ "headers": {"Authorization": "unused-secret-ref"},
+ },
+ },
+ }
+ ],
+ secret_provider=_EmptySecrets(),
+ connection=connection,
+ )
+
+ assert len(resolved) == 1
+ server = resolved[0]
+ assert server.url == f"{e2e_account['api_url']}/gateways/mcps/custom/{slug}"
+ assert [c.binding.name for c in server.credentials] == ["X-AG-Credentials"]
+ # No upstream server token, ours or theirs, appears anywhere in the resolved output.
+ assert "unused-secret-ref" not in server.model_dump_json()
+ assert "placeholder.invalid" not in server.model_dump_json()
+
+
+async def test_a_tool_call_through_the_resolved_route_reaches_the_mock_upstream(
+ e2e_account, custom_mcp_endpoint
+):
+ connection = PlatformConnection(
+ base_url=e2e_account["api_url"], authorization=e2e_account["credentials"]
+ )
+ slug = custom_mcp_endpoint["slug"]
+
+ resolved = await resolve_mcp(
+ [{"name": slug, "connection": {"type": "http", "url": "https://unused/mcp"}}],
+ secret_provider=_EmptySecrets(),
+ connection=connection,
+ )
+ server = resolved[0]
+
+ # The exact request the runner sends (`toAcpMcpServers`): the gateway URL, our
+ # credential in its bound header, no upstream secret anywhere in the request.
+ headers = {c.binding.name: c.value for c in server.credentials}
+ response = requests.post(
+ server.url,
+ headers={**headers, "MCP-Method": "tools/list"},
+ json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
+ timeout=30,
+ )
+ response.raise_for_status()
+ tool_names = {tool["name"] for tool in response.json()["result"]["tools"]}
+ assert {"echo", "fail", "slow"} <= tool_names
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 4e0f521702..b1fc0cfa04 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
@@ -79,14 +79,25 @@ def _template_from_recording(rec: dict) -> AgentTemplate:
return AgentTemplate.from_params(params)
+_GATEWAY_BASE = "https://gateway.example.com"
+_GATEWAY_CREDENTIALS = "Secret replay-token"
+
+
async def _resolve_connection(template: AgentTemplate, rec: dict):
"""Resolve the connection over the recorded (redacted) vault secret, through the same
``_default_resolve_session_connection`` the handler calls -- only the live ``GET /secrets/``
- fetch is swapped for a static list, which is the whole point of an offline replay."""
+ fetch is swapped for a static list, which is the whole point of an offline replay. The
+ gateway base URL and caller credentials stand in for what ``VaultConnectionResolver``
+ would derive from the live ``PlatformConnection`` (WP12: the connected path routes
+ through the gateway rather than injecting the vault's provider secret)."""
model_ref = _agent_model_ref(template)
assert model_ref is not None
ctx = RuntimeAuthContext(harness=template.harness, backend=template.sandbox)
- static = _StaticSecretsResolver(rec["request"]["vault_secrets"])
+ static = _StaticSecretsResolver(
+ rec["request"]["vault_secrets"],
+ gateway_base_url=_GATEWAY_BASE,
+ gateway_credentials_value=_GATEWAY_CREDENTIALS,
+ )
return await _default_resolve_session_connection(
model_ref, ctx, resolve_connection=static.resolve
)
@@ -123,11 +134,15 @@ async def test_custom_openai_compatible_connection_replays(tmp_path):
assert resolved.deployment == "custom"
assert resolved.model == "openai/gpt-oss-20b:free"
assert resolved.endpoint is not None
- assert resolved.endpoint.base_url == "https://openrouter.ai/api/v1"
- assert resolved.credential_mode == "env"
- assert [credential.binding.name for credential in resolved.credentials] == [
- "OPENAI_API_KEY"
- ]
+ # D36/D30: the gateway holds the provider secret now; the resolved route names the
+ # gateway's `custom/{slug}` target, not the vault record's own upstream URL.
+ assert resolved.endpoint.base_url == (
+ f"{_GATEWAY_BASE}/gateways/llms/custom/replay-compat"
+ )
+ assert resolved.credential_mode == "none"
+ assert resolved.credentials == []
+ assert resolved.gateway_credentials is not None
+ assert resolved.gateway_credentials.value == _GATEWAY_CREDENTIALS
# The resolved connection is the only credential channel; nothing rides beside it.
session_config = SessionConfig(agent=template, resolved_connection=resolved)
@@ -149,19 +164,17 @@ async def test_custom_openai_compatible_connection_replays(tmp_path):
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",
- }
- ]
+ assert connection["endpoint"] == {
+ "baseUrl": f"{_GATEWAY_BASE}/gateways/llms/custom/replay-compat"
+ }
+ assert connection["credentialMode"] == "none"
+ # No provider secret ever reaches the wire now (D36/D30): the gateway holds it. Our own
+ # credentials into the gateway ride the dedicated field, never `credentials`.
+ assert connection["credentials"] == []
+ assert connection["gatewayCredentials"] == {
+ "header": "X-AG-Credentials",
+ "value": _GATEWAY_CREDENTIALS,
+ }
# 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/adapters/test_codex_settings_layers.py b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_codex_settings_layers.py
index 71125e9a76..648de95f4d 100644
--- a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_codex_settings_layers.py
+++ b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_codex_settings_layers.py
@@ -70,6 +70,35 @@ def test_managed_run_renders_file_free_provider_block():
assert "sk-" not in content
+def test_gateway_route_renders_base_url_and_env_http_headers():
+ # WP13/D31: a gateway-routed managed connection carries base_url + env_http_headers, mapping
+ # OUR header name to the shared env var — never the raw value.
+ content, config = _config(
+ build_codex_settings_files(
+ {},
+ credential_mode="none",
+ gateway_base_url="https://gw.example.com/gateways/llms/standard/openai",
+ gateway_header="X-AG-Credentials",
+ )
+ )
+ provider = config["model_providers"][MANAGED_PROVIDER_ID]
+ assert (
+ provider["base_url"] == "https://gw.example.com/gateways/llms/standard/openai"
+ )
+ assert provider["env_http_headers"] == {
+ "X-AG-Credentials": "AGENTA_GATEWAY_CREDENTIALS_VALUE"
+ }
+ assert "ApiKey" not in content # never the raw credential value
+
+
+def test_non_gateway_run_omits_base_url_and_headers():
+ # Byte-identical to before when there is nothing gateway-shaped to add.
+ content, config = _config(build_codex_settings_files({}, credential_mode="env"))
+ provider = config["model_providers"][MANAGED_PROVIDER_ID]
+ assert "base_url" not in provider
+ assert "env_http_headers" not in provider
+
+
def test_managed_run_places_model_provider_scalar_before_the_table():
# TOML requires top-level scalars before any table. The provider pointer and any authored scalars
# must precede the [model_providers.*] table, or tomllib would fold them into it.
diff --git a/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_error_detail.py b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_error_detail.py
new file mode 100644
index 0000000000..295ac37011
--- /dev/null
+++ b/sdks/python/oss/tests/pytest/unit/agents/adapters/test_vercel_stream_error_detail.py
@@ -0,0 +1,127 @@
+"""WP25: `errorDetail` (the runner's `AgentErrorDetail`, WP13) must reach the caller on the
+vercel stream, not stop at `AgentRunFailed`.
+
+`result_from_wire` already raises `AgentRunFailed(message, error_detail=...)` on `ok: false`
+(``utils/wire.py``), and nothing between there and the stream adapters rewraps it — verified by
+reading ``streaming.py`` and ``decorators/routing.py`` (WP25's spec doc). This pins that survival
+end to end, and pins the five refusals ``launch-3.md`` names by their real gateway codes
+(``api/oss/src/apis/fastapi/gateways/llms/proxy.py`` ``_map_domain_exception``).
+"""
+
+from __future__ import annotations
+
+from typing import Any, AsyncIterator, Dict, List, Optional
+
+from agenta.sdk.agents.adapters.vercel.stream import (
+ agent_run_to_vercel_parts,
+ agent_stream_to_vercel_stream,
+)
+from agenta.sdk.agents.errors import AgentRunFailed
+from agenta.sdk.agents.streaming import AgentStream
+
+REFUSALS = [
+ { # missing credential
+ "code": "secret_missing",
+ "message": "No project secret for anthropic under mode standard",
+ "next_step": "configure the connection's secret",
+ },
+ { # rejected credential (SecretInvalidError -- revoked or refresh failed)
+ "code": "secret_invalid",
+ "message": "Secret for anthropic:project-42 is invalid",
+ "next_step": "reconnect the connection's secret",
+ },
+ { # unregistered target
+ "code": "endpoint_not_found",
+ "message": "No endpoint named 'staging-claude'",
+ "next_step": "check the endpoint configuration",
+ },
+ { # disallowed model
+ "code": "model_not_allowed",
+ "message": "model not allowed: gpt-5.5-experimental",
+ "next_step": "choose a model the connection allows",
+ },
+ { # deactivated endpoint
+ "code": "endpoint_inactive",
+ "message": "Endpoint 'prod-openai' is inactive",
+ "next_step": "reactivate the endpoint, or choose another",
+ },
+]
+
+
+def _wire_result(detail: Dict[str, Any]) -> Dict[str, Any]:
+ return {
+ "ok": False,
+ "error": detail["message"],
+ "errorDetail": {
+ "code": detail["code"],
+ "message": detail["message"],
+ "retryable": False,
+ **({"next_step": detail["next_step"]} if "next_step" in detail else {}),
+ },
+ }
+
+
+async def _failing_records(detail: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]:
+ yield {"kind": "result", "result": _wire_result(detail)}
+
+
+async def _failing_events(detail: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]:
+ if False: # pragma: no cover - makes this an async generator
+ yield {}
+ raise AgentRunFailed(
+ detail["message"], error_detail=_wire_result(detail)["errorDetail"]
+ )
+
+
+async def _drain(parts: AsyncIterator[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ return [part async for part in parts]
+
+
+def _agent_error_data(parts: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
+ for part in parts:
+ if part["type"] == "data-agent-error":
+ return part["data"]
+ return None
+
+
+async def test_dev_twin_carries_error_detail_from_a_failed_terminal_result():
+ for detail in REFUSALS:
+ parts = await _drain(
+ agent_run_to_vercel_parts(AgentStream(_failing_records(detail)))
+ )
+ data = _agent_error_data(parts)
+ assert data is not None, detail["code"]
+ assert data["code"] == detail["code"]
+ assert data["errorDetail"] == _wire_result(detail)["errorDetail"]
+
+
+async def test_live_twin_carries_error_detail_from_a_raised_agent_run_failed():
+ for detail in REFUSALS:
+ parts = await _drain(agent_stream_to_vercel_stream(_failing_events(detail)))
+ data = _agent_error_data(parts)
+ assert data is not None, detail["code"]
+ assert data["code"] == detail["code"]
+ assert data["errorDetail"] == _wire_result(detail)["errorDetail"]
+
+
+async def test_error_frame_and_error_text_are_unchanged_by_error_detail():
+ # A caller reading only `error`/`errorText` must see no regression.
+ detail = REFUSALS[0]
+ parts = await _drain(agent_stream_to_vercel_stream(_failing_events(detail)))
+ error_frames = [p for p in parts if p["type"] == "error"]
+ assert len(error_frames) == 1
+ # sanitize_runner_error reads str(exc), which AgentRunFailed prefixes ("Agent run
+ # failed: ..."); the point of this test is that adding errorDetail didn't change it.
+ assert error_frames[0]["errorText"] == f"Agent run failed: {detail['message']}"
+
+
+async def test_error_detail_is_omitted_not_null_for_a_plain_failure():
+ async def _plain_failure() -> AsyncIterator[Dict[str, Any]]:
+ if False: # pragma: no cover
+ yield {}
+ raise RuntimeError("runner died mid-stream")
+
+ parts = await _drain(agent_stream_to_vercel_stream(_plain_failure()))
+ data = _agent_error_data(parts)
+ assert data is not None
+ assert "errorDetail" not in data
diff --git a/sdks/python/oss/tests/pytest/unit/agents/connections/test_gateway_routing.py b/sdks/python/oss/tests/pytest/unit/agents/connections/test_gateway_routing.py
new file mode 100644
index 0000000000..9908c29424
--- /dev/null
+++ b/sdks/python/oss/tests/pytest/unit/agents/connections/test_gateway_routing.py
@@ -0,0 +1,107 @@
+"""The gateway-route builders in ``connections/endpoints.py`` (WP12).
+
+``resolve_connection``'s connected path answers "the gateway, and our credentials for it"
+instead of "which provider, which key" (specs-wp12.md). These are the pieces that make that
+route: the D30 ``(namespace, name)`` grammar, the D33/D34 front-door gate, and the builder
+that assembles a :class:`ResolvedConnection` carrying no provider secret.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from agenta.sdk.agents.connections.endpoints import (
+ build_gateway_resolved_connection,
+ gateway_route,
+ gateway_target,
+)
+
+# --------------------------------------------------------------------- gateway_target (D30)
+
+
+def test_provider_key_routes_through_standard():
+ assert gateway_target(kind="provider_key", provider="OpenAI", slug="whatever") == (
+ "standard",
+ "openai",
+ )
+
+
+def test_custom_provider_routes_through_custom_by_slug():
+ assert gateway_target(kind="custom_provider", provider="openai", slug="my-gw") == (
+ "custom",
+ "my-gw",
+ )
+
+
+# ---------------------------------------------------------------------------- gateway_route
+
+
+def test_gateway_route_has_no_protocol_suffix():
+ route = gateway_route(
+ namespace="standard", name="openai", gateway_base_url="https://gw.example/api"
+ )
+ assert route == "https://gw.example/api/gateways/llms/standard/openai"
+ assert "/v1/" not in route
+
+
+def test_gateway_route_strips_a_trailing_slash_on_the_base():
+ route = gateway_route(
+ namespace="custom", name="my-gw", gateway_base_url="https://gw.example/api/"
+ )
+ assert route == "https://gw.example/api/gateways/llms/custom/my-gw"
+
+
+# --------------------------------------------------------- build_gateway_resolved_connection
+
+
+def test_build_gateway_resolved_connection_carries_no_provider_secret():
+ resolved = build_gateway_resolved_connection(
+ provider="openai",
+ model="gpt-5.5",
+ deployment="direct",
+ namespace="standard",
+ name="openai",
+ gateway_base_url="https://gw.example/api",
+ gateway_credentials_value="Secret token",
+ )
+ assert resolved.credential_mode == "none"
+ assert resolved.credentials == []
+ assert resolved.endpoint.base_url == (
+ "https://gw.example/api/gateways/llms/standard/openai"
+ )
+ assert resolved.gateway_credentials is not None
+ assert resolved.gateway_credentials.value == "Secret token"
+ # Structural guard (F-SDK-DUMP): no upstream secret leaves through a dump either, and
+ # there is none to leave here in the first place.
+ assert "sk-" not in resolved.model_dump_json()
+
+
+def test_build_gateway_resolved_connection_requires_an_effective_https_route():
+ with pytest.raises(ValueError):
+ build_gateway_resolved_connection(
+ provider="openai",
+ model="gpt-5.5",
+ deployment="direct",
+ namespace="standard",
+ name="openai",
+ gateway_base_url="http://gateway.example.com",
+ gateway_credentials_value="Secret token",
+ )
+
+
+@pytest.mark.parametrize(
+ "gateway_base_url",
+ ["http://localhost:8000", "http://127.0.0.1:8000"],
+)
+def test_build_gateway_resolved_connection_allows_loopback_http(gateway_base_url):
+ # D37: the https requirement is loopback-exempt, and the gateway route is no exception.
+ resolved = build_gateway_resolved_connection(
+ provider="openai",
+ model="gpt-5.5",
+ deployment="direct",
+ namespace="standard",
+ name="openai",
+ gateway_base_url=gateway_base_url,
+ gateway_credentials_value="Secret token",
+ )
+ assert resolved.plaintext_headers() == {"X-AG-Credentials": "Secret token"}
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 2a389ac8c6..cc4e390173 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
@@ -14,6 +14,7 @@
from agenta.sdk.agents.connections import (
Connection,
Endpoint,
+ GatewayCredentials,
ModelRef,
ResolvedConnection,
)
@@ -360,6 +361,46 @@ def test_resolved_connection_rejects_invalid_credential_combinations(
)
+# ------------------------------------------------------- credential_mode "none" + gateway (WP12)
+
+
+def test_credential_mode_none_with_gateway_credentials_is_the_normal_combination():
+ # The gateway-routed shape: no provider secret, our own credentials carry the header.
+ resolved = ResolvedConnection(
+ provider="openai",
+ model="gpt-5.5",
+ credential_mode="none",
+ endpoint=Endpoint(base_url="https://gw.example/gateways/llms/standard/openai"),
+ gateway_credentials=GatewayCredentials(value="Secret token"),
+ )
+ assert resolved.credential_mode == "none"
+ assert resolved.credentials == []
+ assert resolved.gateway_credentials is not None
+
+
+def test_a_provider_secret_alongside_credential_mode_none_is_rejected():
+ # `credentials` and `credential_mode` must still agree (the generic rule that predates
+ # the gateway field): a provider secret cannot ride alongside `credential_mode "none"`,
+ # gateway credentials or not.
+ with pytest.raises(ValidationError):
+ ResolvedConnection(
+ provider="openai",
+ model="gpt-5.5",
+ credential_mode="none",
+ credentials=[
+ {
+ "binding": {"kind": "environment", "name": "OPENAI_API_KEY"},
+ "value": "sk-secret",
+ "usage": "opaque_http",
+ }
+ ],
+ endpoint=Endpoint(
+ base_url="https://gw.example/gateways/llms/standard/openai"
+ ),
+ gateway_credentials=GatewayCredentials(value="Secret token"),
+ )
+
+
def test_plaintext_environment_materializes_only_at_local_boundary():
resolved = ResolvedConnection(
provider="anthropic",
diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/model_connection.gateway.json b/sdks/python/oss/tests/pytest/unit/agents/golden/model_connection.gateway.json
new file mode 100644
index 0000000000..224a6fffff
--- /dev/null
+++ b/sdks/python/oss/tests/pytest/unit/agents/golden/model_connection.gateway.json
@@ -0,0 +1,13 @@
+{
+ "provider": "openai",
+ "deployment": "custom",
+ "credentialMode": "none",
+ "credentials": [],
+ "endpoint": {
+ "baseUrl": "https://gateway.example.com/gateways/llms/standard/openai"
+ },
+ "gatewayCredentials": {
+ "header": "X-AG-Credentials",
+ "value": "ApiKey mock-gateway-credentials"
+ }
+}
diff --git a/sdks/python/oss/tests/pytest/unit/agents/golden/run_result.error_detail.json b/sdks/python/oss/tests/pytest/unit/agents/golden/run_result.error_detail.json
new file mode 100644
index 0000000000..ee5e6194b3
--- /dev/null
+++ b/sdks/python/oss/tests/pytest/unit/agents/golden/run_result.error_detail.json
@@ -0,0 +1,13 @@
+{
+ "ok": false,
+ "error": "gateway-llm: model not allowed: gpt-5.5-experimental",
+ "errorDetail": {
+ "code": "model_not_allowed",
+ "message": "model not allowed: gpt-5.5-experimental",
+ "retryable": false,
+ "next_step": "choose a model the connection allows",
+ "details": {
+ "type": "invalid_request_error"
+ }
+ }
+}
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 b715da4b63..563a0eef31 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
@@ -189,6 +189,166 @@ async def test_http_server_url_blocked_by_ssrf_guard():
)
+_GATEWAY_BASE = "https://api.x/api"
+
+
+def _gateway_route(name: str) -> str:
+ return f"{_GATEWAY_BASE}/gateways/mcps/custom/{name}"
+
+
+async def test_gateway_routes_through_custom_namespace_with_our_credentials():
+ # Every author-declared server is a D30 `custom` target: the resolved URL is the
+ # gateway route, and the sole credential is OUR own (X-AG-Credentials), never the
+ # upstream secret the author's `credentials` refs named.
+ resolved = await MCPResolver(
+ secret_provider=DictSecretProvider({"memory_token": "upstream-secret"}),
+ gateway_base_url=_GATEWAY_BASE,
+ gateway_credentials_value="Access tok",
+ ).resolve(
+ [
+ server(
+ connection=MCPConnection(
+ type="http",
+ url=PUBLIC_MCP_URL,
+ headers={"X-Workspace": "demo"},
+ credentials=MCPHeaderSecretRefs(
+ headers={"Authorization": "memory_token"}
+ ),
+ )
+ )
+ ]
+ )
+ assert resolved[0].to_wire()["connection"] == {
+ "type": "http",
+ "url": _gateway_route("memory"),
+ "headers": {"X-Workspace": "demo"},
+ "credentials": [
+ {
+ "binding": {"kind": "header", "name": "X-AG-Credentials"},
+ "value": "Access tok",
+ "usage": "opaque_http",
+ }
+ ],
+ }
+ assert "upstream-secret" not in repr(resolved[0])
+ assert "upstream-secret" not in resolved[0].model_dump_json()
+ assert "Access tok" not in resolved[0].model_dump_json()
+
+
+async def test_gateway_collapses_every_servers_array_to_one_credential_each():
+ # CU6: N servers, each with its own author-declared secret refs, still resolve to
+ # exactly one gateway credential PER server -- the per-server array shrinks to one
+ # entry everywhere, not just when there is a single server to resolve.
+ resolved = await MCPResolver(
+ secret_provider=DictSecretProvider({}),
+ gateway_base_url=_GATEWAY_BASE,
+ gateway_credentials_value="Access tok",
+ ).resolve(
+ [
+ server(
+ name="memory",
+ connection=MCPConnection(
+ type="http",
+ url=PUBLIC_MCP_URL,
+ credentials=MCPHeaderSecretRefs(
+ headers={"Authorization": "memory_token"}
+ ),
+ ),
+ ),
+ server(
+ name="notion",
+ connection=MCPConnection(
+ type="http",
+ url=PUBLIC_MCP_URL,
+ credentials=MCPHeaderSecretRefs(
+ headers={
+ "Authorization": "notion_token",
+ "X-Api-Key": "notion_key",
+ }
+ ),
+ ),
+ ),
+ ]
+ )
+ assert [server_.name for server_ in resolved] == ["memory", "notion"]
+ for server_, name in zip(resolved, ["memory", "notion"]):
+ assert server_.url == _gateway_route(name)
+ assert [c.binding.name for c in server_.credentials] == ["X-AG-Credentials"]
+ assert [c.value for c in server_.credentials] == ["Access tok"]
+
+
+async def test_gateway_route_ignores_the_authors_url_and_needs_no_secret_lookup():
+ # The upstream secret is the gateway's problem now (its own stored endpoint holds it),
+ # so a missing named secret never blocks resolution once a gateway is configured.
+ resolved = await MCPResolver(
+ secret_provider=DictSecretProvider({}),
+ gateway_base_url=_GATEWAY_BASE,
+ gateway_credentials_value="Access tok",
+ ).resolve(
+ [
+ server(
+ name="notion",
+ connection=MCPConnection(
+ type="http",
+ url="http://169.254.169.254/latest/meta-data/", # would fail the SSRF guard
+ credentials=MCPHeaderSecretRefs(
+ headers={"Authorization": "missing-secret"}
+ ),
+ ),
+ )
+ ]
+ )
+ assert resolved[0].url == _gateway_route("notion")
+
+
+async def test_gateway_route_passes_policy_through_unchanged():
+ resolved = await MCPResolver(
+ secret_provider=DictSecretProvider({}),
+ gateway_base_url=_GATEWAY_BASE,
+ gateway_credentials_value="Access tok",
+ ).resolve(
+ [
+ server(
+ policy=MCPPolicy(
+ tools=MCPToolPolicy(mode="include", names=["search"]),
+ permission="ask",
+ )
+ )
+ ]
+ )
+ assert resolved[0].to_wire()["policy"] == {
+ "tools": {"mode": "include", "names": ["search"]},
+ "permission": "ask",
+ }
+
+
+async def test_no_gateway_configured_falls_back_to_direct_dial():
+ # Backward compatible: the offline/standalone case (no gateway args) is untouched.
+ resolved = await MCPResolver(
+ secret_provider=DictSecretProvider({"memory_token": "secret-value"})
+ ).resolve(
+ [
+ server(
+ connection=MCPConnection(
+ type="http",
+ url=PUBLIC_MCP_URL,
+ credentials=MCPHeaderSecretRefs(
+ headers={"Authorization": "memory_token"}
+ ),
+ )
+ )
+ ]
+ )
+ assert resolved[0].url == PUBLIC_MCP_URL
+ assert resolved[0].to_wire()["connection"]["credentials"] == [
+ {
+ "binding": {"kind": "header", "name": "Authorization"},
+ "value": "secret-value",
+ "usage": "opaque_http",
+ }
+ ]
+
+
async def test_omit_missing_secret_keeps_public_headers_only():
resolved = await MCPResolver(
secret_provider=DictSecretProvider({}),
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 255fbf38d1..6ee6d928bb 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
@@ -18,11 +18,36 @@
from agenta.sdk.agents.platform import PlatformConnection, VaultConnectionResolver
from agenta.sdk.agents.platform import connections
+# The `connection` fixture pins base_url to this host; the gateways mount under it, so every
+# routed resolution composes its gateway route against it (D30).
+_GATEWAY_BASE = "https://api.x/api"
+
def _credential_environment(resolved) -> dict[str, str]:
return {item.binding.name: item.value for item in resolved.credentials}
+def _gateway_route(namespace: str, name: str) -> str:
+ return f"{_GATEWAY_BASE}/gateways/llms/{namespace}/{name}"
+
+
+def _assert_routed_through_gateway(resolved, *, namespace: str, name: str) -> None:
+ """D36/D30: the connected path injects no provider secret; the gateway holds it.
+
+ A vault record no longer distinguishes itself in the resolved output once two records
+ share a (namespace, name) pair (e.g. two `openai` provider keys both route through
+ `standard/openai`) — the gateway, not the SDK, picks the secret server-side. So this
+ only asserts the route and the absence of a provider secret, never which literal vault
+ row was selected.
+ """
+ assert resolved.credential_mode == "none"
+ assert resolved.credentials == []
+ assert resolved.endpoint is not None
+ assert resolved.endpoint.base_url == _gateway_route(namespace, name)
+ assert resolved.gateway_credentials is not None
+ assert resolved.gateway_credentials.value == "Access tok"
+
+
def _model(
slug: str | None = "openai", provider: str = "openai", model: str = "gpt-5.5"
) -> ModelRef:
@@ -107,8 +132,7 @@ async def test_resolve_fetches_secrets_and_selects_one_key(fake_http, connection
assert resolved.provider == "openai"
assert resolved.model == "gpt-5.5"
assert resolved.deployment == "direct"
- assert resolved.credential_mode == "env"
- assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-prod"}
+ _assert_routed_through_gateway(resolved, namespace="standard", name="openai")
assert resolved.input_modalities == ["text", "image"]
assert capture["method"] == "GET"
assert capture["url"] == "https://api.x/api/secrets/"
@@ -133,7 +157,7 @@ async def test_default_connection_requires_unique_provider_match(fake_http, conn
resolved = await VaultConnectionResolver(connection).resolve(
model=_model(slug=None), context=_context()
)
- assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-default"}
+ _assert_routed_through_gateway(resolved, namespace="standard", name="openai")
async def test_managed_connection_with_empty_key_fails_closed(fake_http, connection):
@@ -182,7 +206,11 @@ async def test_default_connection_picks_the_one_declaring_the_model(
resolved = await VaultConnectionResolver(connection).resolve(
model=_model(slug=None), context=_context()
)
- assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-b"}
+ # Both candidates route through the same `standard/openai` gateway target — the vault
+ # row picked no longer distinguishes itself in the output (the gateway resolves the
+ # secret server-side). The declaration logic is exercised for its real effect: resolving
+ # at all instead of raising `AmbiguousConnectionError`.
+ _assert_routed_through_gateway(resolved, namespace="standard", name="openai")
async def test_an_explicit_model_declaration_beats_a_connection_with_no_list(
@@ -200,7 +228,7 @@ async def test_an_explicit_model_declaration_beats_a_connection_with_no_list(
resolved = await VaultConnectionResolver(connection).resolve(
model=_model(slug=None), context=_context()
)
- assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-b"}
+ _assert_routed_through_gateway(resolved, namespace="standard", name="openai")
async def test_two_connections_declaring_the_same_model_stay_ambiguous(
@@ -264,7 +292,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 _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-prod"}
+ _assert_routed_through_gateway(resolved, namespace="standard", name="openai")
async def test_missing_provider_hint_is_harness_correct_for_claude(
@@ -286,10 +314,12 @@ async def test_missing_provider_hint_is_harness_correct_for_claude(
assert "openai/" not in message
-async def test_bare_claude_alias_resolves_to_anthropic(fake_http, connection):
+async def test_bare_claude_alias_infers_anthropic_and_routes_through_the_gateway(
+ fake_http, connection
+):
# F-031: a bare Claude alias from the curated Claude alias list is unambiguously Anthropic,
- # so the F-017 prefix rule must NOT reject it. It resolves against the vault's anthropic key
- # the same way the documented `anthropic/haiku` form does, instead of failing loud.
+ # so the F-017 prefix rule must NOT reject it — provider inference still happens. The
+ # gateway, not the SDK, decides whether the protocol is servable, so resolution routes.
fake_http(
connections, payload=[_provider_key("anthropic-prod", "anthropic", "sk-ant")]
)
@@ -299,16 +329,14 @@ async def test_bare_claude_alias_resolves_to_anthropic(fake_http, connection):
context=RuntimeAuthContext(harness="claude"),
)
assert resolved.provider == "anthropic", alias
- assert resolved.model == alias, alias
- assert _credential_environment(resolved) == {"ANTHROPIC_API_KEY": "sk-ant"}, (
- alias
- )
- assert resolved.input_modalities == ["text", "image"], alias
+ _assert_routed_through_gateway(resolved, namespace="standard", name="anthropic")
-async def test_bare_claude_dated_id_resolves_to_anthropic(fake_http, connection):
+async def test_bare_claude_dated_id_infers_anthropic_and_routes_through_the_gateway(
+ fake_http, connection
+):
# F-031: a bare dated Anthropic id (claude-opus-4-8) is also unambiguously Anthropic via the
- # claude-* naming convention, so it resolves rather than failing loud on a missing prefix.
+ # claude-* naming convention, so provider inference still fires and the route resolves.
fake_http(
connections, payload=[_provider_key("anthropic-prod", "anthropic", "sk-ant")]
)
@@ -317,7 +345,7 @@ async def test_bare_claude_dated_id_resolves_to_anthropic(fake_http, connection)
context=RuntimeAuthContext(harness="claude"),
)
assert resolved.provider == "anthropic"
- assert resolved.model == "claude-opus-4-8"
+ _assert_routed_through_gateway(resolved, namespace="standard", name="anthropic")
async def test_bare_model_matching_a_candidate_infers_the_provider(
@@ -334,20 +362,16 @@ async def test_bare_model_matching_a_candidate_infers_the_provider(
resolved = await VaultConnectionResolver(connection).resolve(
model=ModelRef.coerce("gpt-4o-mini"), context=_context()
)
- assert resolved.credential_mode == "env"
+ _assert_routed_through_gateway(resolved, namespace="custom", name="my-gw")
-@pytest.mark.parametrize(
- ("provider", "environment_name"),
- [
- ("openai", "OPENAI_API_KEY"),
- ("anthropic", "ANTHROPIC_API_KEY"),
- ("openrouter", "OPENROUTER_API_KEY"),
- ],
-)
+@pytest.mark.parametrize("provider", ["openai", "openrouter"])
async def test_known_direct_custom_provider_uses_direct_deployment(
- fake_http, connection, provider, environment_name
+ fake_http, connection, provider
):
+ # A named custom record for an OpenAI-shaped family still normalizes to `deployment
+ # "direct"`, but the connected path now routes it through `custom/{slug}` on the
+ # gateway rather than injecting the vault's provider-family env var (D4/D36).
endpoint = "https://93.184.216.34/v1"
model_id = "vendor/model-v1"
fake_http(
@@ -372,12 +396,35 @@ async def test_known_direct_custom_provider_uses_direct_deployment(
assert resolved.deployment == "direct"
assert resolved.model == model_id
assert resolved.input_modalities is None
- assert resolved.endpoint.base_url == endpoint
- if hasattr(resolved, "plaintext_environment"):
- environment = resolved.plaintext_environment()
- else:
- environment = resolved.env
- assert environment == {environment_name: "provider-key"}
+ _assert_routed_through_gateway(resolved, namespace="custom", name="custom-direct")
+
+
+async def test_known_direct_custom_provider_for_anthropic_routes_through_the_gateway(
+ fake_http, connection
+):
+ # A named Anthropic custom record routes through the gateway's custom namespace like any
+ # other custom connection; the gateway, not the SDK, decides the protocol.
+ endpoint = "https://93.184.216.34/v1"
+ model_id = "vendor/model-v1"
+ fake_http(
+ connections,
+ payload=[
+ _custom_provider(
+ "custom-direct",
+ "anthropic",
+ key="provider-key",
+ url=endpoint,
+ models=[model_id],
+ )
+ ],
+ )
+
+ resolved = await VaultConnectionResolver(connection).resolve(
+ model=_model("custom-direct", provider="anthropic", model=model_id),
+ context=_context(),
+ )
+ assert resolved.provider == "anthropic"
+ _assert_routed_through_gateway(resolved, namespace="custom", name="custom-direct")
async def test_missing_named_connection_fails_loud(fake_http, connection):
@@ -401,6 +448,8 @@ async def test_provider_mismatch_fails_loud(fake_http, connection):
async def test_custom_provider_snake_case_extras_normalize_for_bedrock(
fake_http, connection
):
+ # Bedrock extras normalize the same as any custom connection; the SDK routes it through
+ # the gateway's custom namespace and leaves servability to the gateway.
fake_http(
connections,
payload=[
@@ -423,22 +472,11 @@ async def test_custom_provider_snake_case_extras_normalize_for_bedrock(
),
context=RuntimeAuthContext(harness="claude"),
)
- assert resolved.provider == "anthropic"
- assert resolved.model == "anthropic.claude-3-5-sonnet"
assert resolved.deployment == "bedrock"
- 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"
+ _assert_routed_through_gateway(resolved, namespace="custom", name="my-bedrock")
-async def test_bedrock_bearer_is_opaque_http_with_regional_endpoint(
- fake_http, connection
-):
+async def test_bedrock_bearer_token_routes_through_the_gateway(fake_http, connection):
fake_http(
connections,
payload=[
@@ -459,16 +497,13 @@ async def test_bedrock_bearer_is_opaque_http_with_regional_endpoint(
),
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"]
+ assert resolved.deployment == "bedrock"
+ _assert_routed_through_gateway(resolved, namespace="custom", name="my-bedrock")
async def test_custom_provider_vertex_snake_case_extras(fake_http, connection):
+ # Vertex extras normalize the same as any custom connection and route through the
+ # gateway's custom namespace.
fake_http(
connections,
payload=[
@@ -489,14 +524,7 @@ async def test_custom_provider_vertex_snake_case_extras(fake_http, connection):
context=RuntimeAuthContext(harness="claude"),
)
assert resolved.deployment == "vertex_ai"
- assert _credential_environment(resolved) == {
- "GOOGLE_APPLICATION_CREDENTIALS": "/adc.json",
- }
- assert resolved.environment == {
- "GOOGLE_CLOUD_PROJECT": "proj",
- "GOOGLE_CLOUD_LOCATION": "us-central1",
- }
- assert [item.usage for item in resolved.credentials] == ["local_use"]
+ _assert_routed_through_gateway(resolved, namespace="custom", name="my-vertex")
async def test_vertex_api_key_mode_is_rejected_as_out_of_scope(fake_http, connection):
@@ -524,6 +552,9 @@ async def test_vertex_api_key_mode_is_rejected_as_out_of_scope(fake_http, connec
async def test_custom_gateway_api_key_from_extras_and_endpoint(fake_http, connection):
+ # An explicit `provider="anthropic"` on the model makes this an Anthropic-shaped custom
+ # gateway regardless of the vault row's own `data.kind`; it routes through the gateway's
+ # custom namespace instead of injecting `ANTHROPIC_API_KEY`.
fake_http(
connections,
payload=[
@@ -541,9 +572,8 @@ async def test_custom_gateway_api_key_from_extras_and_endpoint(fake_http, connec
model=_model("anthropic-gw", provider="anthropic", model="gpt-5.5"),
context=RuntimeAuthContext(harness="claude"),
)
- assert resolved.deployment == "custom"
- assert _credential_environment(resolved) == {"ANTHROPIC_API_KEY": "sk-gw"}
- assert resolved.endpoint.base_url == "https://93.184.216.34/v1"
+ assert resolved.provider == "anthropic"
+ _assert_routed_through_gateway(resolved, namespace="custom", name="anthropic-gw")
async def test_custom_provider_private_url_fails_loud_not_dropped(
@@ -655,9 +685,7 @@ async def test_openai_compatible_custom_normalizes_to_openai(fake_http, connecti
assert resolved.provider == "openai"
assert resolved.deployment == "custom"
assert resolved.model == model_id
- assert resolved.endpoint.base_url == endpoint
- assert resolved.credential_mode == "env"
- assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-oai-compatible"}
+ _assert_routed_through_gateway(resolved, namespace="custom", name="my-ollama")
async def test_openai_compatible_custom_missing_url_fails_loud(fake_http, connection):
@@ -691,6 +719,8 @@ async def test_openai_compatible_custom_missing_url_fails_loud(fake_http, connec
async def test_full_custom_model_key_selects_and_strips_to_backend_model(
fake_http, connection
):
+ # The full `slug/deployment/model` key still selects the right candidate and strips down
+ # to the backend model id before it routes through the gateway's custom namespace.
fake_http(
connections,
payload=[
@@ -710,8 +740,9 @@ async def test_full_custom_model_key_selects_and_strips_to_backend_model(
model=ModelRef.coerce("my-bedrock/bedrock/anthropic.claude-x"),
context=RuntimeAuthContext(harness="claude"),
)
- assert resolved.model == "anthropic.claude-x"
assert resolved.deployment == "bedrock"
+ assert resolved.model == "anthropic.claude-x"
+ _assert_routed_through_gateway(resolved, namespace="custom", name="my-bedrock")
async def test_resolve_fails_loud_on_http_error(fake_http, connection):
@@ -754,7 +785,7 @@ async def test_saved_slug_selects_one_of_two_keys_for_one_provider(
model=_model("openai-2-bbbbbbbbbbbb"), context=_context()
)
- assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-second"}
+ _assert_routed_through_gateway(resolved, namespace="standard", name="openai")
async def test_a_slugged_record_still_resolves_provider_only_when_unique(
@@ -771,7 +802,7 @@ async def test_a_slugged_record_still_resolves_provider_only_when_unique(
model=_model(slug=None), context=_context()
)
- assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-one"}
+ _assert_routed_through_gateway(resolved, namespace="standard", name="openai")
async def test_legacy_record_without_a_slug_stays_addressable_by_provider(
@@ -785,7 +816,7 @@ async def test_legacy_record_without_a_slug_stays_addressable_by_provider(
resolved = await VaultConnectionResolver(connection).resolve(
model=model, context=_context()
)
- assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-legacy"}
+ _assert_routed_through_gateway(resolved, namespace="standard", name="openai")
def test_saved_models_and_harnesses_are_carried_on_the_candidate():
@@ -840,7 +871,7 @@ async def test_saved_models_do_not_filter_resolution_yet(fake_http, connection):
# The request asks for a model outside the saved list on a harness outside the saved
# set; enforcement belongs to a later slice, so resolution must not start filtering here.
assert resolved.model == "gpt-5.5"
- assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-one"}
+ _assert_routed_through_gateway(resolved, namespace="standard", name="openai")
async def test_custom_connection_resolves_by_its_stored_slug(fake_http, connection):
@@ -870,8 +901,12 @@ async def test_custom_connection_resolves_by_its_stored_slug(fake_http, connecti
)
assert resolved.deployment == "custom"
- assert resolved.endpoint.base_url == endpoint
- assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-gw"}
+ # The gateway route is composed from the connection's stable slug, not its display name
+ # (which the endpoint's own stored URL — dropped from the resolved output now that the
+ # gateway holds the secret and dials the upstream — used to carry).
+ _assert_routed_through_gateway(
+ resolved, namespace="custom", name="my-gateway-abcdef123456"
+ )
async def test_legacy_custom_connection_without_a_slug_resolves_by_name(
@@ -899,7 +934,7 @@ async def test_legacy_custom_connection_without_a_slug_resolves_by_name(
context=_context(),
)
- assert _credential_environment(resolved) == {"OPENAI_API_KEY": "sk-legacy"}
+ _assert_routed_through_gateway(resolved, namespace="custom", name="my-gateway")
def test_a_slugged_custom_record_keeps_its_model_key_namespace():
diff --git a/sdks/python/oss/tests/pytest/unit/agents/platform/test_resolve.py b/sdks/python/oss/tests/pytest/unit/agents/platform/test_resolve.py
index 536089639d..b81d0efb8b 100644
--- a/sdks/python/oss/tests/pytest/unit/agents/platform/test_resolve.py
+++ b/sdks/python/oss/tests/pytest/unit/agents/platform/test_resolve.py
@@ -1,14 +1,10 @@
-"""The composition entrypoints: resolve_tools / resolve_mcp / resolve_secrets."""
+"""The composition entrypoints: resolve_tools / resolve_mcp."""
from __future__ import annotations
from typing import Mapping, Sequence
-from agenta.sdk.agents.platform import (
- resolve_provider_keys,
- resolve_secrets,
- resolve_tools,
-)
+from agenta.sdk.agents.platform import PlatformConnection, resolve_tools
from agenta.sdk.agents.platform import resolve_mcp
@@ -39,6 +35,23 @@ async def test_resolve_mcp_empty_returns_empty():
assert await resolve_mcp([], secret_provider=_EmptySecrets()) == []
-def test_resolve_secrets_is_the_provider_key_entrypoint():
- # The third entrypoint is the provider-key fetch (harness/model keys), not named secrets.
- assert resolve_secrets is resolve_provider_keys
+async def test_resolve_mcp_routes_through_the_gateway_when_configured():
+ # `resolve_mcp` is the connected default (WP15): with a backend configured, every
+ # server routes through `custom/{name}` with our credentials rather than dialling the
+ # author's own URL with a named secret — `_EmptySecrets` proves no vault lookup happens.
+ connection = PlatformConnection(
+ base_url="https://api.x/api", authorization="Access tok"
+ )
+ resolved = await resolve_mcp(
+ [
+ {
+ "name": "notion",
+ "connection": {"type": "http", "url": "https://93.184.216.34/mcp"},
+ }
+ ],
+ secret_provider=_EmptySecrets(),
+ connection=connection,
+ )
+ assert len(resolved) == 1
+ assert resolved[0].url == "https://api.x/api/gateways/mcps/custom/notion"
+ assert [c.binding.name for c in resolved[0].credentials] == ["X-AG-Credentials"]
diff --git a/sdks/python/oss/tests/pytest/unit/agents/platform/test_secrets_http.py b/sdks/python/oss/tests/pytest/unit/agents/platform/test_secrets_http.py
index ce167894b2..5246f0d1bd 100644
--- a/sdks/python/oss/tests/pytest/unit/agents/platform/test_secrets_http.py
+++ b/sdks/python/oss/tests/pytest/unit/agents/platform/test_secrets_http.py
@@ -1,12 +1,8 @@
-"""Named-secret and provider-key resolution against a mocked vault."""
+"""Named-secret resolution against a mocked vault."""
from __future__ import annotations
-from agenta.sdk.agents.platform import (
- PlatformConnection,
- resolve_named_secrets,
- resolve_provider_keys,
-)
+from agenta.sdk.agents.platform import PlatformConnection, resolve_named_secrets
from agenta.sdk.agents.platform import secrets
@@ -74,52 +70,3 @@ async def test_no_names_short_circuits(fake_http, connection):
capture = fake_http(secrets)
assert await resolve_named_secrets([], connection=connection) == {}
assert capture == {}
-
-
-# --- provider keys (GET /secrets/) -----------------------------------------
-
-
-async def test_provider_keys_without_api_base_return_empty(fake_http):
- assert await resolve_provider_keys(connection=PlatformConnection()) == {}
-
-
-async def test_provider_keys_map_only_provider_keys_with_dedupe(fake_http, connection):
- fake_http(
- secrets,
- payload=[
- {
- "kind": "provider_key",
- "data": {"kind": "openai", "provider": {"key": "sk-1"}},
- },
- # duplicate env var -> first one wins (setdefault).
- {
- "kind": "provider_key",
- "data": {"kind": "openai", "provider": {"key": "sk-2"}},
- },
- {
- "kind": "provider_key",
- "data": {"kind": "anthropic", "provider": {"key": "sk-ant"}},
- },
- # not a provider key -> ignored.
- {"kind": "other", "data": {"kind": "openai", "provider": {"key": "x"}}},
- # unmapped provider -> ignored.
- {
- "kind": "provider_key",
- "data": {"kind": "made_up", "provider": {"key": "y"}},
- },
- # missing key -> ignored.
- {"kind": "provider_key", "data": {"kind": "groq", "provider": {}}},
- ],
- )
- env = await resolve_provider_keys(connection=connection)
- assert env == {"OPENAI_API_KEY": "sk-1", "ANTHROPIC_API_KEY": "sk-ant"}
-
-
-async def test_provider_keys_http_error_returns_empty(fake_http, connection):
- fake_http(secrets, status=400)
- assert await resolve_provider_keys(connection=connection) == {}
-
-
-async def test_provider_keys_network_exception_returns_empty(fake_http, connection):
- fake_http(secrets, raises=RuntimeError("network down"))
- assert await resolve_provider_keys(connection=connection) == {}
diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_gateway_credentials.py b/sdks/python/oss/tests/pytest/unit/agents/test_gateway_credentials.py
new file mode 100644
index 0000000000..76c5b8412f
--- /dev/null
+++ b/sdks/python/oss/tests/pytest/unit/agents/test_gateway_credentials.py
@@ -0,0 +1,115 @@
+"""The gateway-credentials field, from the producer side (wave 2's seed, D36 and D37).
+
+The specific failure the shape invites is a silent drop: a field that validates in the SDK,
+serializes, and never arrives. So the assertion that matters is against the shared golden
+`model_connection.gateway.json`, which the runner asserts too
+(`services/runner/tests/unit/gateway-credentials.test.ts`) — one anchor, both legs.
+"""
+
+import pytest
+
+from agenta.sdk.agents.connections.models import (
+ Endpoint,
+ EnvironmentCredentialBinding,
+ GatewayCredentials,
+ ResolvedConnection,
+ ResolvedCredential,
+)
+
+_GATEWAY_URL = "https://gateway.example.com/gateways/llms/standard/openai"
+
+
+def _connection(**overrides) -> ResolvedConnection:
+ fields = {
+ "provider": "openai",
+ "model": "gpt-5.5",
+ "deployment": "custom",
+ "credential_mode": "none",
+ "endpoint": Endpoint(base_url=_GATEWAY_URL),
+ "gateway_credentials": GatewayCredentials(
+ value="ApiKey mock-gateway-credentials"
+ ),
+ }
+ fields.update(overrides)
+ return ResolvedConnection(**fields)
+
+
+def test_wire_matches_the_shared_golden(golden):
+ assert _connection().to_wire() == golden("model_connection.gateway.json")
+
+
+def test_the_header_is_materialized_and_the_environment_is_not():
+ connection = _connection()
+
+ assert connection.plaintext_headers() == {
+ "X-AG-Credentials": "ApiKey mock-gateway-credentials"
+ }
+ assert connection.plaintext_environment() == {}
+
+
+def test_a_dump_never_carries_the_value():
+ dumped = _connection().model_dump()
+
+ assert dumped["gateway_credentials"]["value"] == "**********"
+ assert "mock-gateway-credentials" not in repr(_connection())
+
+
+def test_the_provider_secret_stays_its_own_field():
+ """D36: our credentials and a provider's secret are not interchangeable."""
+ connection = _connection(
+ credential_mode="env",
+ credentials=[
+ ResolvedCredential(
+ binding=EnvironmentCredentialBinding(name="OPENAI_API_KEY"),
+ value="sk-provider",
+ usage="opaque_http",
+ )
+ ],
+ )
+
+ assert connection.plaintext_environment() == {"OPENAI_API_KEY": "sk-provider"}
+ assert connection.plaintext_headers() == {
+ "X-AG-Credentials": "ApiKey mock-gateway-credentials"
+ }
+
+
+@pytest.mark.parametrize("value", ["", " "])
+def test_an_empty_header_name_is_refused(value):
+ with pytest.raises(ValueError):
+ GatewayCredentials(header=value, value="ApiKey something")
+
+
+def test_an_empty_value_is_refused():
+ with pytest.raises(ValueError):
+ GatewayCredentials(value="")
+
+
+def test_plain_http_to_a_remote_host_is_refused():
+ with pytest.raises(ValueError):
+ _connection(endpoint=Endpoint(base_url="http://gateway.example.com/gateways"))
+
+
+@pytest.mark.parametrize(
+ "base_url",
+ ["http://localhost:8000/gateways", "http://127.0.0.1:8000/gateways"],
+)
+def test_loopback_is_exempt_from_https(base_url):
+ """D37: the check exists so a secret cannot cross a plaintext hop to a remote host."""
+ assert _connection(endpoint=Endpoint(base_url=base_url)).plaintext_headers()
+
+
+def test_the_loopback_exemption_covers_the_provider_secret_too():
+ """The runner re-validates; an SDK that accepted more than the runner would strand a run."""
+ connection = _connection(
+ endpoint=Endpoint(base_url="http://localhost:8000/gateways"),
+ credential_mode="env",
+ credentials=[
+ ResolvedCredential(
+ binding=EnvironmentCredentialBinding(name="OPENAI_API_KEY"),
+ value="sk-provider",
+ usage="opaque_http",
+ )
+ ],
+ )
+
+ assert connection.plaintext_environment() == {"OPENAI_API_KEY": "sk-provider"}
diff --git a/sdks/python/oss/tests/pytest/unit/agents/test_provider_env_vars_parity.py b/sdks/python/oss/tests/pytest/unit/agents/test_provider_env_vars_parity.py
index c883cd11da..a6d50901ea 100644
--- a/sdks/python/oss/tests/pytest/unit/agents/test_provider_env_vars_parity.py
+++ b/sdks/python/oss/tests/pytest/unit/agents/test_provider_env_vars_parity.py
@@ -1,11 +1,14 @@
-"""PY-C7: the three provider->env-var copies must never drift again (minimax was missing)."""
+"""PY-C7: the provider->env-var copies must never drift again (minimax was missing).
+
+WP14 removed the third copy (`platform.secrets._PROVIDER_ENV_VARS`) along with the whole-vault
+`resolve_provider_keys` dump it existed for; two copies remain.
+"""
from __future__ import annotations
from agenta.sdk.agents.capabilities import PROVIDER_ENV_VARS
from agenta.sdk.agents.connections import resolver as offline_resolver
from agenta.sdk.agents.platform import connections as platform_connections
-from agenta.sdk.agents.platform import secrets as platform_secrets
def test_minimax_present_in_canonical_map() -> None:
@@ -14,5 +17,4 @@ def test_minimax_present_in_canonical_map() -> None:
def test_all_copies_match_canonical_map() -> None:
assert platform_connections._PROVIDER_ENV_VARS == PROVIDER_ENV_VARS
- assert platform_secrets._PROVIDER_ENV_VARS == PROVIDER_ENV_VARS
assert offline_resolver._PROVIDER_ENV_VARS == PROVIDER_ENV_VARS
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 32c5c135a4..e9197b6268 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
@@ -24,6 +24,7 @@
from agenta.sdk.agents import (
AgentaAgentTemplate,
+ AgentRunFailed,
AgentTemplate,
ClaudeAgentTemplate,
CodexAgentTemplate,
@@ -872,6 +873,43 @@ def test_request_to_wire_codex_subscription_renders_no_provider_block():
assert "harnessFiles" not in payload
+def test_request_to_wire_codex_gateway_route_renders_base_url_and_headers():
+ # WP13/D31: a gateway-routed resolved connection threads endpoint.base_url and the gateway
+ # credential's HEADER NAME (never its value) onto config.toml.
+ from agenta.sdk.agents.connections.models import GatewayCredentials
+
+ config = CodexAgentTemplate(
+ model="openai/gpt-5.5",
+ resolved_connection=ResolvedConnection(
+ provider="openai",
+ model="gpt-5.5",
+ deployment="custom",
+ credential_mode="none",
+ endpoint=Endpoint(
+ base_url="https://gw.example.com/gateways/llms/standard/openai"
+ ),
+ gateway_credentials=GatewayCredentials(
+ header="X-AG-Credentials", value="ApiKey mock-gateway-credentials"
+ ),
+ ),
+ )
+ payload = request_to_wire(
+ harness=HarnessKind.CODEX,
+ sandbox="local",
+ config=config,
+ messages=[Message(role="user", content="hi")],
+ )
+ content = payload["harnessFiles"][0]["content"]
+ assert (
+ 'base_url = "https://gw.example.com/gateways/llms/standard/openai"' in content
+ )
+ assert (
+ 'env_http_headers = { "X-AG-Credentials" = "AGENTA_GATEWAY_CREDENTIALS_VALUE" }'
+ in content
+ )
+ assert "ApiKey mock-gateway-credentials" not in content
+
+
def test_author_permission_rules_exclude_mcp_from_wire_but_keep_settings():
config = ClaudeAgentTemplate(
harness_permissions={
@@ -1132,6 +1170,27 @@ def test_result_from_wire_raises_on_failure(golden):
result_from_wire(golden("run_result.error.json"))
+def test_result_from_wire_carries_gateway_error_detail(golden):
+ # WP13: a gateway refusal survives structured onto AgentRunFailed, not only as a string.
+ with pytest.raises(AgentRunFailed) as excinfo:
+ result_from_wire(golden("run_result.error_detail.json"))
+ exc = excinfo.value
+ assert exc.error_detail is not None
+ assert exc.error_detail["code"] == "model_not_allowed"
+ assert exc.error_detail["retryable"] is False
+ assert exc.error_detail["next_step"] == "choose a model the connection allows"
+ # failure_code takes the specific cause, not the generic default.
+ assert exc.failure_code == "model_not_allowed"
+
+
+def test_result_from_wire_error_detail_absent_for_a_plain_failure(golden):
+ with pytest.raises(AgentRunFailed) as excinfo:
+ result_from_wire(golden("run_result.error.json"))
+ exc = excinfo.value
+ assert exc.error_detail is None
+ assert exc.failure_code == "agent_run_failed"
+
+
def test_sanitize_runner_error_passes_clean_message_through():
# A concise, single-line message (what conciseError emits for known cases) is unchanged.
clean = "pi_core: model authentication failed — add the project's Anthropic key."
diff --git a/sdks/python/oss/tests/pytest/unit/golden/ssrf_guard_vectors.json b/sdks/python/oss/tests/pytest/unit/golden/ssrf_guard_vectors.json
new file mode 100644
index 0000000000..d0688b773d
--- /dev/null
+++ b/sdks/python/oss/tests/pytest/unit/golden/ssrf_guard_vectors.json
@@ -0,0 +1,326 @@
+[
+ {
+ "host": "0.0.0.0",
+ "blocked": true
+ },
+ {
+ "host": "0.255.255.255",
+ "blocked": true
+ },
+ {
+ "host": "1.0.0.0",
+ "blocked": false
+ },
+ {
+ "host": "10.0.0.0",
+ "blocked": true
+ },
+ {
+ "host": "10.255.255.255",
+ "blocked": true
+ },
+ {
+ "host": "9.255.255.255",
+ "blocked": false
+ },
+ {
+ "host": "11.0.0.0",
+ "blocked": false
+ },
+ {
+ "host": "100.64.0.0",
+ "blocked": false
+ },
+ {
+ "host": "100.64.0.1",
+ "blocked": false
+ },
+ {
+ "host": "100.127.255.255",
+ "blocked": false
+ },
+ {
+ "host": "100.63.255.255",
+ "blocked": false
+ },
+ {
+ "host": "100.128.0.0",
+ "blocked": false
+ },
+ {
+ "host": "127.0.0.1",
+ "blocked": true
+ },
+ {
+ "host": "127.255.255.255",
+ "blocked": true
+ },
+ {
+ "host": "126.255.255.255",
+ "blocked": false
+ },
+ {
+ "host": "128.0.0.0",
+ "blocked": false
+ },
+ {
+ "host": "169.254.0.1",
+ "blocked": true
+ },
+ {
+ "host": "169.254.169.254",
+ "blocked": true
+ },
+ {
+ "host": "169.254.255.255",
+ "blocked": true
+ },
+ {
+ "host": "169.253.255.255",
+ "blocked": false
+ },
+ {
+ "host": "169.255.0.0",
+ "blocked": false
+ },
+ {
+ "host": "172.16.0.0",
+ "blocked": true
+ },
+ {
+ "host": "172.31.255.255",
+ "blocked": true
+ },
+ {
+ "host": "172.15.255.255",
+ "blocked": false
+ },
+ {
+ "host": "172.32.0.0",
+ "blocked": false
+ },
+ {
+ "host": "192.0.0.0",
+ "blocked": true
+ },
+ {
+ "host": "192.0.0.7",
+ "blocked": true
+ },
+ {
+ "host": "192.0.0.8",
+ "blocked": true
+ },
+ {
+ "host": "192.0.0.170",
+ "blocked": true
+ },
+ {
+ "host": "192.0.0.255",
+ "blocked": true
+ },
+ {
+ "host": "192.0.1.0",
+ "blocked": false
+ },
+ {
+ "host": "192.0.2.0",
+ "blocked": true
+ },
+ {
+ "host": "192.0.2.255",
+ "blocked": true
+ },
+ {
+ "host": "192.0.3.0",
+ "blocked": false
+ },
+ {
+ "host": "192.168.0.0",
+ "blocked": true
+ },
+ {
+ "host": "192.168.255.255",
+ "blocked": true
+ },
+ {
+ "host": "192.167.255.255",
+ "blocked": false
+ },
+ {
+ "host": "192.169.0.0",
+ "blocked": false
+ },
+ {
+ "host": "198.18.0.0",
+ "blocked": true
+ },
+ {
+ "host": "198.19.255.255",
+ "blocked": true
+ },
+ {
+ "host": "198.17.255.255",
+ "blocked": false
+ },
+ {
+ "host": "198.20.0.0",
+ "blocked": false
+ },
+ {
+ "host": "198.51.100.0",
+ "blocked": true
+ },
+ {
+ "host": "198.51.100.255",
+ "blocked": true
+ },
+ {
+ "host": "198.51.101.0",
+ "blocked": false
+ },
+ {
+ "host": "203.0.113.0",
+ "blocked": true
+ },
+ {
+ "host": "203.0.113.255",
+ "blocked": true
+ },
+ {
+ "host": "203.0.114.0",
+ "blocked": false
+ },
+ {
+ "host": "224.0.0.0",
+ "blocked": true
+ },
+ {
+ "host": "239.255.255.255",
+ "blocked": true
+ },
+ {
+ "host": "223.255.255.255",
+ "blocked": false
+ },
+ {
+ "host": "240.0.0.0",
+ "blocked": true
+ },
+ {
+ "host": "255.255.255.254",
+ "blocked": true
+ },
+ {
+ "host": "255.255.255.255",
+ "blocked": true
+ },
+ {
+ "host": "8.8.8.8",
+ "blocked": false
+ },
+ {
+ "host": "1.1.1.1",
+ "blocked": false
+ },
+ {
+ "host": "93.184.216.34",
+ "blocked": false
+ },
+ {
+ "host": "::",
+ "blocked": true
+ },
+ {
+ "host": "::1",
+ "blocked": true
+ },
+ {
+ "host": "::2",
+ "blocked": true
+ },
+ {
+ "host": "fe80::1",
+ "blocked": true
+ },
+ {
+ "host": "fe80::ffff:ffff:ffff:ffff",
+ "blocked": true
+ },
+ {
+ "host": "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
+ "blocked": true
+ },
+ {
+ "host": "fec0::1",
+ "blocked": false
+ },
+ {
+ "host": "fc00::1",
+ "blocked": true
+ },
+ {
+ "host": "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
+ "blocked": true
+ },
+ {
+ "host": "fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
+ "blocked": true
+ },
+ {
+ "host": "ff00::1",
+ "blocked": true
+ },
+ {
+ "host": "ff02::1",
+ "blocked": true
+ },
+ {
+ "host": "feff::1",
+ "blocked": false
+ },
+ {
+ "host": "2001:db8::1",
+ "blocked": true
+ },
+ {
+ "host": "100::1",
+ "blocked": true
+ },
+ {
+ "host": "2001::1",
+ "blocked": true
+ },
+ {
+ "host": "2001:2::1",
+ "blocked": true
+ },
+ {
+ "host": "2001:10::1",
+ "blocked": true
+ },
+ {
+ "host": "2606:4700:4700::1111",
+ "blocked": false
+ },
+ {
+ "host": "::ffff:127.0.0.1",
+ "blocked": true
+ },
+ {
+ "host": "::ffff:169.254.169.254",
+ "blocked": true
+ },
+ {
+ "host": "::ffff:93.184.216.34",
+ "blocked": false
+ },
+ {
+ "host": "0:0:0:0:0:ffff:10.0.0.1",
+ "blocked": true
+ },
+ {
+ "host": "2001:4860:4860::8888",
+ "blocked": false
+ }
+]
diff --git a/sdks/python/oss/tests/pytest/unit/test_llm_v0_provider_key_binding.py b/sdks/python/oss/tests/pytest/unit/test_llm_v0_provider_key_binding.py
index d6f5d8bfc5..49e15edd45 100644
--- a/sdks/python/oss/tests/pytest/unit/test_llm_v0_provider_key_binding.py
+++ b/sdks/python/oss/tests/pytest/unit/test_llm_v0_provider_key_binding.py
@@ -8,6 +8,7 @@
to the family's first record.
"""
+import asyncio
from types import SimpleNamespace
import pytest
@@ -184,3 +185,53 @@ async def test_the_model_reaches_litellm_in_the_form_the_resolver_chose(litellm,
await _run([{"model": "claude-sonnet-5", "connection": "anthropic"}])
assert litellm.calls[0]["model"] == "anthropic/claude-sonnet-5"
+
+
+async def test_no_attribute_is_ever_set_on_the_litellm_module(litellm, vault):
+ """CU2: the key travels in kwargs only, never as `setattr(litellm, ...)`."""
+
+ vault(TWO_OPENAI_CONNECTIONS)
+ before = set(vars(litellm))
+
+ await _run([{"model": "gpt-4o-mini", "connection": "openai-2"}])
+
+ assert set(vars(litellm)) == before
+ assert not any(name.endswith("_key") for name in vars(litellm))
+
+
+async def test_concurrent_calls_with_different_connections_do_not_cross_contaminate(
+ litellm, vault
+):
+ """Two tenants racing through the same process must never see each other's key.
+
+ A module-global attribute would let a slow second caller overwrite the key the first
+ caller is mid-flight on. Per-call kwargs make that structurally impossible.
+ """
+
+ vault(TWO_OPENAI_CONNECTIONS)
+
+ order = []
+
+ async def acompletion(**kwargs):
+ # The entry resolved second (openai-2) finishes first, so a shared-state bug
+ # would leak "sk-second" into the first entry's in-flight call.
+ if kwargs["api_key"] == "sk-second":
+ await asyncio.sleep(0)
+ else:
+ await asyncio.sleep(0.01)
+ order.append(kwargs["api_key"])
+ message = SimpleNamespace(
+ model_dump=lambda exclude_none=True: {"role": "assistant"}
+ )
+ return SimpleNamespace(choices=[SimpleNamespace(message=message)], usage=None)
+
+ litellm.acompletion = acompletion
+
+ first, second = await asyncio.gather(
+ _run([{"model": "gpt-4o-mini", "connection": "openai"}]),
+ _run([{"model": "gpt-4o-mini", "connection": "openai-2"}]),
+ )
+
+ assert order == ["sk-second", "sk-first"]
+ assert first[1] == {}
+ assert second[1] == {}
diff --git a/sdks/python/oss/tests/pytest/unit/test_ssrf_guard_generated_fixtures.py b/sdks/python/oss/tests/pytest/unit/test_ssrf_guard_generated_fixtures.py
new file mode 100644
index 0000000000..cc26facfd7
--- /dev/null
+++ b/sdks/python/oss/tests/pytest/unit/test_ssrf_guard_generated_fixtures.py
@@ -0,0 +1,36 @@
+"""Regenerate-and-compare: the SSRF-guard range tables and vector fixture must match what
+`ssrf_guard_vectors.py` produces right now. A drift here means someone hand-edited the
+committed JSON, or Python's `ipaddress` special registries changed underneath us — either
+way, `services/runner/src/tools/ssrf-guard.ts` and `agenta.sdk.utils.net` are tested against
+a stale ground truth until the fixtures are regenerated.
+"""
+
+import ipaddress
+import json
+
+from agenta.sdk.utils import net
+
+from oss.tests.pytest.utils.ssrf_guard_vectors import (
+ RANGES_PATH,
+ VECTORS_PATH,
+ generate_ranges,
+ generate_vectors,
+)
+
+
+def test_generated_ranges_match_committed_fixture():
+ committed = json.loads(RANGES_PATH.read_text())
+ assert committed == generate_ranges()
+
+
+def test_generated_vectors_match_committed_fixture():
+ committed = json.loads(VECTORS_PATH.read_text())
+ assert committed == generate_vectors()
+
+
+def test_net_py_agrees_with_every_vector():
+ for vector in generate_vectors():
+ ip = ipaddress.ip_address(vector["host"])
+ assert net._is_blocked_ip(ip, allow_insecure=False) == vector["blocked"], (
+ vector["host"]
+ )
diff --git a/sdks/python/oss/tests/pytest/unit/test_utils_net_ssrf.py b/sdks/python/oss/tests/pytest/unit/test_utils_net_ssrf.py
index 4e472299db..830ebc5ee1 100644
--- a/sdks/python/oss/tests/pytest/unit/test_utils_net_ssrf.py
+++ b/sdks/python/oss/tests/pytest/unit/test_utils_net_ssrf.py
@@ -121,25 +121,27 @@ def _resolve(env=None):
@pytest.mark.allow_insecure_env
-def test_allow_insecure_defaults_false(resolve_allow_insecure):
- assert resolve_allow_insecure() is False
+def test_allow_insecure_defaults_true(resolve_allow_insecure):
+ """CU14: unset resolves permissive here, as it already did in the API and the runner."""
+ assert resolve_allow_insecure() is True
@pytest.mark.allow_insecure_env
def test_allow_insecure_canonical_env_var(resolve_allow_insecure):
- assert resolve_allow_insecure({"AGENTA_INSECURE_EGRESS_ALLOWED": "true"}) is True
+ # `false` is the discriminating value: `true` matches the default and proves nothing.
+ assert resolve_allow_insecure({"AGENTA_INSECURE_EGRESS_ALLOWED": "false"}) is False
@pytest.mark.allow_insecure_env
def test_allow_insecure_legacy_alias_still_honored(resolve_allow_insecure):
assert (
- resolve_allow_insecure({"AGENTA_CUSTOM_PROVIDER_ALLOW_INSECURE": "true"})
- is True
+ resolve_allow_insecure({"AGENTA_CUSTOM_PROVIDER_ALLOW_INSECURE": "false"})
+ is False
)
@pytest.mark.allow_insecure_env
def test_allow_insecure_ignores_ambient_env(resolve_allow_insecure, monkeypatch):
# The ambient shell may export it (a loaded dev env file); resolution must still start clean.
- monkeypatch.setenv("AGENTA_INSECURE_EGRESS_ALLOWED", "true")
- assert resolve_allow_insecure() is False
+ monkeypatch.setenv("AGENTA_INSECURE_EGRESS_ALLOWED", "false")
+ assert resolve_allow_insecure() is True
diff --git a/sdks/python/oss/tests/pytest/utils/ssrf_guard_vectors.py b/sdks/python/oss/tests/pytest/utils/ssrf_guard_vectors.py
new file mode 100644
index 0000000000..db94ae8dec
--- /dev/null
+++ b/sdks/python/oss/tests/pytest/utils/ssrf_guard_vectors.py
@@ -0,0 +1,173 @@
+"""Generator for the cross-language SSRF-guard fixtures.
+
+Two artifacts, both derived from Python's own `ipaddress` module — the ground truth,
+independent of both `agenta.sdk.utils.net` and the TypeScript guard:
+
+- `generate_ranges()`: the collapsed CIDR tables (private + reserved + multicast, per
+ address family) that `services/runner/src/tools/ssrf-guard.ts` loads at runtime instead
+ of a hand-transcribed table. IPv4-mapped/compatible IPv6 addresses are unwrapped to their
+ embedded IPv4 and checked against the IPv4 table first, mirroring `ipaddress.IPv6Address`'s
+ own `is_private`/`is_reserved` (which special-case `ipv4_mapped` before falling back to
+ IPv6 network membership) — so the IPv6 table only ever applies to genuine IPv6 literals.
+- `generate_vectors()`: boundary/representative addresses labeled via the six blocked
+ predicates, for both languages' tests to assert their guard's verdict against.
+
+Regenerating either and diffing against the committed JSON is the drift check: a table edited
+in one language without updating the other flips a label and turns a test red.
+"""
+
+import ipaddress
+from pathlib import Path
+from typing import Dict, List
+
+RANGES_PATH = (
+ Path(__file__).parents[6]
+ / "services"
+ / "runner"
+ / "src"
+ / "tools"
+ / "ssrf-guard-ranges.generated.json"
+)
+VECTORS_PATH = (
+ Path(__file__).parent.parent / "unit" / "golden" / "ssrf_guard_vectors.json"
+)
+
+
+def generate_ranges() -> Dict[str, List[str]]:
+ v4 = ipaddress._IPv4Constants
+ v4_networks = list(v4._private_networks) + [
+ v4._reserved_network,
+ v4._multicast_network,
+ ]
+ v6 = ipaddress._IPv6Constants
+ v6_networks = (
+ list(v6._private_networks)
+ + list(v6._reserved_networks)
+ + [v6._multicast_network]
+ )
+ return {
+ "ipv4": [str(n) for n in ipaddress.collapse_addresses(v4_networks)],
+ "ipv6": [str(n) for n in ipaddress.collapse_addresses(v6_networks)],
+ }
+
+
+HOSTS: List[str] = [
+ # 0.0.0.0/8
+ "0.0.0.0",
+ "0.255.255.255",
+ "1.0.0.0",
+ # 10.0.0.0/8
+ "10.0.0.0",
+ "10.255.255.255",
+ "9.255.255.255",
+ "11.0.0.0",
+ # 100.64.0.0/10 (shared address space — NOT blocked)
+ "100.64.0.0",
+ "100.64.0.1",
+ "100.127.255.255",
+ "100.63.255.255",
+ "100.128.0.0",
+ # 127.0.0.0/8
+ "127.0.0.1",
+ "127.255.255.255",
+ "126.255.255.255",
+ "128.0.0.0",
+ # 169.254.0.0/16
+ "169.254.0.1",
+ "169.254.169.254",
+ "169.254.255.255",
+ "169.253.255.255",
+ "169.255.0.0",
+ # 172.16.0.0/12
+ "172.16.0.0",
+ "172.31.255.255",
+ "172.15.255.255",
+ "172.32.0.0",
+ # 192.0.0.0/24 (was misremembered as /29 in the runner's hand-transcribed table)
+ "192.0.0.0",
+ "192.0.0.7",
+ "192.0.0.8",
+ "192.0.0.170",
+ "192.0.0.255",
+ "192.0.1.0",
+ # 192.0.2.0/24 (TEST-NET-1)
+ "192.0.2.0",
+ "192.0.2.255",
+ "192.0.3.0",
+ # 192.168.0.0/16
+ "192.168.0.0",
+ "192.168.255.255",
+ "192.167.255.255",
+ "192.169.0.0",
+ # 198.18.0.0/15 (benchmarking)
+ "198.18.0.0",
+ "198.19.255.255",
+ "198.17.255.255",
+ "198.20.0.0",
+ # 198.51.100.0/24 (TEST-NET-2)
+ "198.51.100.0",
+ "198.51.100.255",
+ "198.51.101.0",
+ # 203.0.113.0/24 (TEST-NET-3)
+ "203.0.113.0",
+ "203.0.113.255",
+ "203.0.114.0",
+ # 224.0.0.0/4 (multicast) + 240.0.0.0/4 (reserved) — one contiguous /3 once collapsed
+ "224.0.0.0",
+ "239.255.255.255",
+ "223.255.255.255",
+ "240.0.0.0",
+ "255.255.255.254",
+ "255.255.255.255",
+ # public IPv4
+ "8.8.8.8",
+ "1.1.1.1",
+ "93.184.216.34",
+ # IPv6 unspecified / loopback
+ "::",
+ "::1",
+ "::2",
+ # fe80::/10 (link-local)
+ "fe80::1",
+ "fe80::ffff:ffff:ffff:ffff",
+ "febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
+ "fec0::1",
+ # fc00::/7 (unique-local)
+ "fc00::1",
+ "fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
+ "fbff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
+ # ff00::/8 (multicast)
+ "ff00::1",
+ "ff02::1",
+ "feff::1",
+ # documentation / reserved IPv6
+ "2001:db8::1",
+ "100::1",
+ "2001::1",
+ "2001:2::1",
+ "2001:10::1",
+ "2606:4700:4700::1111", # public — must stay allowed despite living near 2001::/23
+ # IPv4-mapped IPv6
+ "::ffff:127.0.0.1",
+ "::ffff:169.254.169.254",
+ "::ffff:93.184.216.34",
+ "0:0:0:0:0:ffff:10.0.0.1",
+ # public IPv6
+ "2001:4860:4860::8888",
+]
+
+
+def generate_vectors() -> List[Dict[str, object]]:
+ vectors = []
+ for host in HOSTS:
+ ip = ipaddress.ip_address(host)
+ blocked = (
+ ip.is_private
+ or ip.is_loopback
+ or ip.is_link_local
+ or ip.is_reserved
+ or ip.is_multicast
+ or ip.is_unspecified
+ )
+ vectors.append({"host": host, "blocked": blocked})
+ return vectors
diff --git a/sdks/python/oss/tests/pytest/utils/test_webhook_ssrf_v0.py b/sdks/python/oss/tests/pytest/utils/test_webhook_ssrf_v0.py
index 01c03471fa..d2bc2ed2c9 100644
--- a/sdks/python/oss/tests/pytest/utils/test_webhook_ssrf_v0.py
+++ b/sdks/python/oss/tests/pytest/utils/test_webhook_ssrf_v0.py
@@ -96,7 +96,7 @@ def test_public_ip_accepted_and_returned_literally(self):
def test_hostname_resolving_to_private_ip_rejected(self):
with patch(
- "agenta.sdk.workflows.handlers.socket.getaddrinfo",
+ "agenta.sdk.utils.net.socket.getaddrinfo",
return_value=[(None, None, None, None, ("192.168.1.100", 0))],
):
with pytest.raises(ValueError, match="blocked IP"):
@@ -104,7 +104,7 @@ def test_hostname_resolving_to_private_ip_rejected(self):
def test_hostname_resolving_to_public_ip_returns_literal(self):
with patch(
- "agenta.sdk.workflows.handlers.socket.getaddrinfo",
+ "agenta.sdk.utils.net.socket.getaddrinfo",
return_value=[(None, None, None, None, ("93.184.216.34", 0))],
):
assert _validate_webhook_url("https://example.com/hook") == "93.184.216.34"
diff --git a/services/oss/src/agent/secrets.py b/services/oss/src/agent/secrets.py
deleted file mode 100644
index 065cfe149c..0000000000
--- a/services/oss/src/agent/secrets.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""Harness provider-key resolution: now lives in the SDK platform package.
-
-Kept as a thin re-export so existing service imports keep working. ``resolve_harness_secrets``
-is the prior name for the SDK's ``resolve_provider_keys``.
-
-The agent ``/invoke`` path no longer calls this: it resolves ONE least-privilege connection
-for the configured model via ``resolve_connection`` (``oss.src.agent.app``) instead of the
-model-blind whole-vault dump. This module remains only for the deprecated direct-import
-integration test (``test_resolve_secrets_http.py``) until that function is removed.
-"""
-
-from agenta.sdk.agents.platform.secrets import (
- _PROVIDER_ENV_VARS,
- resolve_provider_keys as resolve_harness_secrets,
-)
-
-__all__ = ["resolve_harness_secrets", "_PROVIDER_ENV_VARS"]
diff --git a/services/oss/tests/pytest/acceptance/test_agent_gateway_route.py b/services/oss/tests/pytest/acceptance/test_agent_gateway_route.py
new file mode 100644
index 0000000000..77d6059b29
--- /dev/null
+++ b/services/oss/tests/pytest/acceptance/test_agent_gateway_route.py
@@ -0,0 +1,115 @@
+"""Acceptance: agent v0's model call goes through the gateway (specs-wp14.md).
+
+WRITTEN, NOT RUN by this package (`api/AGENTS.md` testing rules: acceptance needs a real
+deployment, and this worktree carries none). Depends on WP5's `mock-llm-gateway` and WP7's
+gateway service, same as `api/oss/tests/pytest/acceptance/gateways/
+test_llm_gateway_proxy_acceptance.py`; collection succeeds today, execution needs that M2
+deployment. Run manually once it exists:
+
+ load-env hosting/docker-compose/oss/.env.oss.dev
+ bash hosting/docker-compose/run.sh --oss --dev --build
+ cd services/oss && py-run-tests # or: pytest oss/tests/pytest/acceptance -m acceptance
+
+Proves the contract in specs-wp14.md: the agent resolves a `custom_provider` vault
+connection into a `custom/{slug}` gateway route and reaches the mock upstream through it —
+no direct socket to a provider, no provider secret in the request. The audit-event half of
+the acceptance criterion ("its calls appear as audit events with the right principal",
+launch-2.md) is not asserted here: WP4 owns emission and has no HTTP query surface on this
+branch yet. Extend this test once that surface lands.
+"""
+
+from __future__ import annotations
+
+from uuid import uuid4
+
+import pytest
+
+pytestmark = [pytest.mark.acceptance]
+
+_MOCK_BASE_URL = "http://mock-llm-gateway:9091/v1"
+_MOCK_MODEL = "mock/echo"
+
+
+def _assert_ok(response):
+ assert response.status_code == 200, response.text
+ return response.json()
+
+
+@pytest.fixture(scope="module")
+def mock_custom_connection(mod_api):
+ """A `custom_provider` vault secret and a matching gateway endpoint, both pointed at
+ WP5's mock upstream and named by the same slug — the pair `resolve_connection` needs to
+ route a `mode: agenta` connection through `custom/{slug}` (D30)."""
+ slug = f"wp14-acceptance-{uuid4().hex[:8]}"
+
+ _assert_ok(
+ mod_api(
+ "POST",
+ "/secrets/",
+ json={
+ "secret": {
+ "slug": slug,
+ "kind": "custom_provider",
+ "data": {
+ "kind": "openai",
+ "provider": {"url": _MOCK_BASE_URL, "key": "sk-mock"},
+ "models": [{"slug": _MOCK_MODEL}],
+ },
+ }
+ },
+ )
+ )
+ _assert_ok(
+ mod_api(
+ "POST",
+ "/gateways/llms/endpoints/",
+ json={
+ "endpoint": {
+ "slug": slug,
+ "provider_key": "openai",
+ "deployment_kind": "custom",
+ "secret_id": None, # the mock needs no upstream secret (D23)
+ "data": {
+ "route": {"base_url": _MOCK_BASE_URL},
+ "models": {"allowlist": [_MOCK_MODEL]},
+ },
+ }
+ },
+ )
+ )
+ return slug
+
+
+def test_agent_run_completes_through_the_gateway(
+ mock_custom_connection, mod_services_api
+):
+ """POST /agent/v0/invoke with a named connection routes the model call through the
+ gateway's `custom/{slug}` route rather than a direct provider socket. Success (and the
+ mock's echoed content) is only reachable this way: no key for a real provider is
+ configured anywhere in this run."""
+ resp = mod_services_api(
+ "POST",
+ "/agent/v0/invoke",
+ json={
+ "messages": [{"role": "user", "content": "hi"}],
+ "parameters": {
+ "agent": {
+ "harness": {"kind": "pi_core"},
+ "llm": {
+ "model": _MOCK_MODEL,
+ "connection": {
+ "mode": "agenta",
+ "slug": mock_custom_connection,
+ },
+ },
+ }
+ },
+ },
+ )
+
+ body = _assert_ok(resp)
+ messages = body["messages"]
+ assert messages, "expected at least one assistant message"
+ assert messages[-1]["role"] == "assistant"
+ # WP5's mock echoes the request's last message content back (specs-wp5.md).
+ assert "hi" in messages[-1]["content"]
diff --git a/services/oss/tests/pytest/integration/agent/test_resolve_secrets_http.py b/services/oss/tests/pytest/integration/agent/test_resolve_secrets_http.py
deleted file mode 100644
index ec9f405d31..0000000000
--- a/services/oss/tests/pytest/integration/agent/test_resolve_secrets_http.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""``resolve_harness_secrets`` against a mocked ``GET /secrets/``.
-
-Best-effort by design: it maps only ``provider_key`` vault entries to env vars, dedupes by
-env var, and returns ``{}`` on any error rather than failing the run.
-"""
-
-from __future__ import annotations
-
-import pytest
-
-from oss.src.agent.secrets import resolve_harness_secrets
-from agenta.sdk.agents.platform import secrets as platform_secrets
-
-pytestmark = pytest.mark.integration
-
-
-async def test_no_api_base_returns_empty(install_http):
- install_http(platform_secrets, api_base=None)
- assert await resolve_harness_secrets() == {}
-
-
-async def test_maps_only_provider_keys_with_dedupe(install_http):
- install_http(
- platform_secrets,
- status=200,
- payload=[
- {
- "kind": "provider_key",
- "data": {"kind": "openai", "provider": {"key": "sk-1"}},
- },
- # duplicate env var -> first one wins (setdefault).
- {
- "kind": "provider_key",
- "data": {"kind": "openai", "provider": {"key": "sk-2"}},
- },
- {
- "kind": "provider_key",
- "data": {"kind": "anthropic", "provider": {"key": "sk-ant"}},
- },
- # not a provider key -> ignored.
- {"kind": "other", "data": {"kind": "openai", "provider": {"key": "x"}}},
- # unmapped provider -> ignored.
- {
- "kind": "provider_key",
- "data": {"kind": "made_up", "provider": {"key": "y"}},
- },
- # missing key -> ignored.
- {"kind": "provider_key", "data": {"kind": "groq", "provider": {}}},
- ],
- )
-
- env = await resolve_harness_secrets()
-
- assert env == {"OPENAI_API_KEY": "sk-1", "ANTHROPIC_API_KEY": "sk-ant"}
-
-
-async def test_http_error_returns_empty(install_http):
- install_http(platform_secrets, status=400)
- assert await resolve_harness_secrets() == {}
-
-
-async def test_network_exception_returns_empty(install_http):
- install_http(platform_secrets, raises=RuntimeError("network down"))
- assert await resolve_harness_secrets() == {}
diff --git a/services/oss/tests/pytest/unit/agent/test_gateway_route.py b/services/oss/tests/pytest/unit/agent/test_gateway_route.py
new file mode 100644
index 0000000000..fdf25301cd
--- /dev/null
+++ b/services/oss/tests/pytest/unit/agent/test_gateway_route.py
@@ -0,0 +1,144 @@
+"""WP14: the agent holds no provider secret and its model calls route through the gateway.
+
+specs-wp14.md's contracts, exercised at the service boundary. The resolver's own gateway-route
+logic (namespace/name selection, credential shape) is already covered exhaustively by WP12's
+suite (`sdks/python/oss/tests/pytest/unit/agents/platform/test_connections_http.py`); this
+file only asserts the service's WIRING onto that resolver, and that its refusals are not
+flattened before they reach the caller.
+"""
+
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+
+from agenta.sdk.agents import AgentResult, AgentTemplate, ResolvedToolSet
+from agenta.sdk.agents.connections import (
+ MissingCredentialError,
+ ModelRef,
+ RuntimeAuthContext,
+)
+from agenta.sdk.agents.platform import connection as platform_connection
+from agenta.sdk.agents.platform import connections as platform_connections
+from agenta.sdk.agents.platform import resolve_connection
+from agenta.sdk.models.workflows import WorkflowServiceRequest
+
+from oss.src.agent import app
+
+_AGENT_SRC = Path(app.__file__).resolve().parent
+
+# Names that would mean a provider secret is being read directly rather than routed through
+# the gateway (D30/D36) — the deleted whole-vault dump and its aliases.
+_FORBIDDEN_NAMES = {"resolve_provider_keys", "resolve_secrets", "_PROVIDER_ENV_VARS"}
+
+
+def test_no_provider_secret_path_in_the_agent_service():
+ """Grep-style guard: nothing under services/oss/src/agent can read a provider secret.
+ A name here is one deployment mistake from being wired back in."""
+ hits = []
+ for path in _AGENT_SRC.rglob("*.py"):
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ for node in ast.walk(tree):
+ name = getattr(node, "id", None) or getattr(node, "attr", None)
+ if name in _FORBIDDEN_NAMES:
+ hits.append(f"{path}:{node.lineno}:{name}")
+ assert not hits, f"provider-secret-reading code path found: {hits}"
+
+
+def test_composition_resolve_connection_is_the_gateway_resolver():
+ """`app._composition()` wires the real gateway-routing resolver, not a stub or the
+ deleted whole-vault dump."""
+ assert app.resolve_connection is resolve_connection
+
+
+async def test_service_resolves_a_gateway_route_with_no_provider_secret(monkeypatch):
+ """End-to-end through the service's own composition against a mocked `/secrets/`: proves
+ the WIRING routes through the gateway with no provider secret, not the resolver's own
+ selection logic (WP12's suite)."""
+ monkeypatch.setattr(
+ platform_connection, "_derive_base_url", lambda: "https://api.x/api"
+ )
+ monkeypatch.setattr(
+ platform_connection, "_derive_authorization", lambda: "Access tok"
+ )
+
+ class _Response:
+ status_code = 200
+
+ def json(self):
+ return [
+ {
+ "kind": "provider_key",
+ "data": {
+ "kind": "openai",
+ "provider": {"key": "sk-should-never-surface"},
+ },
+ }
+ ]
+
+ class _Client:
+ def __init__(self, *args, **kwargs) -> None:
+ pass
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *args):
+ return False
+
+ async def get(self, url, headers=None):
+ return _Response()
+
+ monkeypatch.setattr(platform_connections.httpx, "AsyncClient", _Client)
+
+ resolved = await app.resolve_connection(
+ model=ModelRef(provider="openai", model="gpt-5.5"),
+ context=RuntimeAuthContext(harness="pi_core"),
+ )
+
+ assert resolved.credential_mode == "none"
+ assert resolved.credentials == []
+ assert (
+ resolved.endpoint.base_url == "https://api.x/api/gateways/llms/standard/openai"
+ )
+ assert "sk-should-never-surface" not in repr(resolved)
+ assert "sk-should-never-surface" not in repr(resolved.gateway_credentials)
+
+
+async def test_connection_refusal_keeps_its_status_code(monkeypatch, fake_backend):
+ """Errors contract: a resolve_connection refusal is not flattened into a generic
+ failure — its status_code (what `handle_invoke_failure` reads to pick the HTTP status)
+ survives to the caller of `_agent`."""
+
+ async def _resolve(*, model, context):
+ raise MissingCredentialError(provider="openai", slug=None)
+
+ async def _tools(tools, **_kw):
+ return ResolvedToolSet(tool_callback=None)
+
+ async def _no_mcp(mcp_servers, **_kw):
+ return []
+
+ backend = fake_backend(result=AgentResult(output="unused"))
+ monkeypatch.setattr(app, "resolve_tools", _tools)
+ monkeypatch.setattr(app, "resolve_mcp_servers", _no_mcp)
+ monkeypatch.setattr(app, "resolve_connection", _resolve)
+ monkeypatch.setattr(app, "trace_context", lambda: None)
+ monkeypatch.setattr(app, "record_usage", lambda usage: None)
+ monkeypatch.setattr(app, "select_backend", lambda selection: backend)
+ monkeypatch.setattr(
+ app,
+ "_default_agent_template",
+ lambda: AgentTemplate(instructions="x", model="m"),
+ )
+
+ with pytest.raises(MissingCredentialError) as excinfo:
+ await app._agent(
+ request=WorkflowServiceRequest(),
+ messages=[{"role": "user", "content": "hi"}],
+ parameters={"agent": {"harness": {"kind": "pi_core"}}},
+ )
+
+ assert excinfo.value.status_code == 422
diff --git a/services/oss/tests/pytest/unit/agent/test_secrets_mapping.py b/services/oss/tests/pytest/unit/agent/test_secrets_mapping.py
deleted file mode 100644
index 72b217ed5a..0000000000
--- a/services/oss/tests/pytest/unit/agent/test_secrets_mapping.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""Provider-key -> harness env-var mapping.
-
-The harness authenticates with the project's vault provider keys, injected as the env vars
-each provider's SDK reads. If a name here drifts from what the harness expects, auth fails
-silently and the run falls back to login/OAuth, so the table is worth a guard.
-"""
-
-from __future__ import annotations
-
-from oss.src.agent.secrets import _PROVIDER_ENV_VARS
-
-
-def test_standard_providers_map_to_expected_env_vars():
- assert _PROVIDER_ENV_VARS["openai"] == "OPENAI_API_KEY"
- assert _PROVIDER_ENV_VARS["anthropic"] == "ANTHROPIC_API_KEY"
- assert _PROVIDER_ENV_VARS["gemini"] == "GEMINI_API_KEY"
- assert _PROVIDER_ENV_VARS["groq"] == "GROQ_API_KEY"
- assert _PROVIDER_ENV_VARS["together_ai"] == "TOGETHER_API_KEY"
- assert _PROVIDER_ENV_VARS["openrouter"] == "OPENROUTER_API_KEY"
-
-
-def test_both_mistral_spellings_share_one_env_var():
- assert _PROVIDER_ENV_VARS["mistral"] == "MISTRAL_API_KEY"
- assert _PROVIDER_ENV_VARS["mistralai"] == "MISTRAL_API_KEY"
diff --git a/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts b/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts
index 1f30e920ed..4f93166116 100644
--- a/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts
+++ b/services/runner/src/engines/sandbox_agent/daytona-secret-plan.ts
@@ -115,6 +115,11 @@ const PUBLIC_MODEL_ENVIRONMENT_BINDINGS = new Set([
// 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.
+//
+// Kept (WP13 Phase 3): the gateway-routed vault resolver never emits a `local_use` credential
+// any more (`platform/connections.py` `_resolve_from_secrets` discards `env` after a fail-loud
+// check and routes even Bedrock/Vertex through the gateway) — the offline standalone-SDK
+// resolvers (`connections/resolver.py`) still do, and this allowlist is theirs.
const LOCAL_USE_MODEL_CREDENTIAL_BINDINGS = new Set([
"AWS_ACCESS_KEY_ID",
"AWS_SECRET_ACCESS_KEY",
diff --git a/services/runner/src/engines/sandbox_agent/engine.ts b/services/runner/src/engines/sandbox_agent/engine.ts
index f58ed4a1b4..6c702d626a 100644
--- a/services/runner/src/engines/sandbox_agent/engine.ts
+++ b/services/runner/src/engines/sandbox_agent/engine.ts
@@ -3,6 +3,7 @@ import {
type AgentRunResult,
type EmitEvent,
} from "../../protocol.ts";
+import { parseGatewayErrorDetail } from "../../gateway-error.ts";
import { acquireEnvironment } from "./environment.ts";
import { runTurn } from "./run-turn.ts";
import {
@@ -10,6 +11,15 @@ import {
type SandboxAgentDeps,
} from "./runtime-contracts.ts";
+/** Every `AgentRunResult` this engine returns passes through here: the one choke point where a
+ * gateway refusal recoverable from the harness's error text (`gateway-error.ts`) gets attached,
+ * regardless of which of `runTurn`'s several failure paths produced it. */
+export function withGatewayErrorDetail(result: AgentRunResult): AgentRunResult {
+ if (result.ok || result.errorDetail || !result.error) return result;
+ const errorDetail = parseGatewayErrorDetail(result.error);
+ return errorDetail ? { ...result, errorDetail } : result;
+}
+
/**
* Whether a completed turn's environment may be parked: never on abort, client disconnect,
* pause, or failure. Session-owned streams survive disconnect WITHOUT aborting the run signal
@@ -41,7 +51,8 @@ export async function runSandboxAgent(
turnOptions: Pick = {},
): Promise {
const acquired = await acquireEnvironment(request, deps, signal);
- if (!acquired.ok) return { ok: false, error: acquired.error };
+ if (!acquired.ok)
+ return withGatewayErrorDetail({ ok: false, error: acquired.error });
const env = acquired.env;
let result: AgentRunResult | undefined;
try {
@@ -49,6 +60,7 @@ export async function runSandboxAgent(
loaded: env.loadedFromContinuity,
...turnOptions,
});
+ result = withGatewayErrorDetail(result);
return result;
} finally {
// `result` is undefined when runTurn threw: a failed turn, so destroy.
diff --git a/services/runner/src/engines/sandbox_agent/environment.ts b/services/runner/src/engines/sandbox_agent/environment.ts
index 3916d160f5..be807130e3 100644
--- a/services/runner/src/engines/sandbox_agent/environment.ts
+++ b/services/runner/src/engines/sandbox_agent/environment.ts
@@ -639,6 +639,7 @@ export async function acquireEnvironment(
const endpoint = storeReachableFromSandbox(storeEndpoint)
? undefined
: ((await (deps.discoverTunnelEndpoint ?? discoverTunnelEndpoint)({
+ storeEndpoint,
log: logger,
})) ?? undefined);
const refusal = mountRefusal(storeEndpoint, endpoint);
@@ -708,6 +709,7 @@ export async function acquireEnvironment(
const endpoint = storeReachableFromSandbox(storeEndpoint)
? undefined
: ((await (deps.discoverTunnelEndpoint ?? discoverTunnelEndpoint)({
+ storeEndpoint,
log: logger,
})) ?? undefined);
const refusal = mountRefusal(storeEndpoint, endpoint);
diff --git a/services/runner/src/engines/sandbox_agent/mount.ts b/services/runner/src/engines/sandbox_agent/mount.ts
index c64b66166c..bb852e287a 100644
--- a/services/runner/src/engines/sandbox_agent/mount.ts
+++ b/services/runner/src/engines/sandbox_agent/mount.ts
@@ -497,30 +497,49 @@ async function defaultCheckMountpoint(
// --- Remote (Daytona / E2B): geesefs runs INSIDE the sandbox ---------------- //
export interface TunnelDeps {
+ /**
+ * The in-network store endpoint the tunnel has to forward to. Supplied, only a tunnel
+ * whose own upstream is this endpoint is accepted; omitted, the first https tunnel wins.
+ */
+ storeEndpoint?: string;
/** ngrok agent API base (its local :4040 dashboard). */
ngrokApi?: string;
fetchImpl?: typeof fetch;
log?: (msg: string) => void;
}
+/** host:port of a URL-ish string, for comparing a tunnel's upstream against ours. */
+function upstreamAuthority(value: string): string | null {
+ const raw = value.trim();
+ if (!raw) return null;
+ try {
+ const url = new URL(raw.includes("://") ? raw : `http://${raw}`);
+ return url.port ? `${url.hostname}:${url.port}` : url.hostname;
+ } catch {
+ return null;
+ }
+}
+
/**
* Resolve the public tunnel URL for the in-network store endpoint. A remote sandbox cannot
* reach `seaweedfs:8333` on the compose network, so geesefs there must hit a public URL; the
- * `ngrok` service (compose profile `remote`) tunnels the store, and its agent API lists the
- * active tunnels. Returns null when no tunnel is up. The remote mount is then skipped rather than
- * failing the run, but the skip is NOT silent: the caller warns the operator with the cause named,
- * and tells the model the durable folder is unreachable this turn, because a model whose history
- * shows the folder working will otherwise report the user's saved work as lost.
+ * `ngrok-mounts` service (compose profile `with-tunnel`) tunnels it, and its agent API lists the
+ * active tunnels. Returns null when no tunnel forwards to the store. The remote mount is then
+ * skipped rather than failing the run, but the skip is NOT silent: the caller warns the operator
+ * with the cause named, and tells the model the durable folder is unreachable this turn, because
+ * a model whose history shows the folder working will otherwise report the user's work as lost.
+ *
+ * The agent may be tunnelling something else entirely — the development compose files point it
+ * at the platform's own ingress so providers can reach webhooks — so a tunnel is matched on the
+ * upstream it forwards to and never on the order the agent lists them. Returning the wrong URL
+ * would mount an HTTP API as an object store, which fails far from its cause.
*/
export async function discoverTunnelEndpoint(
deps: TunnelDeps = {},
): Promise {
const log = deps.log ?? defaultLog;
const doFetch = deps.fetchImpl ?? fetch;
- const api =
- deps.ngrokApi ??
- process.env.AGENTA_MOUNTS_TUNNEL_API ??
- "http://ngrok:4040";
+ const api = deps.ngrokApi ?? "http://ngrok-mounts:4040";
try {
const res = await doFetch(`${api}/api/tunnels`);
if (!res.ok) {
@@ -528,10 +547,43 @@ export async function discoverTunnelEndpoint(
return null;
}
const body = (await res.json()) as {
- tunnels?: Array<{ public_url?: string; proto?: string }>;
+ tunnels?: Array<{
+ public_url?: string;
+ proto?: string;
+ config?: { addr?: string };
+ }>;
};
const tunnels = body.tunnels ?? [];
- // Prefer https; fall back to any public_url.
+
+ if (deps.storeEndpoint) {
+ const wanted = upstreamAuthority(deps.storeEndpoint);
+ // An endpoint we cannot parse is a hard failure, never a licence to guess.
+ if (!wanted) {
+ log(
+ `tunnel discovery: cannot parse store endpoint; ` +
+ `not using another tunnel's URL`,
+ );
+ return null;
+ }
+ // One `ngrok http` can be listed twice, http and https over the same upstream.
+ const matches = tunnels.filter(
+ (t) =>
+ !!t.public_url &&
+ !!t.config?.addr &&
+ upstreamAuthority(t.config.addr) === wanted,
+ );
+ if (matches.length === 0) {
+ log(
+ `tunnel discovery: no tunnel forwards to ${wanted} ` +
+ `(${tunnels.length} tunnel(s) up); not using another tunnel's URL`,
+ );
+ return null;
+ }
+ const preferred = matches.find((t) => t.proto === "https") ?? matches[0];
+ return preferred.public_url ?? null;
+ }
+
+ // No store endpoint to match against: prefer https, then any public_url.
const https = tunnels.find(
(t) => t.proto === "https" && !!t.public_url,
)?.public_url;
diff --git a/services/runner/src/engines/sandbox_agent/pi-assets.ts b/services/runner/src/engines/sandbox_agent/pi-assets.ts
index 99149228a9..2a010f5b50 100644
--- a/services/runner/src/engines/sandbox_agent/pi-assets.ts
+++ b/services/runner/src/engines/sandbox_agent/pi-assets.ts
@@ -18,6 +18,7 @@ import { dirname, join } from "node:path";
import type { AgentRunRequest, ResolvedToolSpec } from "../../protocol.ts";
import {
encodePiModelProviderOverride,
+ PI_GATEWAY_PLACEHOLDER_API_KEY,
PI_MODEL_PROVIDER_OVERRIDE_ENV,
} from "../../extensions/model-provider-override.ts";
import { advertisedToolSpecs } from "../../tools/public-spec.ts";
@@ -31,6 +32,7 @@ import {
serializePiModelsJson,
type PiModelsJsonPlan,
} from "./pi-model-config.ts";
+import { materializeGatewayHeaders } from "./run-plan.ts";
import type {
RunPlan,
RunPlanCredentials,
@@ -380,12 +382,22 @@ export function buildPiExtensionEnv(
// 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.
+ // two competing registrations for the same run would race for the provider. This is the
+ // path a gateway-routed connection whose ORIGINAL deployment is "direct" takes (WP12's
+ // majority case, a plain provider_key connection) — `isPiModelConfigApplicable` only covers
+ // a named custom-agenta connection, so `headers` carries OUR gateway credential here or it
+ // never reaches Pi at all for every other gateway-routed connection.
const modelBaseUrl = request.modelConnection?.endpoint?.baseUrl;
if (modelBaseUrl !== undefined && !isPiModelConfigApplicable(request)) {
+ const gatewayHeaders = materializeGatewayHeaders(request);
+ const isGatewayRoute = Object.keys(gatewayHeaders).length > 0;
env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = encodePiModelProviderOverride({
provider: request.modelConnection?.provider,
baseUrl: modelBaseUrl,
+ ...(isGatewayRoute ? { headers: gatewayHeaders } : {}),
+ // credentialMode "none" leaves no real key anywhere; without SOME apiKey Pi may treat the
+ // model as unavailable for selection (see PiModelProviderOverride.apiKey).
+ ...(isGatewayRoute ? { apiKey: PI_GATEWAY_PLACEHOLDER_API_KEY } : {}),
});
}
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 2995fbf6e5..925e5dc469 100644
--- a/services/runner/src/engines/sandbox_agent/pi-model-config.ts
+++ b/services/runner/src/engines/sandbox_agent/pi-model-config.ts
@@ -26,6 +26,7 @@ import type {
PiBuiltinModel,
PiBuiltinRegistry,
} from "./pi-builtin-registry.ts";
+import { GATEWAY_CREDENTIALS_VALUE_ENV } from "./run-plan.ts";
/** The API dialect this builder emits. The only value v1 supports (design Decision 1). */
export type PiProviderApi = "openai-completions";
@@ -56,6 +57,12 @@ export interface PiModelConfigPlan {
apiKeyEnv: typeof OPENAI_API_KEY_ENV;
/** The exact selected model(s). v1 registers exactly one. */
models: Array<{ id: string }>;
+ /**
+ * OUR gateway credential (D31/D36), keyed by header name, valued by `$ENV_VAR` indirection
+ * (`models.json`'s own value-resolution syntax — see the bundled Pi `docs/models.md`) so the
+ * raw value never reaches this file on disk. Absent when the connection is not gateway-routed.
+ */
+ headers?: Record;
}
/**
@@ -258,10 +265,11 @@ export function isPiModelConfigApplicable(request: AgentRunRequest): boolean {
* 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 materialized model environment (`secrets` — on a Daytona
- * Secrets run this includes the opaque credential BINDINGS, whose in-sandbox value is the
- * Daytona placeholder);
+ * - credential mode "env", OR "none" with a gateway credential (D31/D36: a gateway route
+ * carries OUR credentials instead of the provider's, so there is no API key to require);
+ * - `OPENAI_API_KEY` present in the materialized model environment when credential mode is
+ * "env" (`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.
@@ -277,13 +285,21 @@ export function buildPiModelConfigPlan(
const baseUrl = request.modelConnection?.endpoint?.baseUrl?.trim();
const model = request.model?.trim();
const hasKey = !!secrets[OPENAI_API_KEY_ENV]?.trim();
+ const gatewayCredentials = request.modelConnection?.gatewayCredentials;
+ // A gateway route (D31/D36) carries OUR credentials instead of a provider key: credentialMode
+ // is "none" and there is nothing in `secrets` to require. `env` mode is still the only other
+ // legal shape (the provider key itself), so this is not a third credentialMode value — it is
+ // gatewayCredentials substituting for the API-key check the same way it substitutes on the wire.
+ const credentialModeOk =
+ credentialMode === "env" || (credentialMode === "none" && !!gatewayCredentials);
const missing: string[] = [];
if (!slug) missing.push("a connection slug");
if (!baseUrl) missing.push("an endpoint base URL");
- if (credentialMode !== "env")
+ if (!credentialModeOk)
missing.push(`credential mode "env" (got "${credentialMode ?? "none"}")`);
- if (!hasKey) missing.push(`${OPENAI_API_KEY_ENV} in the resolved secrets`);
+ if (credentialMode === "env" && !hasKey)
+ missing.push(`${OPENAI_API_KEY_ENV} in the resolved secrets`);
if (!model) missing.push("a model id");
if (missing.length > 0) {
@@ -312,6 +328,9 @@ export function buildPiModelConfigPlan(
baseUrl: baseUrl as string,
apiKeyEnv: OPENAI_API_KEY_ENV,
models: [{ id: modelId }],
+ ...(gatewayCredentials
+ ? { headers: { [gatewayCredentials.header]: `$${GATEWAY_CREDENTIALS_VALUE_ENV}` } }
+ : {}),
};
}
@@ -332,6 +351,7 @@ export function serializePiModelsJson(plan: PiModelsJsonPlan): string {
baseUrl: plan.baseUrl,
api: plan.api,
apiKey: `$${plan.apiKeyEnv}`,
+ ...(plan.headers ? { headers: plan.headers } : {}),
models: plan.models.map((model) => ({ id: model.id })),
};
const document = { providers: { [piModelsJsonProviderId(plan)]: block } };
diff --git a/services/runner/src/engines/sandbox_agent/run-plan.ts b/services/runner/src/engines/sandbox_agent/run-plan.ts
index b83b28c0ee..efe95c821e 100644
--- a/services/runner/src/engines/sandbox_agent/run-plan.ts
+++ b/services/runner/src/engines/sandbox_agent/run-plan.ts
@@ -212,8 +212,7 @@ export interface RunPlan {
}
export type BuildRunPlanResult =
- | { ok: true; plan: RunPlan }
- | { ok: false; error: string };
+ { 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.
@@ -308,6 +307,39 @@ function defaultDaytonaCwd(durableCwd?: string): string {
return durableCwd ?? `/home/sandbox/agenta-${randomBytes(6).toString("hex")}`;
}
+const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
+
+/** Mirrors `ResolvedConnection._require_effective_https` in the Python SDK: https anywhere, or
+ * plain http to a loopback host, which has no remote to leak the value to. */
+function isEffectiveSecureEndpoint(baseUrl: string | undefined): boolean {
+ try {
+ const endpoint = new URL(baseUrl ?? "");
+ if (!endpoint.hostname) return false;
+ if (endpoint.protocol === "https:") return true;
+ return (
+ endpoint.protocol === "http:" &&
+ LOOPBACK_HOSTNAMES.has(endpoint.hostname.replace(/^\[|\]$/g, ""))
+ );
+ } catch {
+ return false;
+ }
+}
+
+/** The env var a gateway credential's raw value lands in, for a harness config file (Pi
+ * `models.json`, Codex `config.toml`) to reference by `$VAR` indirection rather than writing
+ * the secret to disk — the same pattern `apiKeyEnv` already uses for provider keys. */
+export const GATEWAY_CREDENTIALS_VALUE_ENV = "AGENTA_GATEWAY_CREDENTIALS_VALUE";
+
+/** The gateway credentials as the header they belong in. The header counterpart of
+ * `materializeModelEnvironment`; validated there, materialized here. */
+export function materializeGatewayHeaders(
+ request: AgentRunRequest,
+): Record {
+ const credentials = request.modelConnection?.gatewayCredentials;
+ if (!credentials?.header?.trim() || !credentials.value) return {};
+ return { [credentials.header]: credentials.value };
+}
+
export function materializeModelEnvironment(
request: AgentRunRequest,
):
@@ -371,19 +403,15 @@ export function materializeModelEnvironment(
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 (
+ credential.usage === "opaque_http" &&
+ !isEffectiveSecureEndpoint(connection.endpoint?.baseUrl)
+ ) {
+ return {
+ ok: false,
+ error:
+ "opaque_http model credentials require an effective HTTPS endpoint",
+ };
}
if (Object.hasOwn(environment, name)) {
return {
@@ -394,6 +422,32 @@ export function materializeModelEnvironment(
environment[name] = credential.value;
}
+ const gatewayCredentials = connection.gatewayCredentials;
+ if (gatewayCredentials !== undefined) {
+ if (!gatewayCredentials.header?.trim() || !gatewayCredentials.value) {
+ return {
+ ok: false,
+ error: "gateway credentials require a non-empty header name and value",
+ };
+ }
+ if (!isEffectiveSecureEndpoint(connection.endpoint?.baseUrl)) {
+ return {
+ ok: false,
+ error: "gateway credentials require an effective HTTPS endpoint",
+ };
+ }
+ // A gateway route carries OUR credentials in place of the provider's; a request naming
+ // both is confused about which one authenticates and is rejected rather than guessed at
+ // (specs-wp13.md Phase 1).
+ if (credentials.length > 0) {
+ return {
+ ok: false,
+ error:
+ "modelConnection cannot combine gateway credentials with provider credentials",
+ };
+ }
+ }
+
return {
ok: true,
environment,
diff --git a/services/runner/src/engines/sandbox_agent/runtime-policy.ts b/services/runner/src/engines/sandbox_agent/runtime-policy.ts
index 34a509af8a..fbfc115fef 100644
--- a/services/runner/src/engines/sandbox_agent/runtime-policy.ts
+++ b/services/runner/src/engines/sandbox_agent/runtime-policy.ts
@@ -3,6 +3,7 @@ import {
type ToolPermission,
} from "../../protocol.ts";
import { claimSessionOwnership, REPLICA_ID } from "../../sessions/alive.ts";
+import { materializeGatewayHeaders } from "./run-plan.ts";
import { PendingApprovalPauseController } from "./pause.ts";
type Log = (message: string) => void;
@@ -88,6 +89,21 @@ export function applyClaudeConnectionEnv(
logger(`claude base_url: ${baseUrl}`);
}
+ // OUR gateway credential (D31/D36), not a provider secret. `ANTHROPIC_CUSTOM_HEADERS` is the
+ // mechanism the pinned claude-agent-acp bridge itself uses for a gateway route (its own
+ // `createEnvForGateway` sets the same pair), so this mirrors a supported shape rather than
+ // inventing one (OD14). Format: one `Name: Value` pair per line.
+ const gatewayHeaders = materializeGatewayHeaders(request);
+ const headerLines = Object.entries(gatewayHeaders)
+ .map(([name, value]) => `${name}: ${value}`)
+ .join("\n");
+ if (headerLines) {
+ env.ANTHROPIC_CUSTOM_HEADERS = headerLines;
+ logger(
+ `claude gateway credentials header: ${request.modelConnection?.gatewayCredentials?.header}`,
+ );
+ }
+
if (deployment === "bedrock") {
env.CLAUDE_CODE_USE_BEDROCK = "1";
const region = request.modelConnection?.endpoint?.region;
diff --git a/services/runner/src/environment/runtime-lifecycle.ts b/services/runner/src/environment/runtime-lifecycle.ts
index 1fa68ba212..94c409f92f 100644
--- a/services/runner/src/environment/runtime-lifecycle.ts
+++ b/services/runner/src/environment/runtime-lifecycle.ts
@@ -42,6 +42,7 @@ import {
writeOtlpAuthFile,
} from "../engines/sandbox_agent/pi-assets.ts";
import { applyClaudeConnectionEnv } from "../engines/sandbox_agent/runtime-policy.ts";
+import { GATEWAY_CREDENTIALS_VALUE_ENV } from "../engines/sandbox_agent/run-plan.ts";
import type { AgentRunRequest } from "../protocol.ts";
import type { RunPlan } from "../engines/sandbox_agent/run-plan.ts";
import type { Log } from "./timing.ts";
@@ -198,6 +199,15 @@ export function buildRuntimeEnvironment(
);
// Apply only the resolved provider keys.
Object.assign(env, p.credentials.modelEnvironment);
+ // OUR gateway credential, not a provider secret: unlike `modelEnvironment` it never goes
+ // through Daytona Secret hiding (there is no third party to leak it to — it authenticates the
+ // harness to US), so it lands directly in the daemon env. Pi and Codex reference it by
+ // `$AGENTA_GATEWAY_CREDENTIALS_VALUE` indirection from their own config files rather than
+ // writing the raw value to disk; Claude reads it straight into ANTHROPIC_CUSTOM_HEADERS below.
+ const gatewayCredentials = r.modelConnection?.gatewayCredentials;
+ if (gatewayCredentials?.value) {
+ env[GATEWAY_CREDENTIALS_VALUE_ENV] = gatewayCredentials.value;
+ }
applyClaudeConnectionEnv(env, input.request, p.acpAgent as never, input.log);
const piSessionDir = configurePiSessionWorkspace(input.plan, env);
configurePiSkillSnapshot(input.piSkillSnapshot as never, env);
diff --git a/services/runner/src/extensions/agenta.ts b/services/runner/src/extensions/agenta.ts
index a4caf7c8cb..6d0dad80dc 100644
--- a/services/runner/src/extensions/agenta.ts
+++ b/services/runner/src/extensions/agenta.ts
@@ -246,7 +246,7 @@ function promptGuidelines(spec: ResolvedToolSpec): string[] {
}
if (spec.name === "request_connection") {
guidelines.push(
- "When calling request_connection, set integration to the lowercase provider key such as slack or github; use mode oauth unless the user explicitly asks for an API key.",
+ "When calling request_connection for an external integration, set integration to the lowercase provider key such as slack or github; use mode oauth unless the user explicitly asks for an API key. When a model or MCP call was refused because the target is not registered, call it instead with target: {plane: 'llm'|'mcp', name: } and omit integration/mode.",
);
}
if (spec.name === "commit_revision") {
@@ -357,8 +357,7 @@ 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 modelProviderOverrideRaw = process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV];
const modelProviderOverride =
modelProviderOverrideRaw === undefined
? undefined
@@ -387,11 +386,19 @@ 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.
+ // Extension factories complete before Pi selects the configured model. Registering only
+ // baseUrl/headers here overrides the built-in provider without replacing its model catalog.
+ // `headers` carries OUR gateway credential (D31/D36) when this connection is gateway-routed —
+ // absent for a plain custom-endpoint override with no gateway involved.
if (modelProviderOverride) {
pi.registerProvider(modelProviderOverride.provider, {
baseUrl: modelProviderOverride.baseUrl,
+ ...(modelProviderOverride.headers
+ ? { headers: modelProviderOverride.headers }
+ : {}),
+ ...(modelProviderOverride.apiKey
+ ? { apiKey: modelProviderOverride.apiKey }
+ : {}),
});
}
diff --git a/services/runner/src/extensions/model-provider-override.ts b/services/runner/src/extensions/model-provider-override.ts
index 35096e18e6..f6d18d2c60 100644
--- a/services/runner/src/extensions/model-provider-override.ts
+++ b/services/runner/src/extensions/model-provider-override.ts
@@ -1,9 +1,29 @@
export const PI_MODEL_PROVIDER_OVERRIDE_ENV =
"AGENTA_AGENT_MODEL_PROVIDER_OVERRIDE";
+/** Never a secret; see `PiModelProviderOverride.apiKey`. */
+export const PI_GATEWAY_PLACEHOLDER_API_KEY = "agenta-gateway";
+
export interface PiModelProviderOverride {
provider: string;
baseUrl: string;
+ /**
+ * OUR gateway credential (D31/D36), keyed by header name. Present only on a gateway-routed
+ * connection. Unlike `models.json`'s `$ENV` indirection, this rides the raw value directly:
+ * the payload travels through a runner-set env var the extension reads at startup, never a
+ * file on disk, the same delivery `ANTHROPIC_CUSTOM_HEADERS` already uses for Claude.
+ */
+ headers?: Record;
+ /**
+ * A non-secret placeholder, present only alongside `headers`. A gateway route's credentialMode
+ * is "none" — no provider key exists anywhere in this run's environment, and Pi's own built-in
+ * credential state for the overridden provider must not be assumed present either (a gateway
+ * run's Pi agent dir carries no seeded auth.json) — so without SOME `apiKey` Pi may treat the
+ * model as unavailable for selection (bundled Pi `docs/models.md`, Value Resolution: "if no
+ * auth is configured, the models load but stay unavailable"). The real auth rides `headers`;
+ * this literal is never a value anything downstream reads.
+ */
+ apiKey?: string;
}
const PROVIDER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
@@ -45,7 +65,44 @@ export function validatePiModelProviderOverride(
);
}
- return { provider, baseUrl };
+ const rawHeaders = (value as { headers?: unknown }).headers;
+ let headers: Record | undefined;
+ if (rawHeaders !== undefined) {
+ if (
+ !rawHeaders ||
+ typeof rawHeaders !== "object" ||
+ Array.isArray(rawHeaders)
+ ) {
+ throw new Error("model provider override headers must be an object");
+ }
+ headers = {};
+ for (const [name, headerValue] of Object.entries(
+ rawHeaders as Record,
+ )) {
+ if (!name.trim() || typeof headerValue !== "string" || !headerValue) {
+ throw new Error(
+ "model provider override headers require non-empty names and values",
+ );
+ }
+ headers[name] = headerValue;
+ }
+ }
+
+ const rawApiKey = (value as { apiKey?: unknown }).apiKey;
+ let apiKey: string | undefined;
+ if (rawApiKey !== undefined) {
+ if (typeof rawApiKey !== "string" || !rawApiKey) {
+ throw new Error("model provider override apiKey must be a non-empty string");
+ }
+ apiKey = rawApiKey;
+ }
+
+ return {
+ provider,
+ baseUrl,
+ ...(headers ? { headers } : {}),
+ ...(apiKey ? { apiKey } : {}),
+ };
}
export function encodePiModelProviderOverride(value: unknown): string {
diff --git a/services/runner/src/gateway-error.ts b/services/runner/src/gateway-error.ts
new file mode 100644
index 0000000000..0eaf8f0c1b
--- /dev/null
+++ b/services/runner/src/gateway-error.ts
@@ -0,0 +1,121 @@
+/**
+ * Recover the gateway's structured refusal from a harness-reported error string.
+ *
+ * The LLM gateway's data-plane refusals (`apis/fastapi/gateways/llms/proxy.py`
+ * `_map_domain_exception`) are OpenAI-shaped: `{"error": {"message", "type", "code", ...}}`.
+ * A harness's provider SDK is the thing that actually receives that HTTP body. Two recovery
+ * paths, tried in order (OD18, `open-designs.md`):
+ *
+ * 1. The JSON body, verbatim, embedded in the harness's error text (most OpenAI/Anthropic-
+ * compatible SDKs fold it into their thrown error's message). Recovers everything:
+ * `code`, `message`, `next_step`, `details`.
+ * 2. A single machine-readable marker (`⟦agenta_code:⟧`) the gateway appends to every
+ * TYPED refusal's `message` field specifically so `code` survives even when a harness's
+ * SDK strips the JSON structure and keeps only that one field — confirmed for Codex
+ * (`codex-rs`'s `extract_error_message` discards everything but `error.message`). Recovers
+ * `code` only: `retryable`/`next_step`/`details` are lost on a marker-only harness, and a
+ * caller of this function (WP19's step-up interaction) must degrade to a generic prompt
+ * when it sees no `details`/`next_step` rather than assume one exists.
+ *
+ * A harness whose SDK discards BOTH — the full body and the marker inside `message` — still
+ * yields `undefined`; no harness examined does this (OD18).
+ */
+import type { AgentErrorDetail } from "./protocol.ts";
+
+// U+27E6/U+27E7 (MATHEMATICAL LEFT/RIGHT WHITE SQUARE BRACKET): chosen because they never
+// occur in ordinary error prose, a model's own output, JSON delimiters (`{}`/`[]`), or
+// markdown, so nothing else can produce or be mistaken for this marker. Must match the gateway
+// (`api/oss/src/apis/fastapi/gateways/llms/proxy.py` `_CODE_MARKER_OPEN`/`_CLOSE`).
+const CODE_MARKER_RE = /⟦agenta_code:([a-z_]+)⟧/;
+
+interface GatewayErrorBody {
+ message?: unknown;
+ type?: unknown;
+ code?: unknown;
+ [key: string]: unknown;
+}
+
+/** Refusal codes the gateway raises before dialling the upstream — never retryable as-is; the
+ * caller must change the request or its configuration, not repeat the same bytes. */
+const NEXT_STEPS: Record = {
+ model_not_allowed: "choose a model the connection allows",
+ endpoint_inactive: "reactivate the endpoint, or choose another",
+ ceiling_exceeded: "reduce the request below the endpoint's ceiling",
+ policy_denied: "check the connection's policy",
+ secret_missing: "configure the connection's secret",
+ secret_invalid: "reconnect the connection's secret",
+ endpoint_not_found: "check the endpoint configuration",
+ adapter_not_found: "check the endpoint configuration",
+};
+
+/** The first balanced `{...}` JSON object in `text`, or undefined if none parses. Scans left to
+ * right so the FIRST candidate wins, matching where a formatted error message places the body. */
+function firstJsonObject(text: string): unknown {
+ for (let start = text.indexOf("{"); start !== -1; start = text.indexOf("{", start + 1)) {
+ let depth = 0;
+ for (let i = start; i < text.length; i++) {
+ if (text[i] === "{") depth++;
+ else if (text[i] === "}") {
+ depth--;
+ if (depth === 0) {
+ try {
+ return JSON.parse(text.slice(start, i + 1));
+ } catch {
+ break; // not valid JSON from this `{`; try the next one
+ }
+ }
+ }
+ }
+ }
+ return undefined;
+}
+
+/** Parse a harness error string for an embedded gateway refusal body, or undefined. */
+export function parseGatewayErrorDetail(
+ raw: string | undefined,
+): AgentErrorDetail | undefined {
+ if (!raw) return undefined;
+ const fromBody = parseFromBody(raw);
+ if (fromBody) return fromBody;
+ return parseFromMarker(raw);
+}
+
+/** Path 1: the JSON body survived. Recovers the full envelope. */
+function parseFromBody(raw: string): AgentErrorDetail | undefined {
+ const parsed = firstJsonObject(raw);
+ if (!parsed || typeof parsed !== "object") return undefined;
+ const body = (parsed as { error?: GatewayErrorBody }).error;
+ if (!body || typeof body !== "object") return undefined;
+ const code = typeof body.code === "string" ? body.code : undefined;
+ const message = typeof body.message === "string" ? body.message : undefined;
+ if (!code || !message) return undefined;
+
+ const { message: _m, type, code: _c, ...details } = body;
+ if (typeof type === "string") details.type = type;
+
+ return {
+ code,
+ message,
+ // Every code the gateway raises before dialling upstream is a policy/config refusal, not a
+ // transient one; `upstream_error` (the one code that could be transient) has no reliable
+ // signal in the harness-formatted text either, so it stays conservative rather than telling
+ // a model to retry a permanent failure (api/AGENTS.md's retryable guidance).
+ retryable: false,
+ ...(NEXT_STEPS[code] ? { next_step: NEXT_STEPS[code] } : {}),
+ ...(Object.keys(details).length > 0 ? { details } : {}),
+ };
+}
+
+/**
+ * Path 2: the body is gone but the marker survived inside whatever text remains (Codex).
+ * Recovers `code` only — `next_step` and `details` need the body, and are omitted rather
+ * than backfilled from `NEXT_STEPS`, so a caller (WP19) can tell "only a code" from "the
+ * full envelope" and degrade to a generic step-up prompt instead of a specific one.
+ */
+function parseFromMarker(raw: string): AgentErrorDetail | undefined {
+ const match = raw.match(CODE_MARKER_RE);
+ if (!match) return undefined;
+ const code = match[1];
+ const message = raw.replace(CODE_MARKER_RE, "").trim() || raw;
+ return { code, message, retryable: false };
+}
diff --git a/services/runner/src/protocol.ts b/services/runner/src/protocol.ts
index b6a46a51cc..3e9d0d22ee 100644
--- a/services/runner/src/protocol.ts
+++ b/services/runner/src/protocol.ts
@@ -528,6 +528,20 @@ export interface ModelCredential {
usage: "opaque_http" | "local_use";
}
+/**
+ * OUR credentials for the gateway, bound to the header that carries them.
+ *
+ * Deliberately NOT a `ModelCredential` with a widened binding. A `ModelCredential` is a
+ * provider's secret and authenticates the gateway to that provider; this authenticates the
+ * caller as us, into the gateway. A header-bound value also has no environment variable to
+ * materialize into, so folding it into the credential union would produce a value that
+ * validates, crosses the wire, and then vanishes at `materializeModelEnvironment`.
+ */
+export interface GatewayCredentials {
+ header: string;
+ value: string;
+}
+
/**
* Everything the runner needs to reach the model, grouped under the consumer that owns it.
*
@@ -575,6 +589,9 @@ export interface ModelConnection {
credentialMode: "env" | "runtime_provided" | "none";
environment?: Record;
credentials: ModelCredential[];
+ /** Our own credentials for the gateway. Independent of `credentialMode`, which describes the
+ * provider's secret. Omitted when the model is not reached through a gateway. */
+ gatewayCredentials?: GatewayCredentials;
}
export interface AgentRunRequest {
@@ -717,6 +734,22 @@ export interface AgentRunRequest {
streamId?: string;
}
+/**
+ * The platform's agent-actionable error envelope (api/AGENTS.md "Domain-level exceptions"),
+ * carried onto the wire when a run's failure IS one — today, a gateway data-plane refusal
+ * (`model_not_allowed` / `endpoint_inactive` / `ceiling_exceeded` and siblings) relayed back
+ * through a harness's own error text. `code` is the stable lower-snake-case cause; `retryable`
+ * is about replaying the SAME request, never true for a policy/config refusal; `details` carries
+ * every error-specific field (never new top-level fields, matching the platform convention).
+ */
+export interface AgentErrorDetail {
+ code: string;
+ message: string;
+ retryable: boolean;
+ next_step?: string;
+ details?: Record;
+}
+
export interface AgentRunResult {
ok: boolean;
/** Final assistant text (what the playground renders). */
@@ -735,7 +768,15 @@ export interface AgentRunResult {
model?: string;
/** Trace id of the run (the caller's trace when a traceparent was passed). */
traceId?: string;
+ /** Human-facing summary; unchanged shape. Every failure keeps this even when `errorDetail` is
+ * also present, so a caller reading only this field never regresses. */
error?: string;
+ /**
+ * The same failure, structured, when the runner could recover a gateway refusal's cause from
+ * the harness's own error text (best-effort: absent when it could not — see
+ * `parseGatewayErrorDetail` in `gateway-error.ts`). Never present without `error`.
+ */
+ errorDetail?: AgentErrorDetail;
}
/**
diff --git a/services/runner/src/redaction.ts b/services/runner/src/redaction.ts
index 6c1b66cef8..996449b304 100644
--- a/services/runner/src/redaction.ts
+++ b/services/runner/src/redaction.ts
@@ -393,6 +393,7 @@ export interface RunSeedSource {
modelConnection?: {
environment?: Record;
credentials?: Array<{ value?: string }>;
+ gatewayCredentials?: { value?: string };
};
/** Resolved MCP servers: each connection's typed secret header credential values. */
mcpServers?: Array<{
@@ -419,6 +420,7 @@ export function requestSecretValues(
...(request.modelConnection?.credentials ?? []).map(
(credential) => credential.value,
),
+ request.modelConnection?.gatewayCredentials?.value,
...(request.mcpServers ?? []).flatMap((server) =>
(server.connection?.credentials ?? []).map(
(credential) => credential.value,
diff --git a/services/runner/src/tools/ssrf-guard-ranges.generated.json b/services/runner/src/tools/ssrf-guard-ranges.generated.json
new file mode 100644
index 0000000000..1480f96ad0
--- /dev/null
+++ b/services/runner/src/tools/ssrf-guard-ranges.generated.json
@@ -0,0 +1,33 @@
+{
+ "ipv4": [
+ "0.0.0.0/8",
+ "10.0.0.0/8",
+ "127.0.0.0/8",
+ "169.254.0.0/16",
+ "172.16.0.0/12",
+ "192.0.0.0/24",
+ "192.0.2.0/24",
+ "192.168.0.0/16",
+ "198.18.0.0/15",
+ "198.51.100.0/24",
+ "203.0.113.0/24",
+ "224.0.0.0/3"
+ ],
+ "ipv6": [
+ "::/3",
+ "2001::/23",
+ "2001:db8::/32",
+ "2002::/16",
+ "3fff::/20",
+ "4000::/2",
+ "8000::/2",
+ "c000::/3",
+ "e000::/4",
+ "f000::/5",
+ "f800::/6",
+ "fc00::/7",
+ "fe00::/9",
+ "fe80::/10",
+ "ff00::/8"
+ ]
+}
diff --git a/services/runner/src/tools/ssrf-guard.ts b/services/runner/src/tools/ssrf-guard.ts
index 32bf300cd5..edea178a3e 100644
--- a/services/runner/src/tools/ssrf-guard.ts
+++ b/services/runner/src/tools/ssrf-guard.ts
@@ -1,16 +1,23 @@
/**
* Shared SSRF guard: resolve a URL's host and reject it if any resolved address falls in a
- * blocked range. Mirrors the Python webhook validator (`api/oss/src/core/webhooks/utils.py`)
- * range-for-range so the two runtimes agree on what "internal" means — parity is the point
- * (this guard once drifted from the webhook one — keep them in sync).
+ * blocked range. The range tables (`ssrf-guard-ranges.generated.json`) are generated from
+ * Python's `ipaddress` module (`sdks/python/oss/tests/pytest/utils/ssrf_guard_vectors.py`),
+ * not hand-transcribed — a Python test regenerates and diffs them against the committed file,
+ * and `tests/unit/ssrf-guard-vectors.test.ts` asserts this guard's verdict against a shared
+ * fixture of boundary addresses labeled from the same source, so the two runtimes can't drift
+ * apart silently.
*
* Blocked = private/loopback/link-local/reserved/unspecified (IANA ipv4-special-registry,
- * i.e. Python's `ip.is_private`) OR multicast (224.0.0.0/4). IPv6 is checked against the
- * matching IANA ipv6-special-registry blocks, with IPv4-mapped/compatible addresses unwrapped
- * to their embedded IPv4 and checked against the IPv4 table.
+ * i.e. Python's `ip.is_private`) OR multicast. IPv6 is checked against the matching IANA
+ * ipv6-special-registry blocks, with IPv4-mapped/compatible addresses unwrapped to their
+ * embedded IPv4 and checked against the IPv4 table first — mirroring `ipaddress.IPv6Address`,
+ * which special-cases `ipv4_mapped` before falling back to IPv6 network membership.
*/
import { isIPv4, isIPv6 } from "node:net";
import { lookup as dnsLookup } from "node:dns/promises";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
const _TRUTHY = new Set([
"true",
@@ -43,6 +50,11 @@ export function insecureEgressAllowed(): boolean {
return _TRUTHY.has(raw.toLowerCase());
}
+const here = dirname(fileURLToPath(import.meta.url));
+const RANGES: { ipv4: string[]; ipv6: string[] } = JSON.parse(
+ readFileSync(join(here, "ssrf-guard-ranges.generated.json"), "utf-8"),
+);
+
/** [start, end] inclusive, both as 32-bit unsigned ints. */
type IPv4Range = [number, number];
@@ -53,7 +65,9 @@ function ipv4ToInt(ip: string): number {
);
}
-function cidr4(base: string, prefix: number): IPv4Range {
+function cidr4(cidr: string): IPv4Range {
+ const [base, prefixStr] = cidr.split("/");
+ const prefix = Number(prefixStr);
const start = ipv4ToInt(base);
const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
const network = (start & mask) >>> 0;
@@ -63,36 +77,32 @@ function cidr4(base: string, prefix: number): IPv4Range {
return [network, broadcast];
}
-/** IANA ipv4-special-registry "private" set — mirrors Python's `ipaddress._private_networks`. */
-const IPV4_PRIVATE_RANGES: IPv4Range[] = [
- cidr4("0.0.0.0", 8),
- cidr4("10.0.0.0", 8),
- cidr4("127.0.0.0", 8),
- cidr4("169.254.0.0", 16),
- cidr4("172.16.0.0", 12),
- cidr4("192.0.0.0", 29),
- cidr4("192.0.0.170", 31),
- cidr4("192.0.2.0", 24),
- cidr4("192.168.0.0", 16),
- cidr4("198.18.0.0", 15),
- cidr4("198.51.100.0", 24),
- cidr4("203.0.113.0", 24),
- cidr4("240.0.0.0", 4),
- cidr4("255.255.255.255", 32),
-];
-/** Multicast — not part of `is_private` in Python, checked as its own predicate. */
-const IPV4_MULTICAST: IPv4Range = cidr4("224.0.0.0", 4);
+const IPV4_RANGES: IPv4Range[] = RANGES.ipv4.map(cidr4);
function isBlockedIPv4(ip: string): boolean {
const n = ipv4ToInt(ip);
- return (
- IPV4_PRIVATE_RANGES.some(([lo, hi]) => n >= lo && n <= hi) ||
- (n >= IPV4_MULTICAST[0] && n <= IPV4_MULTICAST[1])
- );
+ return IPV4_RANGES.some(([lo, hi]) => n >= lo && n <= hi);
+}
+
+/** Rewrite a trailing embedded IPv4 dotted-quad (e.g. `::ffff:93.184.216.34`) to its two hex
+ * hextets, so `expandIPv6` sees pure hex groups regardless of whether the caller already
+ * ran the literal through `new URL()` (which normalizes this for us). */
+function normalizeEmbeddedIPv4(ip: string): string {
+ const lastColon = ip.lastIndexOf(":");
+ if (lastColon === -1) return ip;
+ const tail = ip.slice(lastColon + 1);
+ if (!tail.includes(".")) return ip;
+ const parts = tail.split(".").map(Number);
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255))
+ return ip;
+ const hi = ((parts[0] << 8) | parts[1]).toString(16);
+ const lo = ((parts[2] << 8) | parts[3]).toString(16);
+ return `${ip.slice(0, lastColon + 1)}${hi}:${lo}`;
}
/** Expand an IPv6 literal (already bracket-stripped) to 8 hextets, resolving `::`. */
-function expandIPv6(ip: string): number[] {
+function expandIPv6(rawIp: string): number[] {
+ const ip = normalizeEmbeddedIPv4(rawIp);
const [head, tail] = ip.split("::");
const headParts = head ? head.split(":").filter(Boolean) : [];
const tailParts = tail ? tail.split(":").filter(Boolean) : [];
@@ -102,6 +112,29 @@ function expandIPv6(ip: string): number[] {
return all.map((h) => parseInt(h, 16) || 0);
}
+function hextetsToBigInt(hextets: number[]): bigint {
+ let value = 0n;
+ for (const h of hextets) value = (value << 16n) | BigInt(h);
+ return value;
+}
+
+/** [start, end] inclusive, both as 128-bit unsigned bigints. */
+type IPv6Range = [bigint, bigint];
+
+const IPV6_MAX = (1n << 128n) - 1n;
+
+function cidr6(cidr: string): IPv6Range {
+ const [base, prefixStr] = cidr.split("/");
+ const prefix = Number(prefixStr);
+ const start = hextetsToBigInt(expandIPv6(base));
+ const mask = prefix === 0 ? 0n : (IPV6_MAX << BigInt(128 - prefix)) & IPV6_MAX;
+ const network = start & mask;
+ const broadcast = network | (~mask & IPV6_MAX);
+ return [network, broadcast];
+}
+
+const IPV6_RANGES: IPv6Range[] = RANGES.ipv6.map(cidr6);
+
/** Extract the embedded IPv4 from an IPv4-mapped (`::ffff:a.b.c.d`) or IPv4-compatible
* (`::a.b.c.d`) address, or `undefined` if this is not such an address. */
function embeddedIPv4(hextets: number[]): string | undefined {
@@ -123,26 +156,8 @@ function isBlockedIPv6(ip: string): boolean {
const mapped = embeddedIPv4(hextets);
if (mapped) return isBlockedIPv4(mapped);
- const isZero = (n: number) => n === 0;
- if (hextets.every(isZero)) return true; // :: (unspecified)
- if (hextets.slice(0, 7).every(isZero) && hextets[7] === 1) return true; // ::1 (loopback)
- if ((hextets[0] & 0xffc0) === 0xfe80) return true; // fe80::/10 (link-local)
- if ((hextets[0] & 0xfe00) === 0xfc00) return true; // fc00::/7 (unique-local/private)
- if ((hextets[0] & 0xff00) === 0xff00) return true; // ff00::/8 (multicast)
- if (
- hextets[0] === 0x0100 &&
- hextets[1] === 0 &&
- hextets[2] === 0 &&
- hextets[3] === 0
- )
- return true; // 100::/64 (discard-only)
- if (hextets[0] === 0x2001) {
- if (hextets[1] === 0 && (hextets[2] & 0xfe00) === 0) return true; // 2001::/23
- if (hextets[1] === 2 && hextets[2] === 0) return true; // 2001:2::/48
- if (hextets[1] === 0xdb8) return true; // 2001:db8::/32
- if ((hextets[1] & 0xfff0) === 0x10 && hextets[1] >> 4 === 1) return true; // 2001:10::/28
- }
- return false;
+ const n = hextetsToBigInt(hextets);
+ return IPV6_RANGES.some(([lo, hi]) => n >= lo && n <= hi);
}
/** True if `host` (a literal IPv4/IPv6 address) falls in a blocked range. */
diff --git a/services/runner/tests/acceptance/gateway-credentials-no-provider-secret.test.ts b/services/runner/tests/acceptance/gateway-credentials-no-provider-secret.test.ts
new file mode 100644
index 0000000000..a8e671bd58
--- /dev/null
+++ b/services/runner/tests/acceptance/gateway-credentials-no-provider-secret.test.ts
@@ -0,0 +1,104 @@
+/**
+ * Acceptance (WP13): a gateway-routed run reaches every harness with no provider secret in the
+ * sandbox environment. No deployed stack exists in this worktree (repo policy: write, don't run),
+ * so this drives the REAL environment-construction functions the runner uses for both the local
+ * and the Daytona path — `materializeModelEnvironment`, `applyClaudeConnectionEnv`,
+ * `buildPiModelConfigPlan`/`serializePiModelsJson`, `buildDaytonaSecretPlan` — against the shared
+ * golden gateway connection, and inspects what they actually produce (the env object, the
+ * models.json text, the Daytona secret plan), not the resolver's intent. It does not spawn a real
+ * sandbox or harness binary; that would need the deployed stack this constraint forbids running.
+ *
+ * launch-2.md's Checkpoint B acceptance: "no provider secret anywhere in the sandbox" and the
+ * secret arrays collapsing to one set of gateway credentials (specs-wp13.md).
+ */
+import { describe, it } from "vitest";
+import assert from "node:assert/strict";
+
+import { loadGolden } from "../utils/golden.ts";
+import type { AgentRunRequest, ModelConnection } from "../../src/protocol.ts";
+import { materializeModelEnvironment } from "../../src/engines/sandbox_agent/run-plan.ts";
+import { applyClaudeConnectionEnv } from "../../src/engines/sandbox_agent/runtime-policy.ts";
+import {
+ buildPiModelConfigPlan,
+ serializePiModelsJson,
+} from "../../src/engines/sandbox_agent/pi-model-config.ts";
+import { buildDaytonaSecretPlan } from "../../src/engines/sandbox_agent/daytona-secret-plan.ts";
+
+const GOLDEN = loadGolden("model_connection.gateway.json") as ModelConnection;
+const GATEWAY_VALUE = GOLDEN.gatewayCredentials!.value; // "ApiKey mock-gateway-credentials"
+
+// A provider-secret-shaped string. If this (or anything matching the real shape a resolver would
+// have emitted pre-gateway, e.g. "sk-...") ever appeared, the wave-2 property is broken.
+const PROVIDER_SECRET_MARKERS = ["sk-", "OPENAI_API_KEY=", "ANTHROPIC_API_KEY="];
+
+function assertNoProviderSecret(haystack: string): void {
+ for (const marker of PROVIDER_SECRET_MARKERS) {
+ assert.equal(
+ haystack.includes(marker),
+ false,
+ `provider-secret marker '${marker}' leaked into the sandbox environment`,
+ );
+ }
+}
+
+describe("gateway route -> no provider secret in the sandbox (local and Daytona shape)", () => {
+ it("materializeModelEnvironment: empty environment for a gateway connection", () => {
+ const request: AgentRunRequest = { modelConnection: GOLDEN };
+ const materialized = materializeModelEnvironment(request);
+ assert.equal(materialized.ok, true);
+ assert.ok(materialized.ok && Object.keys(materialized.environment).length === 0);
+ });
+
+ it("claude: the daemon env carries the gateway header and no provider key", () => {
+ const request: AgentRunRequest = { modelConnection: GOLDEN };
+ const env: Record = {};
+ applyClaudeConnectionEnv(env, request, "claude", () => {});
+ assert.equal(env.ANTHROPIC_API_KEY, undefined);
+ assert.equal(env.ANTHROPIC_AUTH_TOKEN, undefined);
+ assertNoProviderSecret(JSON.stringify(env));
+ assert.ok(env.ANTHROPIC_CUSTOM_HEADERS?.includes(GATEWAY_VALUE));
+ });
+
+ it("pi: the rendered models.json carries $ENV indirection, never a raw key or the gateway value on disk", () => {
+ const request: AgentRunRequest = {
+ modelConnection: GOLDEN,
+ harness: "pi_core",
+ connection: { mode: "agenta", slug: "gateway-conn" },
+ model: "gpt-5.5",
+ };
+ const plan = buildPiModelConfigPlan(request, {});
+ assert.ok(plan);
+ const text = serializePiModelsJson(plan);
+ assertNoProviderSecret(text);
+ assert.equal(text.includes(GATEWAY_VALUE), false); // the value never reaches disk
+ assert.equal(text.includes("$AGENTA_GATEWAY_CREDENTIALS_VALUE"), true);
+ });
+
+ it("daytona: the secret plan for a gateway connection hides nothing, because there is nothing left to hide", () => {
+ const plan = buildDaytonaSecretPlan({ modelConnection: GOLDEN });
+ assert.deepEqual(plan.candidates, []);
+ assert.deepEqual(plan.environment, {});
+ });
+
+ it("across every harness, the ONLY credential value ever set is our own gateway credential", () => {
+ const request: AgentRunRequest = {
+ modelConnection: GOLDEN,
+ harness: "pi_core",
+ connection: { mode: "agenta", slug: "gateway-conn" },
+ model: "gpt-5.5",
+ };
+ const claudeEnv: Record = {};
+ applyClaudeConnectionEnv(claudeEnv, request, "claude", () => {});
+ const piPlan = buildPiModelConfigPlan(request, {});
+ const piText = piPlan ? serializePiModelsJson(piPlan) : "";
+ const materialized = materializeModelEnvironment(request);
+
+ const everything = JSON.stringify({
+ claudeEnv,
+ piText,
+ materializedEnvironment: materialized.ok ? materialized.environment : {},
+ });
+ // The gateway value legitimately appears (Claude reads it directly); no OTHER secret does.
+ assertNoProviderSecret(everything);
+ });
+});
diff --git a/services/runner/tests/unit/daytona-secret-plan.test.ts b/services/runner/tests/unit/daytona-secret-plan.test.ts
index 0825fc3d45..9822c1d5be 100644
--- a/services/runner/tests/unit/daytona-secret-plan.test.ts
+++ b/services/runner/tests/unit/daytona-secret-plan.test.ts
@@ -339,3 +339,23 @@ describe("Daytona Secret planning", () => {
assert.equal(enabled.plan.credentials.daytonaSecretPlan?.candidates.length, 1);
});
});
+
+describe("Daytona Secret plan for a gateway connection (WP13 Phase 3)", () => {
+ it("is empty: no provider credentials to hide, since none were sent", () => {
+ const plan = buildDaytonaSecretPlan({
+ modelConnection: {
+ provider: "openai",
+ deployment: "custom",
+ credentialMode: "none",
+ credentials: [],
+ endpoint: { baseUrl: "https://gateway.example.com/gateways/llms/standard/openai" },
+ gatewayCredentials: {
+ header: "X-AG-Credentials",
+ value: "ApiKey mock-gateway-credentials",
+ },
+ },
+ });
+ assert.deepEqual(plan.candidates, []);
+ assert.deepEqual(plan.environment, {});
+ });
+});
diff --git a/services/runner/tests/unit/extension-tools.test.ts b/services/runner/tests/unit/extension-tools.test.ts
index 94c40d2875..1a142bef9c 100644
--- a/services/runner/tests/unit/extension-tools.test.ts
+++ b/services/runner/tests/unit/extension-tools.test.ts
@@ -117,6 +117,36 @@ describe("agenta extension model provider override", () => {
assert.deepEqual(pi.handlers, {});
});
+ it("carries OUR gateway credential and a placeholder apiKey onto the built-in provider override (WP13 reopen)", () => {
+ // A gateway-routed connection whose original deployment is "direct" (a plain provider_key
+ // connection, WP12's majority case) never goes through the custom-provider models.json path
+ // (isPiModelConfigApplicable requires a NAMED custom-agenta connection) -- this extension
+ // override is the ONLY place it can carry the header, or the run reaches the real provider
+ // with no credential and no visible failure.
+ clearEnv();
+ process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = JSON.stringify({
+ provider: "anthropic",
+ baseUrl: "https://gateway.example.com/gateways/llms/standard/anthropic",
+ headers: { "X-AG-Credentials": "ApiKey mock-gateway-credentials" },
+ apiKey: "agenta-gateway",
+ });
+ const pi = fakePi();
+
+ factory(pi as any);
+
+ assert.deepEqual(pi.registeredProviders, [
+ {
+ name: "anthropic",
+ config: {
+ baseUrl:
+ "https://gateway.example.com/gateways/llms/standard/anthropic",
+ headers: { "X-AG-Credentials": "ApiKey mock-gateway-credentials" },
+ apiKey: "agenta-gateway",
+ },
+ },
+ ]);
+ });
+
it("rejects malformed public override config before registration", () => {
clearEnv();
process.env[PI_MODEL_PROVIDER_OVERRIDE_ENV] = JSON.stringify({
@@ -538,4 +568,32 @@ describe("agenta extension: Pi dialog gate (approval parking)", () => {
);
rmSync(dir, { recursive: true, force: true });
});
+
+ it("WP26: request_connection's prompt guidance covers both the integration and the gateway-target call shapes", () => {
+ clearEnv();
+ process.env.AGENTA_AGENT_TOOLS_PUBLIC_SPECS = JSON.stringify([
+ { name: "request_connection", description: "connect", kind: "client" },
+ ]);
+ process.env.AGENTA_AGENT_TOOLS_RELAY_DIR = "/tmp/agenta-relay-unused";
+
+ const pi = fakePi();
+ factory(pi as any);
+ const tool = pi.registered[0];
+
+ assert.ok(
+ tool.promptGuidelines.some(
+ (line: string) => line.includes("integration") && line.includes("mode"),
+ ),
+ "still guides the existing external-integration call shape",
+ );
+ assert.ok(
+ tool.promptGuidelines.some(
+ (line: string) =>
+ line.includes("target:") &&
+ line.includes("plane") &&
+ line.includes("not registered"),
+ ),
+ "also guides the new gateway-target call shape",
+ );
+ });
});
diff --git a/services/runner/tests/unit/gateway-credentials.test.ts b/services/runner/tests/unit/gateway-credentials.test.ts
new file mode 100644
index 0000000000..429275c059
--- /dev/null
+++ b/services/runner/tests/unit/gateway-credentials.test.ts
@@ -0,0 +1,143 @@
+/**
+ * The gateway-credentials field, from the consumer side (wave 2's seed, D36 and D37).
+ *
+ * Asserts the SAME golden the SDK producer asserts in
+ * `sdks/python/oss/tests/pytest/unit/agents/test_gateway_credentials.py`, so a leg that drops
+ * the field fails here rather than at a run that quietly authenticates as nobody.
+ */
+import { describe, it } from "vitest";
+import assert from "node:assert/strict";
+
+import { loadGolden } from "../utils/golden.ts";
+import type { AgentRunRequest, ModelConnection } from "../../src/protocol.ts";
+import {
+ materializeGatewayHeaders,
+ materializeModelEnvironment,
+} from "../../src/engines/sandbox_agent/run-plan.ts";
+import { requestSecretValues } from "../../src/redaction.ts";
+import { applyClaudeConnectionEnv } from "../../src/engines/sandbox_agent/runtime-policy.ts";
+import { buildPiModelConfigPlan } from "../../src/engines/sandbox_agent/pi-model-config.ts";
+
+const GOLDEN = loadGolden("model_connection.gateway.json") as ModelConnection;
+
+function request(connection: ModelConnection): AgentRunRequest {
+ return { modelConnection: connection } as AgentRunRequest;
+}
+
+describe("gateway credentials on the wire", () => {
+ it("arrives from the shared golden with a header and a value", () => {
+ assert.equal(GOLDEN.gatewayCredentials?.header, "X-AG-Credentials");
+ assert.equal(
+ GOLDEN.gatewayCredentials?.value,
+ "ApiKey mock-gateway-credentials",
+ );
+ });
+
+ it("materializes as a header, and not into the environment", () => {
+ assert.deepEqual(materializeGatewayHeaders(request(GOLDEN)), {
+ "X-AG-Credentials": "ApiKey mock-gateway-credentials",
+ });
+
+ const materialized = materializeModelEnvironment(request(GOLDEN));
+ assert.equal(materialized.ok, true);
+ assert.deepEqual(materialized.ok && materialized.environment, {});
+ });
+
+ it("seeds the run's redaction deny-set", () => {
+ assert.ok(
+ requestSecretValues(request(GOLDEN)).includes(
+ "ApiKey mock-gateway-credentials",
+ ),
+ );
+ });
+
+ it("is absent, not empty, when the model is not reached through a gateway", () => {
+ const direct = { ...GOLDEN, gatewayCredentials: undefined };
+ assert.deepEqual(materializeGatewayHeaders(request(direct)), {});
+ });
+
+ it("refuses an empty header name or value", () => {
+ for (const gatewayCredentials of [
+ { header: " ", value: "ApiKey something" },
+ { header: "X-AG-Credentials", value: "" },
+ ]) {
+ const result = materializeModelEnvironment(
+ request({ ...GOLDEN, gatewayCredentials }),
+ );
+ assert.equal(result.ok, false);
+ }
+ });
+
+ it("refuses a plaintext hop to a remote host and allows one to loopback", () => {
+ const remote = materializeModelEnvironment(
+ request({
+ ...GOLDEN,
+ endpoint: { baseUrl: "http://gateway.example.com" },
+ }),
+ );
+ assert.equal(remote.ok, false);
+
+ const loopback = materializeModelEnvironment(
+ request({ ...GOLDEN, endpoint: { baseUrl: "http://localhost:8000" } }),
+ );
+ assert.equal(loopback.ok, true);
+ });
+
+ it("refuses provider credentials riding alongside a gateway credential", () => {
+ const both = materializeModelEnvironment(
+ request({
+ ...GOLDEN,
+ credentialMode: "env",
+ credentials: [
+ {
+ binding: { kind: "environment", name: "OPENAI_API_KEY" },
+ value: "sk-should-not-be-here",
+ usage: "opaque_http",
+ },
+ ],
+ }),
+ );
+ assert.equal(both.ok, false);
+ });
+});
+
+describe("gateway credentials, per harness (WP13 Phase 2)", () => {
+ const goldenRequest = request(GOLDEN);
+
+ it("claude: carries the header in ANTHROPIC_CUSTOM_HEADERS, and no provider secret", () => {
+ const env: Record = {};
+ applyClaudeConnectionEnv(env, goldenRequest, "claude", () => {});
+ assert.equal(
+ env.ANTHROPIC_CUSTOM_HEADERS,
+ "X-AG-Credentials: ApiKey mock-gateway-credentials",
+ );
+ assert.equal(env.ANTHROPIC_API_KEY, undefined);
+ assert.equal(env.ANTHROPIC_AUTH_TOKEN, undefined);
+ });
+
+ it("pi: carries the header in models.json via $ENV indirection, and no raw value on disk", () => {
+ const piRequest: AgentRunRequest = {
+ ...goldenRequest,
+ harness: "pi_core",
+ connection: { mode: "agenta", slug: "gateway-conn" },
+ model: "gpt-5.5",
+ };
+ const plan = buildPiModelConfigPlan(piRequest, {});
+ assert.ok(plan);
+ assert.deepEqual(plan.headers, {
+ "X-AG-Credentials": "$AGENTA_GATEWAY_CREDENTIALS_VALUE",
+ });
+ assert.equal(
+ JSON.stringify(plan).includes("ApiKey mock-gateway-credentials"),
+ false,
+ );
+ });
+
+ it("claude never sees a provider API key on a gateway connection", () => {
+ const env: Record = {};
+ applyClaudeConnectionEnv(env, goldenRequest, "claude", () => {});
+ assert.equal(env.ANTHROPIC_API_KEY, undefined);
+ assert.equal(env.ANTHROPIC_AUTH_TOKEN, undefined);
+ assert.ok(env.ANTHROPIC_CUSTOM_HEADERS);
+ });
+});
diff --git a/services/runner/tests/unit/gateway-error-harness-formats.test.ts b/services/runner/tests/unit/gateway-error-harness-formats.test.ts
new file mode 100644
index 0000000000..c0efc7036a
--- /dev/null
+++ b/services/runner/tests/unit/gateway-error-harness-formats.test.ts
@@ -0,0 +1,222 @@
+/**
+ * Pins OD18's per-harness findings (open-designs.md): does a harness's SDK preserve the LLM
+ * gateway's `{"error":{...}}` refusal body in the text `parseGatewayErrorDetail` scans, and —
+ * when it does not — does the `⟦agenta_code:...⟧` marker the gateway now embeds in every typed
+ * refusal's `message` (`gateways/utils.py::with_code_marker`, shared by both
+ * `gateways/llms/proxy.py` and `gateways/mcps/proxy.py`) survive instead?
+ *
+ * Pi (`utils/error-body.js`) and the Anthropic SDK (`core/error.js`'s `JSON.stringify`
+ * fallback) both fold the full LLM-plane body into the reported message — the body path wins
+ * for them, recovering the full `AgentErrorDetail`. Codex (`codex-rs`'s
+ * `extract_error_message`) strips everything but `error.message` — but the marker rides
+ * INSIDE that one surviving field, so the marker fallback recovers `code` (and only `code`)
+ * for Codex.
+ *
+ * The MCP plane's wire shape (JSON-RPC) never matches the LLM-plane body scan at all (its
+ * stable cause lives at `error.data.cause`, under a numeric `error.code` the scan doesn't
+ * recognize as ours) — so for that plane the marker is not a fallback for one harness, it is
+ * the only channel, on every harness, proven separately below.
+ */
+import { describe, it } from "vitest";
+import assert from "node:assert/strict";
+
+import { parseGatewayErrorDetail } from "../../src/gateway-error.ts";
+
+interface Refusal {
+ name: string;
+ status: number;
+ code: string;
+ message: string;
+ type: string;
+ nextStep?: string;
+ extra?: Record;
+}
+
+// The five refusals launch-3.md names, with the codes `_map_domain_exception`
+// (api/oss/src/apis/fastapi/gateways/llms/proxy.py) actually raises for each.
+const REFUSALS: Refusal[] = [
+ {
+ name: "missing credential",
+ status: 409,
+ code: "secret_missing",
+ message: "No project secret for anthropic under mode standard",
+ type: "invalid_request_error",
+ nextStep: "configure the connection's secret",
+ },
+ {
+ name: "rejected credential",
+ status: 409,
+ code: "secret_invalid",
+ message: "Secret for anthropic:project-42 is invalid",
+ type: "invalid_request_error",
+ nextStep: "reconnect the connection's secret",
+ },
+ {
+ name: "unregistered target",
+ status: 404,
+ code: "endpoint_not_found",
+ message: "No endpoint named 'staging-claude'",
+ type: "invalid_request_error",
+ nextStep: "check the endpoint configuration",
+ },
+ {
+ name: "disallowed model",
+ status: 403,
+ code: "model_not_allowed",
+ message: "model not allowed: gpt-5.5-experimental",
+ type: "invalid_request_error",
+ nextStep: "choose a model the connection allows",
+ },
+ {
+ name: "deactivated endpoint",
+ status: 403,
+ code: "endpoint_inactive",
+ message: "Endpoint 'prod-openai' is inactive",
+ type: "invalid_request_error",
+ nextStep: "reactivate the endpoint, or choose another",
+ },
+];
+
+// What the gateway actually renders into `message` for a typed refusal (_with_code_marker,
+// proxy.py) -- the marker rides inside the one field every harness examined keeps.
+function markedMessage(r: Refusal): string {
+ return `${r.message} ⟦agenta_code:${r.code}⟧`;
+}
+
+function gatewayBody(r: Refusal): string {
+ return JSON.stringify({
+ error: { message: markedMessage(r), type: r.type, code: r.code, ...r.extra },
+ });
+}
+
+describe("Pi / Anthropic-SDK shape (OD18: body survives -> full detail)", () => {
+ for (const r of REFUSALS) {
+ it(`recovers the full envelope for ${r.code} (${r.name}) via the body path`, () => {
+ // Mirrors `formatProviderError`'s ": " composition (pi-ai's
+ // utils/error-body.js) and @anthropic-ai/sdk's `APIError.makeMessage`'s
+ // " " fallback -- both land the full body
+ // verbatim, marker included, in the text the runner reads.
+ const harnessText = `${r.status}: ${gatewayBody(r)}`;
+ const detail = parseGatewayErrorDetail(harnessText);
+ assert.equal(detail?.code, r.code);
+ // The body path's `message` is the gateway's raw field, marker and all -- the JSON
+ // parse doesn't know to strip it. Only the marker-only fallback strips it (below).
+ assert.equal(detail?.message, markedMessage(r));
+ assert.equal(detail?.retryable, false);
+ if (r.nextStep) assert.equal(detail?.next_step, r.nextStep);
+ });
+ }
+});
+
+describe("Codex shape (OD18: body is stripped -> marker fallback recovers code only)", () => {
+ for (const r of REFUSALS) {
+ it(`recovers ${r.code} (${r.name}) from codex-rs's stripped format via the marker`, () => {
+ // codex-rs's `UnexpectedResponseError::extract_error_message`
+ // (codex-rs/protocol/src/error.rs, rust-v0.145.0) parses the body as JSON and keeps
+ // ONLY `error.message`, discarding `code`/`type` before formatting this string -- but
+ // the marker rides inside that surviving `message`, so it comes along for the ride.
+ const harnessText = `unexpected status ${r.status}: ${markedMessage(r)}`;
+ const detail = parseGatewayErrorDetail(harnessText);
+ assert.equal(detail?.code, r.code);
+ // The marker is stripped from the recovered message for display.
+ assert.equal(detail?.message, `unexpected status ${r.status}: ${r.message}`);
+ assert.equal(detail?.retryable, false);
+ // What's still lost on a marker-only harness (WP25 spec): no next_step, no details --
+ // never backfilled from NEXT_STEPS, so a caller can tell "code only" from "full detail"
+ // and degrade to a generic step-up prompt (WP19) instead of a specific one.
+ assert.equal(detail?.next_step, undefined);
+ assert.equal(detail?.details, undefined);
+ });
+ }
+});
+
+describe("upstream_error: no marker, by design (D16 passthrough)", () => {
+ it("stays undefined when neither the body nor a marker is present", () => {
+ const detail = parseGatewayErrorDetail("unexpected status 401: invalid api key");
+ assert.equal(detail, undefined);
+ });
+});
+
+// The MCP plane (`gateways/mcps/proxy.py::_map_gateway_exception`) shares the same
+// `with_code_marker` helper (`gateways/utils.py`) but a DIFFERENT wire shape: a JSON-RPC
+// error result whose stable identifier is `error.data.cause` (a string) under a numeric
+// `error.code` (JSON-RPC's own reserved code, e.g. -32000) -- not the LLM plane's
+// `error.code` string `parseFromBody` looks for. So the body path never recognizes an MCP
+// refusal at all, marker or not; the marker fallback is the ONLY channel that reaches an
+// MCP cause, on every harness, not just the ones that fail the LLM plane's body scan.
+const MCP_REFUSALS: Refusal[] = [
+ {
+ name: "missing credential",
+ status: 409,
+ code: "secret_missing",
+ message: "No project secret for acme-notion under mode standard",
+ type: "invalid_request_error",
+ },
+ {
+ name: "rejected credential",
+ status: 409,
+ code: "secret_invalid",
+ message: "Secret for custom/acme-notion is invalid",
+ type: "invalid_request_error",
+ },
+ {
+ name: "unregistered target",
+ status: 404,
+ code: "endpoint_not_found",
+ message: "No endpoint named 'acme-notion'",
+ type: "invalid_request_error",
+ },
+ {
+ name: "deactivated endpoint",
+ status: 403,
+ code: "endpoint_inactive",
+ message: "Endpoint 'acme-notion' is inactive",
+ type: "invalid_request_error",
+ },
+ {
+ // WP19: the step-up scope challenge, MCP-only (no LLM-plane equivalent). Raised for the
+ // first time by this package (`core/gateways/mcps/service.py`'s scope-challenge detection);
+ // added here so its marker recovery is pinned same as the four pre-existing MCP causes.
+ name: "insufficient scope (step-up)",
+ status: 409,
+ code: "scope_insufficient",
+ message: "Additional scopes required for custom/acme-notion: ['write']",
+ type: "invalid_request_error",
+ },
+];
+
+function mcpJsonRpcBody(r: Refusal): string {
+ return JSON.stringify({
+ jsonrpc: "2.0",
+ id: null,
+ error: { code: -32000, message: markedMessage(r), data: { cause: r.code } },
+ });
+}
+
+describe("MCP plane, JSON-RPC shape embedded verbatim (body scan doesn't recognize it -> marker still recovers code)", () => {
+ for (const r of MCP_REFUSALS) {
+ it(`recovers ${r.code} (${r.name}) even with the full JSON-RPC body intact`, () => {
+ // The JSON-RPC body's own `error.code` is a NUMBER (-32000), not our string cause, so
+ // parseFromBody's `typeof body.code === "string"` check fails here even when a harness
+ // preserves the whole body verbatim -- this proves the marker is not merely a Codex
+ // fallback, it is the only channel for this plane's shape, full body or not.
+ const harnessText = `MCP tool call failed: ${mcpJsonRpcBody(r)}`;
+ const detail = parseGatewayErrorDetail(harnessText);
+ assert.equal(detail?.code, r.code);
+ assert.equal(detail?.retryable, false);
+ });
+ }
+});
+
+describe("MCP plane, Codex-stripped shape (message only, marker still recovers code)", () => {
+ for (const r of MCP_REFUSALS) {
+ it(`recovers ${r.code} (${r.name}) from an MCP refusal reduced to its bare message`, () => {
+ const harnessText = `MCP error: ${markedMessage(r)}`;
+ const detail = parseGatewayErrorDetail(harnessText);
+ assert.equal(detail?.code, r.code);
+ assert.equal(detail?.message, `MCP error: ${r.message}`);
+ assert.equal(detail?.next_step, undefined);
+ assert.equal(detail?.details, undefined);
+ });
+ }
+});
diff --git a/services/runner/tests/unit/gateway-error.test.ts b/services/runner/tests/unit/gateway-error.test.ts
new file mode 100644
index 0000000000..5c0fae63e1
--- /dev/null
+++ b/services/runner/tests/unit/gateway-error.test.ts
@@ -0,0 +1,103 @@
+/**
+ * `parseGatewayErrorDetail`: best-effort recovery of the gateway's structured refusal from a
+ * harness-reported error string (WP13, per launch-2.md's Checkpoint B acceptance: a model the
+ * connection may not use, or a deactivated endpoint, must fail with the cause named).
+ */
+import { describe, it } from "vitest";
+import assert from "node:assert/strict";
+
+import { parseGatewayErrorDetail } from "../../src/gateway-error.ts";
+import { withGatewayErrorDetail } from "../../src/engines/sandbox_agent/engine.ts";
+
+const GATEWAY_BODY = JSON.stringify({
+ error: {
+ message: "model not allowed: gpt-5.5-experimental",
+ type: "invalid_request_error",
+ code: "model_not_allowed",
+ },
+});
+
+describe("parseGatewayErrorDetail", () => {
+ it("recovers code/message/next_step from a raw embedded gateway body", () => {
+ const detail = parseGatewayErrorDetail(GATEWAY_BODY);
+ assert.deepEqual(detail, {
+ code: "model_not_allowed",
+ message: "model not allowed: gpt-5.5-experimental",
+ retryable: false,
+ next_step: "choose a model the connection allows",
+ details: { type: "invalid_request_error" },
+ });
+ });
+
+ it("recovers the body when a harness SDK prefixes it with its own text", () => {
+ const detail = parseGatewayErrorDetail(
+ `OpenAI API error 403 Forbidden: ${GATEWAY_BODY}\nat Foo.bar (/app/x.js:1:1)`,
+ );
+ assert.equal(detail?.code, "model_not_allowed");
+ });
+
+ it("carries the ceiling_exceeded extras into details", () => {
+ const detail = parseGatewayErrorDetail(
+ JSON.stringify({
+ error: {
+ message: "max_tokens exceeds the endpoint ceiling",
+ type: "invalid_request_error",
+ code: "ceiling_exceeded",
+ ceiling: 4096,
+ requested: 8192,
+ allowed: 4096,
+ },
+ }),
+ );
+ assert.equal(detail?.code, "ceiling_exceeded");
+ assert.equal(detail?.retryable, false);
+ assert.deepEqual(detail?.details, {
+ type: "invalid_request_error",
+ ceiling: 4096,
+ requested: 8192,
+ allowed: 4096,
+ });
+ });
+
+ it("is undefined for a plain harness error with no embedded gateway body", () => {
+ assert.equal(
+ parseGatewayErrorDetail("claude: model authentication failed"),
+ undefined,
+ );
+ });
+
+ it("is undefined for undefined input, malformed JSON, and a body missing code/message", () => {
+ assert.equal(parseGatewayErrorDetail(undefined), undefined);
+ assert.equal(parseGatewayErrorDetail("not json {here"), undefined);
+ assert.equal(
+ parseGatewayErrorDetail(JSON.stringify({ error: { type: "x" } })),
+ undefined,
+ );
+ });
+});
+
+describe("withGatewayErrorDetail (the engine's choke point)", () => {
+ it("attaches errorDetail to a failed result whose error embeds a gateway refusal", () => {
+ const result = withGatewayErrorDetail({ ok: false, error: GATEWAY_BODY });
+ assert.equal(result.errorDetail?.code, "model_not_allowed");
+ assert.equal(result.error, GATEWAY_BODY); // the plain string is unchanged
+ });
+
+ it("leaves an ok:true result and a plain-string failure untouched", () => {
+ const ok = withGatewayErrorDetail({ ok: true, output: "hi" });
+ assert.equal(ok.errorDetail, undefined);
+
+ const plain = withGatewayErrorDetail({ ok: false, error: "boom" });
+ assert.equal(plain.errorDetail, undefined);
+ assert.equal(plain.error, "boom");
+ });
+
+ it("never overwrites an errorDetail the caller already set", () => {
+ const result = withGatewayErrorDetail({
+ ok: false,
+ error: GATEWAY_BODY,
+ errorDetail: { code: "already_set", message: "x", retryable: false },
+ });
+ assert.equal(result.errorDetail?.code, "already_set");
+ });
+});
diff --git a/services/runner/tests/unit/sandbox-agent-mount.test.ts b/services/runner/tests/unit/sandbox-agent-mount.test.ts
index 49bb094b72..3018300541 100644
--- a/services/runner/tests/unit/sandbox-agent-mount.test.ts
+++ b/services/runner/tests/unit/sandbox-agent-mount.test.ts
@@ -170,7 +170,10 @@ describe("harnessSessionMounts (S4)", () => {
});
it("an unknown/unlisted harness mounts nothing (callers fall back to cwd only)", () => {
- assert.deepEqual(harnessSessionMounts("unknown-harness", "/home/agent"), []);
+ assert.deepEqual(
+ harnessSessionMounts("unknown-harness", "/home/agent"),
+ [],
+ );
});
});
@@ -184,26 +187,32 @@ describe("mountHarnessSessionDirs (S4, remote-only)", () => {
];
const sandbox = { runProcess: async () => ({ exitCode: 0 }) };
- await mountHarnessSessionDirs(sandbox, "sess-1", dirs, "https://tunnel.example", {
- apiBase: "http://api:8000",
- authorization: "ApiKey abc",
- log: SILENT,
- signSessionMountCredentials: async (_sessionId, _deps, name) => {
- signedNames.push(name ?? "cwd");
- if (name === "pi-sessions") return null; // simulate one failed sign
- return {
- region: "us-east-1",
- bucket: "agenta-store",
- prefix: `mounts/proj-1/${name}`,
- accessKey: "AK",
- secretKey: "SK",
- };
- },
- mountStorageRemote: async (_sandbox, path) => {
- mountedPaths.push(path);
- return true;
+ await mountHarnessSessionDirs(
+ sandbox,
+ "sess-1",
+ dirs,
+ "https://tunnel.example",
+ {
+ apiBase: "http://api:8000",
+ authorization: "ApiKey abc",
+ log: SILENT,
+ signSessionMountCredentials: async (_sessionId, _deps, name) => {
+ signedNames.push(name ?? "cwd");
+ if (name === "pi-sessions") return null; // simulate one failed sign
+ return {
+ region: "us-east-1",
+ bucket: "agenta-store",
+ prefix: `mounts/proj-1/${name}`,
+ accessKey: "AK",
+ secretKey: "SK",
+ };
+ },
+ mountStorageRemote: async (_sandbox, path) => {
+ mountedPaths.push(path);
+ return true;
+ },
},
- });
+ );
assert.deepEqual(signedNames, ["claude-projects", "pi-sessions"]);
// Only the successfully-signed dir got mounted; the failed one was skipped, not fatal.
@@ -213,15 +222,21 @@ describe("mountHarnessSessionDirs (S4, remote-only)", () => {
it("is a no-op for an empty dir list", async () => {
let called = false;
const sandbox = { runProcess: async () => ({ exitCode: 0 }) };
- await mountHarnessSessionDirs(sandbox, "sess-1", [], "https://tunnel.example", {
- apiBase: "http://api:8000",
- authorization: "ApiKey abc",
- log: SILENT,
- signSessionMountCredentials: async () => {
- called = true;
- return null;
+ await mountHarnessSessionDirs(
+ sandbox,
+ "sess-1",
+ [],
+ "https://tunnel.example",
+ {
+ apiBase: "http://api:8000",
+ authorization: "ApiKey abc",
+ log: SILENT,
+ signSessionMountCredentials: async () => {
+ called = true;
+ return null;
+ },
},
- });
+ );
assert.equal(called, false);
});
});
@@ -354,7 +369,9 @@ describe("mountStorage", () => {
runGeesefs: async () => ({
stop: async () => {
calls.push("stop-unconfirmed");
- throw new Error("geesefs process did not exit after SIGTERM and SIGKILL");
+ throw new Error(
+ "geesefs process did not exit after SIGTERM and SIGKILL",
+ );
},
}),
unmountDeps: {
@@ -371,27 +388,30 @@ describe("mountStorage", () => {
});
for (const detachState of ["mounted", "inconclusive"] as const) {
- it("fails clearly when the failed mount remains " + detachState, async () => {
- let probes = 0;
- await assert.rejects(
- mountStorage("/work/cwd", CREDS, {
- checkMounted: async () => false,
- runGeesefs: async () => {
- throw new Error("fuse: device not found");
- },
- unmountDeps: {
- runUnmount: async () => {},
- checkMountpoint: async () => {
- probes += 1;
- return probes === 1 ? "gone" : detachState;
+ it(
+ "fails clearly when the failed mount remains " + detachState,
+ async () => {
+ let probes = 0;
+ await assert.rejects(
+ mountStorage("/work/cwd", CREDS, {
+ checkMounted: async () => false,
+ runGeesefs: async () => {
+ throw new Error("fuse: device not found");
},
- },
- log: SILENT,
- }),
- /detach could not be confirmed.*refusing ephemeral cwd fallback.*fuse: device not found/,
- );
- assert.equal(probes, 2);
- });
+ unmountDeps: {
+ runUnmount: async () => {},
+ checkMountpoint: async () => {
+ probes += 1;
+ return probes === 1 ? "gone" : detachState;
+ },
+ },
+ log: SILENT,
+ }),
+ /detach could not be confirmed.*refusing ephemeral cwd fallback.*fuse: device not found/,
+ );
+ assert.equal(probes, 2);
+ },
+ );
}
});
@@ -468,7 +488,7 @@ describe("unmountStorage confirmation", () => {
describe("discoverTunnelEndpoint (remote)", () => {
it("prefers the https public_url from the ngrok agent API", async () => {
const url = await discoverTunnelEndpoint({
- ngrokApi: "http://ngrok:4040",
+ ngrokApi: "http://ngrok-mounts:4040",
fetchImpl: (async () =>
okResponse({
tunnels: [
@@ -490,6 +510,113 @@ describe("discoverTunnelEndpoint (remote)", () => {
assert.equal(url, null);
});
+ it("picks the tunnel forwarding to the store, not the first one listed", async () => {
+ const url = await discoverTunnelEndpoint({
+ storeEndpoint: "http://seaweedfs:8333",
+ fetchImpl: (async () =>
+ okResponse({
+ tunnels: [
+ {
+ proto: "https",
+ public_url: "https://ingress.example",
+ config: { addr: "http://traefik:80" },
+ },
+ {
+ proto: "https",
+ public_url: "https://store.example",
+ config: { addr: "http://seaweedfs:8333" },
+ },
+ ],
+ })) as unknown as typeof fetch,
+ log: SILENT,
+ });
+ assert.equal(url, "https://store.example");
+ });
+
+ it("returns null when the store endpoint itself cannot be parsed", async () => {
+ // Falling through to "first https tunnel wins" here would hand back the ingress
+ // tunnel, which is the failure this matching exists to prevent.
+ const url = await discoverTunnelEndpoint({
+ storeEndpoint: " ",
+ fetchImpl: (async () =>
+ okResponse({
+ tunnels: [
+ {
+ proto: "https",
+ public_url: "https://ingress.example",
+ config: { addr: "http://traefik:80" },
+ },
+ ],
+ })) as unknown as typeof fetch,
+ log: SILENT,
+ });
+ assert.equal(url, null);
+ });
+
+ it("prefers the https listing when the store's upstream is listed twice", async () => {
+ // One `ngrok http` can appear as both an http and an https entry over the same
+ // upstream. geesefs would then reach the store unencrypted over the internet.
+ const url = await discoverTunnelEndpoint({
+ storeEndpoint: "http://seaweedfs:8333",
+ fetchImpl: (async () =>
+ okResponse({
+ tunnels: [
+ {
+ proto: "http",
+ public_url: "http://store.example",
+ config: { addr: "http://seaweedfs:8333" },
+ },
+ {
+ proto: "https",
+ public_url: "https://store.example",
+ config: { addr: "http://seaweedfs:8333" },
+ },
+ ],
+ })) as unknown as typeof fetch,
+ log: SILENT,
+ });
+ assert.equal(url, "https://store.example");
+ });
+
+ it("returns null rather than another tunnel's URL when none forwards to the store", async () => {
+ // The development compose files point the agent at the platform ingress, so a
+ // live tunnel is no longer evidence that the store is reachable. Handing back
+ // the ingress URL would mount an HTTP API as an object store.
+ const url = await discoverTunnelEndpoint({
+ storeEndpoint: "http://seaweedfs:8333",
+ fetchImpl: (async () =>
+ okResponse({
+ tunnels: [
+ {
+ proto: "https",
+ public_url: "https://ingress.example",
+ config: { addr: "http://traefik:80" },
+ },
+ ],
+ })) as unknown as typeof fetch,
+ log: SILENT,
+ });
+ assert.equal(url, null);
+ });
+
+ it("matches the upstream on host and port however the agent spells it", async () => {
+ const url = await discoverTunnelEndpoint({
+ storeEndpoint: "http://seaweedfs:8333",
+ fetchImpl: (async () =>
+ okResponse({
+ tunnels: [
+ {
+ proto: "https",
+ public_url: "https://store.example",
+ config: { addr: "seaweedfs:8333" },
+ },
+ ],
+ })) as unknown as typeof fetch,
+ log: SILENT,
+ });
+ assert.equal(url, "https://store.example");
+ });
+
it("returns null when the agent API is unreachable", async () => {
const url = await discoverTunnelEndpoint({
fetchImpl: (async () => {
@@ -519,10 +646,17 @@ describe("mountStorageRemote", () => {
});
assert.equal(ok, true);
- const unmountIndex = commands.findIndex((command) => command.includes("fusermount -u"));
- const mountIndex = commands.findIndex((command) => command.includes("geesefs --log-file"));
+ const unmountIndex = commands.findIndex((command) =>
+ command.includes("fusermount -u"),
+ );
+ const mountIndex = commands.findIndex((command) =>
+ command.includes("geesefs --log-file"),
+ );
assert.ok(unmountIndex >= 0);
- assert.ok(mountIndex > unmountIndex, "unmount attempt precedes the geesefs mount");
+ assert.ok(
+ mountIndex > unmountIndex,
+ "unmount attempt precedes the geesefs mount",
+ );
});
it("execs geesefs IN the sandbox with the tunnel endpoint and creds in env", async () => {
@@ -597,7 +731,10 @@ describe("mountStorageRemote", () => {
// A foreground geesefs (-f) never returns, so `runProcess` blocks until its timeout kills
// the mount it just made; the trailing `&` backgrounds it instead.
- assert.ok(!/\s-f(\s|$)/.test(shellCmd), "remote geesefs must not run foreground");
+ assert.ok(
+ !/\s-f(\s|$)/.test(shellCmd),
+ "remote geesefs must not run foreground",
+ );
assert.ok(shellCmd.trimEnd().endsWith("&"), "geesefs must be backgrounded");
});
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 264f2ae8b9..48fa965385 100644
--- a/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts
+++ b/services/runner/tests/unit/sandbox-agent-pi-assets.test.ts
@@ -127,6 +127,57 @@ describe("buildPiExtensionEnv", () => {
assert.equal(JSON.stringify(env).includes("PUBLIC_HINT"), false);
});
+ it("a direct-deployment gateway connection carries the base URL and X-AG-Credentials (WP13 reopen)", () => {
+ // The majority case (a plain provider_key vault connection, deployment "direct") is NOT a
+ // named custom-agenta connection, so isPiModelConfigApplicable is false and this extension
+ // override is the only place the gateway route can reach Pi. Before this fix the override
+ // carried baseUrl alone -- no header, so the gateway would refuse the call for missing
+ // credentials with nothing telling the caller why.
+ const request = {
+ harness: "pi_core",
+ modelConnection: {
+ provider: "anthropic",
+ deployment: "direct",
+ endpoint: { baseUrl: "https://gateway.example.com/gateways/llms/standard/anthropic" },
+ credentialMode: "none",
+ credentials: [],
+ gatewayCredentials: {
+ header: "X-AG-Credentials",
+ value: "ApiKey mock-gateway-credentials",
+ },
+ },
+ } as AgentRunRequest;
+
+ const env = buildPiExtensionEnv(request, false);
+
+ assert.deepEqual(JSON.parse(env[PI_MODEL_PROVIDER_OVERRIDE_ENV]), {
+ provider: "anthropic",
+ baseUrl: "https://gateway.example.com/gateways/llms/standard/anthropic",
+ headers: { "X-AG-Credentials": "ApiKey mock-gateway-credentials" },
+ apiKey: "agenta-gateway",
+ });
+ });
+
+ it("a non-gateway direct-deployment connection carries no headers or placeholder key (unchanged)", () => {
+ const request = {
+ harness: "pi_core",
+ modelConnection: {
+ provider: "anthropic",
+ deployment: "claude-sonnet-4-5",
+ endpoint: { baseUrl: "https://proxy.example.test/anthropic" },
+ credentialMode: "env",
+ credentials: [],
+ },
+ } 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",
+ });
+ });
+
it("rejects malformed provider endpoint overrides", () => {
const request = (provider: string, baseUrl: string) =>
({
diff --git a/services/runner/tests/unit/ssrf-guard-vectors.test.ts b/services/runner/tests/unit/ssrf-guard-vectors.test.ts
new file mode 100644
index 0000000000..009677dbed
--- /dev/null
+++ b/services/runner/tests/unit/ssrf-guard-vectors.test.ts
@@ -0,0 +1,41 @@
+/**
+ * Cross-language agreement check for CU12: loads the same boundary-address fixture the
+ * Python suite asserts (`sdks/python/oss/tests/pytest/unit/golden/ssrf_guard_vectors.json`,
+ * labeled from Python's `ipaddress` module — the ground truth) and asserts this guard's
+ * verdict against it. A range edited on only one side of the guard flips a vector's label
+ * and turns this test red.
+ *
+ * Run: pnpm test (or: pnpm exec vitest run tests/unit/ssrf-guard-vectors.test.ts)
+ */
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { describe, it } from "vitest";
+import assert from "node:assert/strict";
+
+import { isBlockedIpLiteral } from "../../src/tools/ssrf-guard.ts";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const VECTORS_PATH = join(
+ here,
+ "../../../../sdks/python/oss/tests/pytest/unit/golden/ssrf_guard_vectors.json",
+);
+
+interface Vector {
+ host: string;
+ blocked: boolean;
+}
+
+const vectors: Vector[] = JSON.parse(readFileSync(VECTORS_PATH, "utf-8"));
+
+describe("isBlockedIpLiteral agrees with the Python-generated vector fixture", () => {
+ it(`has a non-trivial vector set (${vectors.length} entries)`, () => {
+ assert.ok(vectors.length > 40);
+ });
+
+ for (const { host, blocked } of vectors) {
+ it(`${host} -> blocked=${blocked}`, () => {
+ assert.equal(isBlockedIpLiteral(host), blocked, host);
+ });
+ }
+});
diff --git a/services/runner/tests/unit/wire-contract.test.ts b/services/runner/tests/unit/wire-contract.test.ts
index de75a80310..f1f1709696 100644
--- a/services/runner/tests/unit/wire-contract.test.ts
+++ b/services/runner/tests/unit/wire-contract.test.ts
@@ -369,6 +369,20 @@ describe("wire contract: results (vs Python golden)", () => {
assert.equal(res.error, "model exploded");
});
+ it("error result with errorDetail: a gateway refusal survives structured (WP13)", () => {
+ const res = loadGolden("run_result.error_detail.json") as AgentRunResult;
+ assert.equal(res.ok, false);
+ assert.equal(res.errorDetail?.code, "model_not_allowed");
+ assert.equal(res.errorDetail?.retryable, false);
+ assert.equal(
+ res.errorDetail?.next_step,
+ "choose a model the connection allows",
+ );
+ assert.deepEqual(res.errorDetail?.details, {
+ type: "invalid_request_error",
+ });
+ });
+
it("minimal ok result: bare success is valid", () => {
const res = { ok: true } as AgentRunResult;
assert.equal(res.ok, true);
diff --git a/web/ee/src/services/billing/types.d.ts b/web/ee/src/services/billing/types.d.ts
index 7127037853..9f408487b6 100644
--- a/web/ee/src/services/billing/types.d.ts
+++ b/web/ee/src/services/billing/types.d.ts
@@ -26,7 +26,6 @@ export interface UsageKeyType {
export interface DataUsageType {
traces_ingested?: UsageKeyType
traces_retrieved?: UsageKeyType
- credits_consumed?: UsageKeyType
users?: UsageKeyType
[key: string]: UsageKeyType | undefined
}
diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/ConnectToolWidget.tsx b/web/oss/src/components/AgentChatSlice/components/clientTools/ConnectToolWidget.tsx
index 4ada5936d4..e7cbb81772 100644
--- a/web/oss/src/components/AgentChatSlice/components/clientTools/ConnectToolWidget.tsx
+++ b/web/oss/src/components/AgentChatSlice/components/clientTools/ConnectToolWidget.tsx
@@ -20,8 +20,10 @@ import {
} from "@phosphor-icons/react"
import {Button, Typography} from "antd"
+import GatewayConnectToolWidget from "./GatewayConnectToolWidget"
import type {ClientToolHandlerProps} from "./types"
import {settledFailureChip, useConnectFlow, type ConnectOutput} from "./useConnectFlow"
+import {parseGatewayTarget} from "./useGatewayConnectFlow"
const {Text} = Typography
@@ -34,6 +36,19 @@ const {Text} = Typography
const DEFERRED_SENTINEL = "DEFERRED_NOT_EXECUTED"
const ConnectToolWidget = ({meta, settle}: ClientToolHandlerProps) => {
+ // Gateway-target path (WP26): `meta.input.target` present means this call asks for a
+ // gateway connection, not an external integration. Checked before any hook runs — the
+ // presence of `target` is fixed for a given call, so each mounted instance consistently
+ // takes one branch or the other across its lifetime.
+ const gatewayTarget = parseGatewayTarget(meta.input)
+ if (gatewayTarget) {
+ return
+ }
+
+ return
+}
+
+const IntegrationConnectToolWidget = ({meta, settle}: ClientToolHandlerProps) => {
const {
label,
phase,
diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/GatewayConnectToolWidget.tsx b/web/oss/src/components/AgentChatSlice/components/clientTools/GatewayConnectToolWidget.tsx
new file mode 100644
index 0000000000..a921a84069
--- /dev/null
+++ b/web/oss/src/components/AgentChatSlice/components/clientTools/GatewayConnectToolWidget.tsx
@@ -0,0 +1,126 @@
+/**
+ * Gateway-target half of the connect widget (WP26). Rendered by `ConnectToolWidget` when
+ * `meta.input.target` is present instead of `meta.input.integration`. Visual language matches
+ * the existing chip row exactly; only the action differs (open a registration surface instead
+ * of running an OAuth popup).
+ */
+import {providerConnectionsAtom} from "@agenta/entities/secret"
+import {ProviderDrawer} from "@agenta/entity-ui/secretProvider"
+import {isInteractionEndedOutput} from "@agenta/shared/clientTools"
+import {CheckCircle, Plugs, Spinner, Warning} from "@phosphor-icons/react"
+import {Button, Typography} from "antd"
+import {useAtomValue} from "jotai"
+
+import MCPConnectDialog from "@/oss/components/pages/settings/MCPEndpoints/MCPConnectDialog"
+
+import type {ClientToolHandlerProps} from "./types"
+import {useGatewayConnectFlow, type GatewayTarget} from "./useGatewayConnectFlow"
+
+const {Text} = Typography
+
+const ChipRow = ({icon, children}: {icon: React.ReactNode; children: React.ReactNode}) => (
+
+ {icon}
+ {children}
+
+)
+
+const GatewayConnectToolWidget = ({
+ target,
+ meta,
+ settle,
+}: ClientToolHandlerProps & {target: GatewayTarget}) => {
+ const {
+ label,
+ phase,
+ outcome,
+ providerDrawerOpen,
+ connectingEndpoint,
+ runConnect,
+ onProviderSaved,
+ onProviderClosed,
+ onMcpConnectSuccess,
+ onMcpDialogClosed,
+ decline,
+ } = useGatewayConnectFlow(target, meta, settle)
+ const connections = useAtomValue(providerConnectionsAtom)
+
+ const planeLabel = target.plane === "llm" ? "model provider" : "MCP server"
+
+ if (phase === "connecting") {
+ return (
+ <>
+ }>
+
+ Connecting {label}…
+
+
+ {target.plane === "llm" ? (
+
+ ) : (
+ // `custom` endpoint only (WP19 repoint) — a `builtin` target has no
+ // per-instance dialog to mount here; the shared catalog drawer it opens
+ // instead is mounted once, globally, in Playground.tsx.
+
+ )}
+ >
+ )
+ }
+
+ if (meta.settled || outcome) {
+ if (isInteractionEndedOutput(meta.output)) {
+ return (
+ }>
+
+ Connection request ended
+
+
+ )
+ }
+ const output = (meta.output ?? {}) as {connected?: boolean}
+ if (outcome?.connected === true || output.connected === true) {
+ return (
+ }
+ >
+ {label} connected
+
+ )
+ }
+ return (
+ }>
+
+ Connection not completed
+
+
+ )
+ }
+
+ return (
+ }>
+
+ Connect {label} ({planeLabel})
+
+
+
+
+
+
+ )
+}
+
+export default GatewayConnectToolWidget
diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/useGatewayConnectFlow.test.ts b/web/oss/src/components/AgentChatSlice/components/clientTools/useGatewayConnectFlow.test.ts
new file mode 100644
index 0000000000..9d17d8aa8a
--- /dev/null
+++ b/web/oss/src/components/AgentChatSlice/components/clientTools/useGatewayConnectFlow.test.ts
@@ -0,0 +1,132 @@
+/**
+ * Unit tests for the pure helpers in `useGatewayConnectFlow` (WP26): parsing a call's `target`
+ * out of `meta.input`, and the settle-output builders for each terminal state. No React render,
+ * no real drawer, no real backend — mirrors `useConnectFlow.test.ts`'s style of testing the
+ * extracted pure logic directly.
+ */
+import {describe, expect, it} from "vitest"
+
+import type {MCPEndpoint} from "@/oss/services/mcpEndpoints/types"
+
+import {
+ gatewayCancelledOutput,
+ gatewayConnectedOutput,
+ gatewayDeclinedOutput,
+ gatewayTargetLabel,
+ parseGatewayTarget,
+ resolveCustomMcpEndpoint,
+} from "./useGatewayConnectFlow"
+
+describe("parseGatewayTarget", () => {
+ it("parses a valid llm-plane target", () => {
+ expect(parseGatewayTarget({target: {plane: "llm", name: "openai"}})).toEqual({
+ plane: "llm",
+ name: "openai",
+ })
+ })
+
+ it("parses a valid mcp-plane target", () => {
+ expect(parseGatewayTarget({target: {plane: "mcp", name: "acme-notion"}})).toEqual({
+ plane: "mcp",
+ name: "acme-notion",
+ })
+ })
+
+ it("is null for the existing integration-only call — the two paths never overlap", () => {
+ expect(parseGatewayTarget({integration: "slack"})).toBeNull()
+ })
+
+ it("is null when target is absent entirely", () => {
+ expect(parseGatewayTarget({})).toBeNull()
+ expect(parseGatewayTarget(null)).toBeNull()
+ expect(parseGatewayTarget(undefined)).toBeNull()
+ })
+
+ it("is null for an unknown plane — a model hallucinating a third plane must not silently pass through", () => {
+ expect(parseGatewayTarget({target: {plane: "sbx", name: "x"}})).toBeNull()
+ })
+
+ it("is null when name is missing or not a string", () => {
+ expect(parseGatewayTarget({target: {plane: "llm"}})).toBeNull()
+ expect(parseGatewayTarget({target: {plane: "llm", name: ""}})).toBeNull()
+ expect(parseGatewayTarget({target: {plane: "llm", name: 42}})).toBeNull()
+ })
+
+ it("is null when target is not an object", () => {
+ expect(parseGatewayTarget({target: "openai"})).toBeNull()
+ })
+})
+
+describe("gatewayTargetLabel", () => {
+ it("is the target's own name, verbatim", () => {
+ expect(gatewayTargetLabel({plane: "llm", name: "openai"})).toBe("openai")
+ expect(gatewayTargetLabel({plane: "mcp", name: "acme-notion"})).toBe("acme-notion")
+ })
+})
+
+describe("settle-output builders", () => {
+ const target = {plane: "mcp" as const, name: "acme-notion"}
+
+ it("connected output carries connected:true and the target back", () => {
+ expect(gatewayConnectedOutput(target)).toEqual({connected: true, target})
+ })
+
+ it("declined output is connected:false with reason 'declined', before anything opens", () => {
+ expect(gatewayDeclinedOutput(target)).toEqual({
+ connected: false,
+ target,
+ reason: "declined",
+ })
+ })
+
+ it("cancelled output is connected:false with reason 'cancelled' — the llm-drawer-closed-without-saving case", () => {
+ expect(gatewayCancelledOutput(target)).toEqual({
+ connected: false,
+ target,
+ reason: "cancelled",
+ })
+ })
+})
+
+describe("resolveCustomMcpEndpoint (WP19 repoint)", () => {
+ const customEndpoint = {
+ id: "ep-1",
+ slug: "acme-notion",
+ namespace: "custom",
+ auth_mode: "oauth",
+ data: {route: {base_url: "https://acme.example/mcp"}},
+ } as unknown as MCPEndpoint
+
+ const builtinEndpoint = {
+ id: "ep-2",
+ slug: "acme-notion",
+ namespace: "builtin",
+ auth_mode: "oauth",
+ data: {route: {base_url: "composio://composio/notion/acme-notion"}},
+ } as unknown as MCPEndpoint
+
+ it("resolves a matching custom endpoint by slug", () => {
+ const target = {plane: "mcp" as const, name: "acme-notion"}
+ expect(resolveCustomMcpEndpoint([customEndpoint], target)).toBe(customEndpoint)
+ })
+
+ it("is null for an llm-plane target regardless of the endpoint list", () => {
+ const target = {plane: "llm" as const, name: "acme-notion"}
+ expect(resolveCustomMcpEndpoint([customEndpoint], target)).toBeNull()
+ })
+
+ it("is null when the only match is a builtin (Composio) endpoint, not custom — falls back to the catalog drawer", () => {
+ const target = {plane: "mcp" as const, name: "acme-notion"}
+ expect(resolveCustomMcpEndpoint([builtinEndpoint], target)).toBeNull()
+ })
+
+ it("is null when no endpoint matches the target's name at all", () => {
+ const target = {plane: "mcp" as const, name: "unknown-server"}
+ expect(resolveCustomMcpEndpoint([customEndpoint], target)).toBeNull()
+ })
+
+ it("is null when the endpoint list hasn't loaded yet", () => {
+ const target = {plane: "mcp" as const, name: "acme-notion"}
+ expect(resolveCustomMcpEndpoint(undefined, target)).toBeNull()
+ })
+})
diff --git a/web/oss/src/components/AgentChatSlice/components/clientTools/useGatewayConnectFlow.ts b/web/oss/src/components/AgentChatSlice/components/clientTools/useGatewayConnectFlow.ts
new file mode 100644
index 0000000000..2681689af1
--- /dev/null
+++ b/web/oss/src/components/AgentChatSlice/components/clientTools/useGatewayConnectFlow.ts
@@ -0,0 +1,189 @@
+/**
+ * The gateway-target half of the `request_connection` client tool (WP26, D35 consequence 2).
+ *
+ * Sibling to `useConnectFlow` (the external-integration OAuth flow), NOT a replacement: this
+ * hook only runs when `meta.input.target` is present. The two flows settle the SAME tool call
+ * shape (`{connected, reason?, retryable?}` | `{errorText}`) so `ConnectToolWidget`'s settled
+ * chip rendering stays shared between both paths.
+ *
+ * `plane: "llm"` opens `ProviderDrawer` — the project's existing "connect a model provider"
+ * surface — and settles on its `onSaved` callback, a real, verified signal.
+ *
+ * `plane: "mcp"` (WP19 repoint): a `target.name` that matches a registered `custom` MCP
+ * endpoint opens `MCPConnectDialog` (the real WP18 surface) and settles on its `onSuccess`
+ * callback — a real, verified signal, same footing as the LLM path. This is also the SAME
+ * dialog a step-up refusal (`scope_insufficient`) points the agent back at: the request
+ * carries no scope list (WP25's marker never recovers one on the MCP plane), so the dialog
+ * re-runs `discover()` and re-offers the current scope checklist rather than this widget
+ * guessing which ones matter (D17). A `target.name` with no matching `custom` endpoint falls
+ * back to the shared tool-catalog drawer via `toolCatalogDrawerOpenAtom` and settles
+ * OPTIMISTICALLY when it closes — unchanged from WP26, and still correct for a `builtin`
+ * (Composio) target, the one case with no per-call completion signal to read (see
+ * specs-wp26.md "Settle semantics"). Safe either way: the gateway re-checks registration and
+ * scope on every call regardless of what this widget believed.
+ */
+import {useCallback, useEffect, useMemo, useRef, useState} from "react"
+
+import {toolCatalogDrawerOpenAtom} from "@agenta/entities/gatewayTool"
+import {useAtom, useAtomValue} from "jotai"
+
+import type {MCPEndpoint} from "@/oss/services/mcpEndpoints/types"
+import {mcpEndpointsAtom} from "@/oss/state/mcpEndpoints/atoms"
+
+import type {ClientToolMeta, SettleClientTool} from "./types"
+
+export type GatewayPlane = "llm" | "mcp"
+
+export interface GatewayTarget {
+ plane: GatewayPlane
+ name: string
+}
+
+/** `meta.input.target`, narrowed and validated — `null` when absent or malformed. */
+export const parseGatewayTarget = (input: unknown): GatewayTarget | null => {
+ const target = (input as {target?: unknown} | null)?.target
+ if (!target || typeof target !== "object") return null
+ const plane = (target as {plane?: unknown}).plane
+ const name = (target as {name?: unknown}).name
+ if (plane !== "llm" && plane !== "mcp") return null
+ if (typeof name !== "string" || !name) return null
+ return {plane, name}
+}
+
+/** `openai` / `acme-notion` verbatim — no catalog lookup available generically across planes. */
+export const gatewayTargetLabel = (target: GatewayTarget): string => target.name
+
+export const gatewayConnectedOutput = (target: GatewayTarget): Record => ({
+ connected: true,
+ target,
+})
+
+export const gatewayDeclinedOutput = (target: GatewayTarget): Record => ({
+ connected: false,
+ target,
+ reason: "declined",
+})
+
+export const gatewayCancelledOutput = (target: GatewayTarget): Record => ({
+ connected: false,
+ target,
+ reason: "cancelled",
+})
+
+export type GatewayConnectPhase = "idle" | "connecting"
+
+/** The `custom` endpoint a `plane: "mcp"` target names, or `null` when it names a
+ * `builtin` (Composio) server instead — the only two cases D35 registration allows. */
+export const resolveCustomMcpEndpoint = (
+ endpoints: MCPEndpoint[] | undefined,
+ target: GatewayTarget,
+): MCPEndpoint | null => {
+ if (target.plane !== "mcp") return null
+ return endpoints?.find((e) => e.namespace === "custom" && e.slug === target.name) ?? null
+}
+
+export const useGatewayConnectFlow = (
+ target: GatewayTarget,
+ meta: ClientToolMeta,
+ settle: SettleClientTool,
+) => {
+ const [phase, setPhase] = useState("idle")
+ const [outcome, setOutcome] = useState<{connected: boolean; reason?: string} | null>(null)
+ const [providerDrawerOpen, setProviderDrawerOpen] = useState(false)
+ const [connectingEndpoint, setConnectingEndpoint] = useState(null)
+ const [catalogOpen, setCatalogOpen] = useAtom(toolCatalogDrawerOpenAtom)
+ // Whether THIS instance is the one that opened the shared catalog drawer — the atom is
+ // shared across every mounted widget, so only the opener may settle on its close.
+ const openedCatalogRef = useRef(false)
+
+ const mcpEndpointsQuery = useAtomValue(mcpEndpointsAtom)
+ const customEndpoint = useMemo(
+ () => resolveCustomMcpEndpoint(mcpEndpointsQuery.data, target),
+ [mcpEndpointsQuery.data, target],
+ )
+
+ const settledRef = useRef(false)
+ const label = gatewayTargetLabel(target)
+
+ const finish = useCallback(
+ (output: Record) => {
+ if (settledRef.current) return
+ settledRef.current = true
+ setPhase("idle")
+ setOutcome({connected: output.connected === true, reason: output.reason as string})
+ settle({output})
+ },
+ [settle],
+ )
+
+ // Fallback only: a `plane: "mcp"` target with no matching `custom` endpoint (a `builtin`
+ // server) still goes through the shared catalog drawer, which has no per-call completion
+ // signal — the shared drawer closing (having been opened by THIS instance) is the only
+ // signal that path gets. See module doc for why "closed = done" is optimistic-but-safe.
+ useEffect(() => {
+ if (target.plane !== "mcp") return
+ if (!openedCatalogRef.current) return
+ if (catalogOpen) return
+ openedCatalogRef.current = false
+ finish(gatewayConnectedOutput(target))
+ }, [catalogOpen, target, finish])
+
+ const runConnect = useCallback(() => {
+ if (settledRef.current || meta.settled) return
+ setPhase("connecting")
+ if (target.plane === "llm") {
+ setProviderDrawerOpen(true)
+ } else if (customEndpoint) {
+ setConnectingEndpoint(customEndpoint)
+ } else {
+ openedCatalogRef.current = true
+ setCatalogOpen(true)
+ }
+ }, [meta.settled, target, setCatalogOpen, customEndpoint])
+
+ const onProviderSaved = useCallback(() => {
+ finish(gatewayConnectedOutput(target))
+ }, [finish, target])
+
+ const onProviderClosed = useCallback(() => {
+ setProviderDrawerOpen(false)
+ if (!settledRef.current && !meta.settled) finish(gatewayCancelledOutput(target))
+ }, [finish, meta.settled, target])
+
+ // Real, verified signal (MCPConnectDialog's own postMessage-checked completion) — not the
+ // catalog drawer's optimistic close, since a `custom` endpoint now has one (WP19 repoint).
+ const onMcpConnectSuccess = useCallback(() => {
+ setConnectingEndpoint(null)
+ finish(gatewayConnectedOutput(target))
+ }, [finish, target])
+
+ // Closed without success: discovery failure and an explicit decline both land here
+ // (MCPConnectDialog renders the discovery error inline first; only closing after either
+ // reaches this handler), and both settle as "cancelled" — the same terminal shape the LLM
+ // path already uses for "opened, then closed with nothing to show for it". An explicit
+ // decline BEFORE opening (see `decline` below) settles as "declined" instead, so the two
+ // stay distinguishable in the settled output.
+ const onMcpDialogClosed = useCallback(() => {
+ setConnectingEndpoint(null)
+ if (!settledRef.current && !meta.settled) finish(gatewayCancelledOutput(target))
+ }, [finish, meta.settled, target])
+
+ const decline = useCallback(() => {
+ if (settledRef.current || meta.settled) return
+ finish(gatewayDeclinedOutput(target))
+ }, [finish, meta.settled, target])
+
+ return {
+ label,
+ phase,
+ outcome,
+ providerDrawerOpen,
+ connectingEndpoint,
+ runConnect,
+ onProviderSaved,
+ onMcpConnectSuccess,
+ onMcpDialogClosed,
+ onProviderClosed,
+ decline,
+ }
+}
diff --git a/web/oss/src/components/Sidebar/scopes/settingsScope.tsx b/web/oss/src/components/Sidebar/scopes/settingsScope.tsx
index 20f17e2338..7a2bd32d07 100644
--- a/web/oss/src/components/Sidebar/scopes/settingsScope.tsx
+++ b/web/oss/src/components/Sidebar/scopes/settingsScope.tsx
@@ -7,6 +7,7 @@ import {
Key,
Lightning,
Link,
+ Plugs,
Receipt,
ShieldCheck,
SlidersHorizontal,
@@ -62,6 +63,8 @@ const getSettingsSidebarIcon = (key: SettingsTabKey) => {
return
case "webhooks":
return
+ case "mcpEndpoints":
+ return
case "workspace":
return
case "organizationGeneral":
diff --git a/web/oss/src/components/pages/settings/MCPEndpoints/MCPConnectDialog.tsx b/web/oss/src/components/pages/settings/MCPEndpoints/MCPConnectDialog.tsx
new file mode 100644
index 0000000000..4d42c5da63
--- /dev/null
+++ b/web/oss/src/components/pages/settings/MCPEndpoints/MCPConnectDialog.tsx
@@ -0,0 +1,168 @@
+import {useCallback, useEffect, useState} from "react"
+
+import {EnhancedModal, ModalContent, ModalFooter, message} from "@agenta/ui"
+import {Checkbox} from "@agenta/ui/ui"
+import {useAtomValue} from "jotai"
+
+import {getAgentaApiUrl, getAgentaWebUrl} from "@/oss/lib/helpers/api"
+import {beginMcpConnect, discoverMcpConnect} from "@/oss/services/mcpEndpoints/api"
+import {MCPEndpoint} from "@/oss/services/mcpEndpoints/types"
+import {projectIdAtom} from "@/oss/state/project"
+
+import {buildTrustedOrigins, isTrustedOauthConnectedMessage} from "./connectMessage"
+
+interface Props {
+ endpoint: MCPEndpoint | null
+ onClose: () => void
+ onSuccess?: () => void
+}
+
+// specs-wp18.md: two-step consent flow. Step 1 (discover) renders the checklist,
+// step 2 (begin) opens the authorization redirect — popup with a same-tab
+// fallback and postMessage-or-poll completion, mirroring
+// `gatewayTool/drawers/ConnectDrawer.tsx`'s own OAuth mechanics.
+export default function MCPConnectDialog({endpoint, onClose, onSuccess}: Props) {
+ const projectId = useAtomValue(projectIdAtom)
+ const [loading, setLoading] = useState(false)
+ const [scopesOffered, setScopesOffered] = useState([])
+ const [selectedScopes, setSelectedScopes] = useState>(new Set())
+ const [discoverError, setDiscoverError] = useState(null)
+
+ const open = !!endpoint
+
+ useEffect(() => {
+ if (!endpoint) return
+ setLoading(true)
+ setDiscoverError(null)
+ discoverMcpConnect(endpoint.id, projectId ?? undefined)
+ .then((result) => {
+ const scopes = result.scopes_offered ?? []
+ setScopesOffered(scopes)
+ setSelectedScopes(new Set(scopes)) // all pre-checked (D17)
+ })
+ .catch((error) => {
+ setDiscoverError(
+ error?.message || "Could not discover this server's OAuth configuration.",
+ )
+ })
+ .finally(() => setLoading(false))
+ }, [endpoint?.id])
+
+ const handleClose = useCallback(() => {
+ setScopesOffered([])
+ setSelectedScopes(new Set())
+ setDiscoverError(null)
+ setLoading(false)
+ onClose()
+ }, [onClose])
+
+ const toggleScope = useCallback((scope: string) => {
+ setSelectedScopes((prev) => {
+ const next = new Set(prev)
+ if (next.has(scope)) next.delete(scope)
+ else next.add(scope)
+ return next
+ })
+ }, [])
+
+ const handleConnect = useCallback(async () => {
+ if (!endpoint) return
+ try {
+ setLoading(true)
+ const result = await beginMcpConnect(
+ endpoint.id,
+ Array.from(selectedScopes),
+ projectId ?? undefined,
+ )
+ const redirectUrl = result.redirect_url
+ if (!redirectUrl) {
+ throw new Error("No authorization URL returned.")
+ }
+
+ const popup = window.open(redirectUrl, "mcp_oauth", "width=600,height=700,popup=yes")
+ if (!popup) {
+ setLoading(false)
+ message.warning("Popup blocked. Redirecting in this tab.")
+ window.location.assign(redirectUrl)
+ return
+ }
+
+ const onAuthDone = () => {
+ window.focus()
+ handleClose()
+ onSuccess?.()
+ }
+
+ const trustedOrigins = buildTrustedOrigins([
+ window.location.origin,
+ getAgentaApiUrl(),
+ getAgentaWebUrl(),
+ ])
+
+ const handler = (event: MessageEvent) => {
+ if (isTrustedOauthConnectedMessage(event.data, event.origin, trustedOrigins)) {
+ window.removeEventListener("message", handler)
+ onAuthDone()
+ }
+ }
+ window.addEventListener("message", handler)
+
+ const pollTimer = setInterval(() => {
+ if (popup && popup.closed) {
+ clearInterval(pollTimer)
+ window.removeEventListener("message", handler)
+ onAuthDone()
+ }
+ }, 1000)
+ } catch (error) {
+ setLoading(false)
+ message.error((error as Error)?.message || "Failed to start the connection.")
+ }
+ }, [endpoint, selectedScopes, projectId, handleClose, onSuccess])
+
+ return (
+
+
+ {discoverError ? (
+ {discoverError}
+ ) : (
+
+
+ Choose which permissions to grant.
+
+ {scopesOffered.length === 0 && !loading ? (
+
+ This server offers no scoped permissions.
+
+ ) : (
+ scopesOffered.map((scope) => (
+
+ ))
+ )}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/web/oss/src/components/pages/settings/MCPEndpoints/MCPEndpointDrawer.tsx b/web/oss/src/components/pages/settings/MCPEndpoints/MCPEndpointDrawer.tsx
new file mode 100644
index 0000000000..2c7b8ac9a8
--- /dev/null
+++ b/web/oss/src/components/pages/settings/MCPEndpoints/MCPEndpointDrawer.tsx
@@ -0,0 +1,168 @@
+import {useCallback, useEffect, useState} from "react"
+
+import {EnhancedModal, ModalContent, ModalFooter, message} from "@agenta/ui"
+import {
+ Field,
+ Input,
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@agenta/ui/ui"
+import {useSetAtom} from "jotai"
+
+import {MCPAuthMode, MCPEndpoint} from "@/oss/services/mcpEndpoints/types"
+import {createMcpEndpointAtom, editMcpEndpointAtom} from "@/oss/state/mcpEndpoints/atoms"
+
+interface Props {
+ open: boolean
+ endpoint?: MCPEndpoint | null
+ onClose: () => void
+ onSuccess?: () => void
+}
+
+const AUTH_MODES: {value: MCPAuthMode; label: string}[] = [
+ {value: "none", label: "None"},
+ {value: "api_key", label: "API key"},
+ {value: "oauth", label: "OAuth"},
+]
+
+export default function MCPEndpointDrawer({open, endpoint, onClose, onSuccess}: Props) {
+ const createEndpoint = useSetAtom(createMcpEndpointAtom)
+ const editEndpoint = useSetAtom(editMcpEndpointAtom)
+
+ const [loading, setLoading] = useState(false)
+ const [slug, setSlug] = useState("")
+ const [name, setName] = useState("")
+ const [baseUrl, setBaseUrl] = useState("")
+ const [authMode, setAuthMode] = useState("none")
+ const [slugError, setSlugError] = useState(null)
+
+ useEffect(() => {
+ if (!open) return
+ setSlug(endpoint?.slug || "")
+ setName(endpoint?.name || "")
+ setBaseUrl(endpoint?.data.route.base_url || "")
+ setAuthMode(endpoint?.auth_mode || "none")
+ setSlugError(null)
+ setLoading(false)
+ }, [open, endpoint])
+
+ const handleClose = useCallback(() => {
+ setLoading(false)
+ onClose()
+ }, [onClose])
+
+ const handleSubmit = useCallback(async () => {
+ if (!slug.trim()) {
+ setSlugError("Required")
+ return
+ }
+ if (!baseUrl.trim()) {
+ message.error("Server URL is required.")
+ return
+ }
+ setSlugError(null)
+ setLoading(true)
+ try {
+ if (endpoint) {
+ await editEndpoint({
+ id: endpoint.id,
+ name: name || slug,
+ auth_mode: authMode,
+ secret_id: endpoint.secret_id,
+ data: {...endpoint.data, route: {base_url: baseUrl}},
+ })
+ } else {
+ await createEndpoint({
+ slug,
+ name: name || slug,
+ auth_mode: authMode,
+ data: {route: {base_url: baseUrl}},
+ })
+ }
+ handleClose()
+ onSuccess?.()
+ } catch (error) {
+ message.error((error as Error)?.message || "Failed to save the MCP server.")
+ } finally {
+ setLoading(false)
+ }
+ }, [
+ slug,
+ name,
+ baseUrl,
+ authMode,
+ endpoint,
+ createEndpoint,
+ editEndpoint,
+ handleClose,
+ onSuccess,
+ ])
+
+ return (
+
+
+
+
+ setName(e.target.value)}
+ />
+
+
+ {
+ setSlug(e.target.value)
+ if (slugError && e.target.value.trim()) setSlugError(null)
+ }}
+ />
+
+
+ setBaseUrl(e.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/web/oss/src/components/pages/settings/MCPEndpoints/MCPEndpoints.tsx b/web/oss/src/components/pages/settings/MCPEndpoints/MCPEndpoints.tsx
new file mode 100644
index 0000000000..23a8dac60f
--- /dev/null
+++ b/web/oss/src/components/pages/settings/MCPEndpoints/MCPEndpoints.tsx
@@ -0,0 +1,216 @@
+import {useCallback, useMemo, useState} from "react"
+
+import {message} from "@agenta/ui"
+import {
+ InfiniteVirtualTableFeatureShell,
+ createStandardColumns,
+ type StandardColumnDef,
+} from "@agenta/ui/table"
+import {EmptyState} from "@agenta/ui/ui"
+import {PencilSimpleLine, Plug, Plus, Trash} from "@phosphor-icons/react"
+import {Button, Tag} from "antd"
+import {useAtomValue, useSetAtom} from "jotai"
+
+import {useStaticTable} from "@/oss/components/pages/settings/hooks/useStaticTable"
+import {MCPEndpoint} from "@/oss/services/mcpEndpoints/types"
+import {deleteMcpEndpointAtom, mcpEndpointsAtom} from "@/oss/state/mcpEndpoints/atoms"
+
+import MCPConnectDialog from "./MCPConnectDialog"
+import MCPEndpointDrawer from "./MCPEndpointDrawer"
+
+interface MCPEndpointRow extends MCPEndpoint {
+ key: string
+ [extra: string]: unknown
+}
+
+const isConnected = (endpoint: MCPEndpoint) => !!endpoint.secret_id
+
+const MCPEndpoints: React.FC = () => {
+ const {data: endpoints, isPending: isLoading} = useAtomValue(mcpEndpointsAtom)
+ const deleteEndpoint = useSetAtom(deleteMcpEndpointAtom)
+
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false)
+ const [editingEndpoint, setEditingEndpoint] = useState(null)
+ const [connectingEndpoint, setConnectingEndpoint] = useState(null)
+
+ const handleCreate = useCallback(() => {
+ setEditingEndpoint(null)
+ setIsDrawerOpen(true)
+ }, [])
+
+ const handleEdit = useCallback((endpoint: MCPEndpoint) => {
+ setEditingEndpoint(endpoint)
+ setIsDrawerOpen(true)
+ }, [])
+
+ const handleDelete = useCallback(
+ async (endpoint: MCPEndpoint) => {
+ try {
+ await deleteEndpoint(endpoint.id)
+ message.success("MCP server removed.")
+ } catch (error) {
+ message.error((error as Error)?.message || "Failed to remove the MCP server.")
+ }
+ },
+ [deleteEndpoint],
+ )
+
+ const handleDrawerClose = useCallback(() => {
+ setIsDrawerOpen(false)
+ setEditingEndpoint(null)
+ }, [])
+
+ const handleConnect = useCallback((endpoint: MCPEndpoint) => {
+ setConnectingEndpoint(endpoint)
+ }, [])
+
+ const rows = useMemo(
+ () => (endpoints ?? []).map((endpoint) => ({...endpoint, key: endpoint.id})),
+ [endpoints],
+ )
+
+ const columns = useMemo(
+ () =>
+ createStandardColumns([
+ {
+ type: "text",
+ key: "name",
+ title: "Name",
+ width: 200,
+ fixed: "left",
+ render: (_value, record) => record.name || record.slug || "-",
+ },
+ {
+ type: "text",
+ key: "url",
+ title: "Server URL",
+ width: 320,
+ render: (_value, record) => (
+
+ {record.data.route.base_url || "-"}
+
+ ),
+ },
+ {
+ type: "text",
+ key: "auth_mode",
+ title: "Auth",
+ width: 120,
+ render: (_value, record) => record.auth_mode,
+ },
+ {
+ type: "text",
+ key: "status",
+ title: "Status",
+ width: 140,
+ render: (_value, record) =>
+ record.auth_mode === "oauth" ? (
+
+ {isConnected(record) ? "Connected" : "Not connected"}
+
+ ) : (
+ "-"
+ ),
+ },
+ {
+ type: "actions",
+ showCopyId: false,
+ items: [
+ {
+ key: "connect",
+ label: "Connect",
+ icon: ,
+ hidden: (record: MCPEndpointRow) =>
+ record.auth_mode !== "oauth" || isConnected(record),
+ onClick: (record: MCPEndpointRow) => handleConnect(record),
+ },
+ {
+ key: "edit",
+ label: "Edit",
+ icon: ,
+ onClick: (record: MCPEndpointRow) => handleEdit(record),
+ },
+ {type: "divider"},
+ {
+ key: "delete",
+ label: "Delete",
+ icon: ,
+ danger: true,
+ onClick: (record: MCPEndpointRow) => handleDelete(record),
+ },
+ ],
+ } satisfies StandardColumnDef,
+ ]),
+ [handleConnect, handleDelete, handleEdit],
+ )
+
+ const {tableScope, pagination} = useStaticTable(
+ "settings-mcp-endpoints",
+ rows,
+ {
+ loading: isLoading,
+ },
+ )
+
+ return (
+
+
+ tableScope={tableScope}
+ autoHeight={false}
+ columns={columns}
+ rowKey="key"
+ pagination={pagination}
+ primaryActions={
+ } onClick={handleCreate}>
+ Register server
+
+ }
+ tableProps={{
+ size: "small",
+ bordered: true,
+ tableLayout: "fixed",
+ locale: {
+ emptyText: (
+
+
+ No MCP servers yet
+
+
+ Register a server by URL to give your agents new tools.
+
+
+ }
+ >
+ } onClick={handleCreate}>
+ Register server
+
+
+ ),
+ },
+ onRow: (record: MCPEndpointRow) => ({
+ onClick: () => handleEdit(record),
+ className: "cursor-pointer",
+ }),
+ }}
+ />
+
+
+ setConnectingEndpoint(null)}
+ />
+
+ )
+}
+
+export default MCPEndpoints
diff --git a/web/oss/src/components/pages/settings/MCPEndpoints/connectMessage.test.ts b/web/oss/src/components/pages/settings/MCPEndpoints/connectMessage.test.ts
new file mode 100644
index 0000000000..0a3f79a63f
--- /dev/null
+++ b/web/oss/src/components/pages/settings/MCPEndpoints/connectMessage.test.ts
@@ -0,0 +1,60 @@
+import {describe, expect, it} from "vitest"
+
+import {buildTrustedOrigins, isTrustedOauthConnectedMessage} from "./connectMessage"
+
+describe("buildTrustedOrigins", () => {
+ it("collects the origin of every valid URL", () => {
+ const origins = buildTrustedOrigins([
+ "https://app.example.test/settings",
+ "https://api.example.test/gateways",
+ ])
+
+ expect(origins).toEqual(new Set(["https://app.example.test", "https://api.example.test"]))
+ })
+
+ it("ignores undefined and invalid URLs", () => {
+ const origins = buildTrustedOrigins([undefined, "not a url", "https://app.example.test"])
+
+ expect(origins).toEqual(new Set(["https://app.example.test"]))
+ })
+})
+
+describe("isTrustedOauthConnectedMessage", () => {
+ const trusted = buildTrustedOrigins(["https://app.example.test"])
+
+ it("accepts the connected message from a trusted origin", () => {
+ expect(
+ isTrustedOauthConnectedMessage(
+ {type: "mcp:oauth:connected"},
+ "https://app.example.test",
+ trusted,
+ ),
+ ).toBe(true)
+ })
+
+ it("rejects a message from an untrusted origin", () => {
+ expect(
+ isTrustedOauthConnectedMessage(
+ {type: "mcp:oauth:connected"},
+ "https://evil.test",
+ trusted,
+ ),
+ ).toBe(false)
+ })
+
+ it("rejects a differently-typed message from a trusted origin", () => {
+ expect(
+ isTrustedOauthConnectedMessage(
+ {type: "tools:oauth:complete"},
+ "https://app.example.test",
+ trusted,
+ ),
+ ).toBe(false)
+ })
+
+ it("rejects a non-object payload", () => {
+ expect(isTrustedOauthConnectedMessage("hello", "https://app.example.test", trusted)).toBe(
+ false,
+ )
+ })
+})
diff --git a/web/oss/src/components/pages/settings/MCPEndpoints/connectMessage.ts b/web/oss/src/components/pages/settings/MCPEndpoints/connectMessage.ts
new file mode 100644
index 0000000000..3c7ff45468
--- /dev/null
+++ b/web/oss/src/components/pages/settings/MCPEndpoints/connectMessage.ts
@@ -0,0 +1,30 @@
+// Pure helpers factored out of MCPConnectDialog so the postMessage trust check is
+// unit-testable without rendering React (mirrors ConnectDrawer.tsx's own check).
+
+export const MCP_OAUTH_CONNECTED = "mcp:oauth:connected"
+
+export function buildTrustedOrigins(urls: (string | undefined)[]): Set {
+ const origins = new Set()
+ for (const url of urls) {
+ if (!url) continue
+ try {
+ origins.add(new URL(url).origin)
+ } catch {
+ // ignore invalid env URLs
+ }
+ }
+ return origins
+}
+
+export function isTrustedOauthConnectedMessage(
+ data: unknown,
+ origin: string,
+ trustedOrigins: Set,
+): boolean {
+ if (!trustedOrigins.has(origin)) return false
+ return (
+ typeof data === "object" &&
+ data !== null &&
+ (data as {type?: unknown}).type === MCP_OAUTH_CONNECTED
+ )
+}
diff --git a/web/oss/src/components/pages/settings/assets/navigation.test.ts b/web/oss/src/components/pages/settings/assets/navigation.test.ts
index a0f0f6d51c..c0a09d6d42 100644
--- a/web/oss/src/components/pages/settings/assets/navigation.test.ts
+++ b/web/oss/src/components/pages/settings/assets/navigation.test.ts
@@ -107,6 +107,7 @@ describe("settings sidebar scopes", () => {
"tools",
"triggers",
"webhooks",
+ "mcpEndpoints",
])
expect(keysForScope("organization")).toEqual([
"organizationGeneral",
diff --git a/web/oss/src/components/pages/settings/assets/navigation.ts b/web/oss/src/components/pages/settings/assets/navigation.ts
index 29e6ed3d4f..04ee25ad6c 100644
--- a/web/oss/src/components/pages/settings/assets/navigation.ts
+++ b/web/oss/src/components/pages/settings/assets/navigation.ts
@@ -7,6 +7,7 @@ export type SettingsTabKey =
| "tools"
| "triggers"
| "webhooks"
+ | "mcpEndpoints"
| "workspace"
| "projects"
| "organizationGeneral"
@@ -87,6 +88,11 @@ export const SETTINGS_TABS: SettingsTabDefinition[] = [
description:
"Send workflow events to your own HTTP endpoints, with signed payloads and delivery retries.",
},
+ {
+ key: "mcpEndpoints",
+ scope: "project",
+ description: "Register MCP servers by URL and connect the ones that need authorization.",
+ },
{
key: "organizationGeneral",
scope: "organization",
@@ -164,6 +170,7 @@ const SETTINGS_LABELS: Record, string> = {
tools: "Tools",
triggers: "Triggers",
webhooks: "Webhooks",
+ mcpEndpoints: "MCP Servers",
workspace: "Members",
projects: "Projects",
organizationGeneral: "Organizations",
diff --git a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx
index 70c3665eb2..0d834ae0be 100644
--- a/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx
+++ b/web/oss/src/pages/w/[workspace_id]/p/[project_id]/settings/index.tsx
@@ -69,6 +69,11 @@ const Webhooks = dynamic(() => import("@/oss/components/pages/settings/Webhooks/
ssr: false,
})
+const MCPEndpoints = dynamic(
+ () => import("@/oss/components/pages/settings/MCPEndpoints/MCPEndpoints"),
+ {ssr: false},
+)
+
const Preferences = dynamic(
() => import("@/oss/components/pages/settings/Preferences/Preferences"),
{ssr: false},
@@ -153,6 +158,11 @@ export const Settings: React.FC = ({AuditLogComponent}) => {
content: ,
title: getSettingsTabLabel("webhooks", settingsAccess),
}
+ case "mcpEndpoints":
+ return {
+ content: ,
+ title: getSettingsTabLabel("mcpEndpoints", settingsAccess),
+ }
case "auditLog":
return {
content: AuditLogComponent ? : ,
diff --git a/web/oss/src/services/mcpEndpoints/api.test.ts b/web/oss/src/services/mcpEndpoints/api.test.ts
new file mode 100644
index 0000000000..ce179207e1
--- /dev/null
+++ b/web/oss/src/services/mcpEndpoints/api.test.ts
@@ -0,0 +1,102 @@
+import {beforeEach, describe, expect, it, vi} from "vitest"
+
+import axios from "@/oss/lib/api/assets/axiosConfig"
+
+import {beginMcpConnect, discoverMcpConnect, editMcpEndpoint} from "./api"
+
+vi.mock("@/oss/lib/api/assets/axiosConfig", () => ({
+ default: {get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn()},
+}))
+
+vi.mock("@/oss/lib/helpers/api", () => ({
+ getAgentaApiUrl: vi.fn(() => "https://api.example.test"),
+}))
+
+const BASE = "https://api.example.test/gateways/mcps/endpoints"
+
+describe("mcpEndpoints api", () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it("discoverMcpConnect posts an empty body — the discover step", async () => {
+ vi.mocked(axios.post).mockResolvedValue({
+ data: {count: 1, scopes_offered: ["read", "write"]},
+ })
+
+ const result = await discoverMcpConnect("endpoint-1", "project-1")
+
+ expect(axios.post).toHaveBeenCalledWith(
+ `${BASE}/endpoint-1/connect`,
+ {},
+ {params: {project_id: "project-1"}},
+ )
+ expect(result.scopes_offered).toEqual(["read", "write"])
+ })
+
+ it("beginMcpConnect posts the chosen scopes — the begin step", async () => {
+ vi.mocked(axios.post).mockResolvedValue({
+ data: {count: 1, redirect_url: "https://auth.example.test/authorize"},
+ })
+
+ const result = await beginMcpConnect("endpoint-1", ["read"], "project-1")
+
+ expect(axios.post).toHaveBeenCalledWith(
+ `${BASE}/endpoint-1/connect`,
+ {scopes: ["read"]},
+ {params: {project_id: "project-1"}},
+ )
+ expect(result.redirect_url).toBe("https://auth.example.test/authorize")
+ })
+
+ it("beginMcpConnect allows an empty scope list through unchanged", async () => {
+ vi.mocked(axios.post).mockResolvedValue({data: {count: 1, redirect_url: "x"}})
+
+ await beginMcpConnect("endpoint-1", [], "project-1")
+
+ expect(axios.post).toHaveBeenCalledWith(
+ `${BASE}/endpoint-1/connect`,
+ {scopes: []},
+ {params: {project_id: "project-1"}},
+ )
+ })
+
+ it("editMcpEndpoint PUTs to the endpoint's own id", async () => {
+ vi.mocked(axios.put).mockResolvedValue({data: {count: 1, endpoint: {id: "endpoint-1"}}})
+
+ await editMcpEndpoint(
+ {
+ id: "endpoint-1",
+ auth_mode: "oauth",
+ secret_id: "secret-1",
+ data: {route: {base_url: "https://mcp.example.com"}},
+ },
+ "project-1",
+ )
+
+ expect(axios.put).toHaveBeenCalledWith(
+ `${BASE}/endpoint-1`,
+ {
+ endpoint: {
+ id: "endpoint-1",
+ auth_mode: "oauth",
+ secret_id: "secret-1",
+ data: {route: {base_url: "https://mcp.example.com"}},
+ },
+ },
+ {params: {project_id: "project-1"}},
+ )
+ })
+
+ it("omits the project id param when absent", async () => {
+ vi.mocked(axios.post).mockResolvedValue({data: {count: 1, scopes_offered: []}})
+
+ await discoverMcpConnect("endpoint-1")
+
+ expect(axios.post).toHaveBeenCalledWith(
+ `${BASE}/endpoint-1/connect`,
+ {},
+ {params: undefined},
+ )
+ })
+})
diff --git a/web/oss/src/services/mcpEndpoints/api.ts b/web/oss/src/services/mcpEndpoints/api.ts
new file mode 100644
index 0000000000..0aaaa46316
--- /dev/null
+++ b/web/oss/src/services/mcpEndpoints/api.ts
@@ -0,0 +1,78 @@
+// Raw axios, not the Fern client: this domain has no generated client yet
+// (specs-wp18.md "Deliberate, not an oversight"). Swap for Fern once regenerated.
+import axios from "@/oss/lib/api/assets/axiosConfig"
+import {getAgentaApiUrl} from "@/oss/lib/helpers/api"
+
+import {
+ MCPConnectResponse,
+ MCPEndpointCreate,
+ MCPEndpointEdit,
+ MCPEndpointResponse,
+ MCPEndpointsResponse,
+} from "./types"
+
+const BASE = "/gateways/mcps/endpoints"
+
+export const listMcpEndpoints = async (projectId?: string): Promise => {
+ const response = await axios.get(`${getAgentaApiUrl()}${BASE}/`, {
+ params: projectId ? {project_id: projectId} : undefined,
+ })
+ return response.data
+}
+
+export const createMcpEndpoint = async (
+ endpoint: MCPEndpointCreate,
+ projectId?: string,
+): Promise => {
+ const response = await axios.post(
+ `${getAgentaApiUrl()}${BASE}/`,
+ {endpoint},
+ {params: projectId ? {project_id: projectId} : undefined},
+ )
+ return response.data
+}
+
+export const editMcpEndpoint = async (
+ endpoint: MCPEndpointEdit,
+ projectId?: string,
+): Promise => {
+ const response = await axios.put(
+ `${getAgentaApiUrl()}${BASE}/${endpoint.id}`,
+ {endpoint},
+ {params: projectId ? {project_id: projectId} : undefined},
+ )
+ return response.data
+}
+
+export const deleteMcpEndpoint = async (endpointId: string, projectId?: string): Promise => {
+ await axios.delete(`${getAgentaApiUrl()}${BASE}/${endpointId}`, {
+ params: projectId ? {project_id: projectId} : undefined,
+ })
+}
+
+// Step 1: discover — omit `scopes` to get the checklist (specs-wp18.md).
+export const discoverMcpConnect = async (
+ endpointId: string,
+ projectId?: string,
+): Promise => {
+ const response = await axios.post(
+ `${getAgentaApiUrl()}${BASE}/${endpointId}/connect`,
+ {},
+ {params: projectId ? {project_id: projectId} : undefined},
+ )
+ return response.data
+}
+
+// Step 2: begin — `scopes` present (possibly empty) returns the redirect.
+export const beginMcpConnect = async (
+ endpointId: string,
+ scopes: string[],
+ projectId?: string,
+): Promise => {
+ const response = await axios.post(
+ `${getAgentaApiUrl()}${BASE}/${endpointId}/connect`,
+ {scopes},
+ {params: projectId ? {project_id: projectId} : undefined},
+ )
+ return response.data
+}
diff --git a/web/oss/src/services/mcpEndpoints/types.ts b/web/oss/src/services/mcpEndpoints/types.ts
new file mode 100644
index 0000000000..1dfeea7048
--- /dev/null
+++ b/web/oss/src/services/mcpEndpoints/types.ts
@@ -0,0 +1,85 @@
+// Mirror of api/oss/src/core/gateways/mcps/dtos.py + apis/fastapi/gateways/mcps/models.py
+// IMPORTANT: Do not add fields that don't exist in the backend.
+
+export type MCPAuthMode = "oauth" | "api_key" | "none"
+export type MCPEndpointNamespace = "builtin" | "standard" | "custom"
+
+export interface MCPOAuthData {
+ resource?: string | null
+ authorization_server?: string | null
+ scopes_offered?: string[]
+}
+
+export interface MCPEndpointRoute {
+ base_url?: string | null
+ headers?: Record | null
+}
+
+export interface MCPToolFilter {
+ include?: string[] | null
+ exclude?: string[] | null
+}
+
+export interface MCPEndpointSettings {
+ timeout_seconds?: number | null
+}
+
+export interface MCPEndpointData {
+ route: MCPEndpointRoute
+ tools?: MCPToolFilter
+ settings?: MCPEndpointSettings
+ oauth?: MCPOAuthData | null
+}
+
+export interface MCPEndpointFlags {
+ is_active?: boolean
+ is_valid?: boolean
+}
+
+export interface MCPEndpoint {
+ id: string
+ slug?: string | null
+ name?: string | null
+ description?: string | null
+ auth_mode: MCPAuthMode
+ namespace?: MCPEndpointNamespace
+ secret_id?: string | null
+ data: MCPEndpointData
+ flags?: MCPEndpointFlags
+}
+
+export interface MCPEndpointCreate {
+ slug?: string | null
+ name?: string | null
+ description?: string | null
+ auth_mode: MCPAuthMode
+ secret_id?: string | null
+ data: MCPEndpointData
+ flags?: MCPEndpointFlags
+}
+
+export interface MCPEndpointEdit {
+ id: string
+ name?: string | null
+ description?: string | null
+ auth_mode: MCPAuthMode
+ secret_id?: string | null
+ data: MCPEndpointData
+ flags?: MCPEndpointFlags
+}
+
+export interface MCPEndpointResponse {
+ count: number
+ endpoint?: MCPEndpoint | null
+}
+
+export interface MCPEndpointsResponse {
+ count: number
+ endpoints: MCPEndpoint[]
+}
+
+export interface MCPConnectResponse {
+ count: number
+ redirect_url?: string | null
+ scopes_offered?: string[]
+}
diff --git a/web/oss/src/state/mcpEndpoints/atoms.ts b/web/oss/src/state/mcpEndpoints/atoms.ts
new file mode 100644
index 0000000000..ee9aa12fe6
--- /dev/null
+++ b/web/oss/src/state/mcpEndpoints/atoms.ts
@@ -0,0 +1,50 @@
+import {atom} from "jotai"
+import {atomWithQuery} from "jotai-tanstack-query"
+
+import {queryClient} from "@/oss/lib/api/queryClient"
+import {
+ createMcpEndpoint,
+ deleteMcpEndpoint,
+ editMcpEndpoint,
+ listMcpEndpoints,
+} from "@/oss/services/mcpEndpoints/api"
+import {MCPEndpointCreate, MCPEndpointEdit} from "@/oss/services/mcpEndpoints/types"
+import {projectIdAtom} from "@/oss/state/project"
+
+export const mcpEndpointsAtom = atomWithQuery((get) => {
+ const projectId = get(projectIdAtom)
+
+ return {
+ queryKey: ["mcp-endpoints", projectId],
+ queryFn: async () => {
+ const response = await listMcpEndpoints(projectId ?? undefined)
+ return response.endpoints
+ },
+ staleTime: 30_000,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ enabled: !!projectId,
+ }
+})
+
+const invalidateMcpEndpoints = () => queryClient.invalidateQueries({queryKey: ["mcp-endpoints"]})
+
+export const createMcpEndpointAtom = atom(null, async (get, _set, endpoint: MCPEndpointCreate) => {
+ const projectId = get(projectIdAtom)
+ const result = await createMcpEndpoint(endpoint, projectId ?? undefined)
+ await invalidateMcpEndpoints()
+ return result.endpoint ?? null
+})
+
+export const editMcpEndpointAtom = atom(null, async (get, _set, endpoint: MCPEndpointEdit) => {
+ const projectId = get(projectIdAtom)
+ const result = await editMcpEndpoint(endpoint, projectId ?? undefined)
+ await invalidateMcpEndpoints()
+ return result.endpoint ?? null
+})
+
+export const deleteMcpEndpointAtom = atom(null, async (get, _set, endpointId: string) => {
+ const projectId = get(projectIdAtom)
+ await deleteMcpEndpoint(endpointId, projectId ?? undefined)
+ await invalidateMcpEndpoints()
+})